Download Latest Version v1.14.0 source code.zip (228.8 kB) Google Add to Preferred Sources
Home / v1.14.0
Name Modified Size InfoDownloads / Week
Parent folder
README.md 2026-09-15 27.5 kB
v1.14.0 source code.tar.gz 2026-09-15 175.8 kB
v1.14.0 source code.zip 2026-09-15 228.8 kB
Totals: 3 Items   432.1 kB 0
  • (BREAKING) Answer a request whose path is routed for another HTTP method with 405 Method Not Allowed and the Allow header instead of 404, per RFC 9110 §15.5.6. With only get "/posts" registered, POST /posts and OPTIONS /posts returned 404 and reached the error 404 handler; they now return 405 with Allow: GET, HEAD and reach error 405. Anything asserting 404 for a wrong-method request — tests, client retry logic, monitoring rules — has to be updated. A path that is not routed at all is still a 404; ws routes are covered by the entry below. HEAD appears in Allow wherever a GET route exists, matching the HEAD -> GET fallback. The Allow header is set before the error handler runs, so a custom error 405 owns the body but cannot drop the header the RFC makes mandatory; without one, the body is the plain Method Not Allowed. Registering error 405 also makes before_all filters run for every unmatched request, plain 404s included - the same over-approximation the existing error 404 branch already had, now reachable through a second status code. Automatic OPTIONS responses are not part of this change.

    :::crystal get "/posts" do "posts" end

    POST /posts -> 405, Allow: GET, HEAD

    GET /nope -> 404

    error 405 do |env| # Kemal::InitHandler presets Content-Type: text/html for every response env.response.content_type = "application/json" {error: "Method not allowed", allow: env.response.headers["Allow"]}.to_json end

  • Refuse an invalid command-line argument with a message and exit 1 instead of an unhandled exception. kemal -p abc ended in Unhandled exception: Invalid Int32: " abc" (ArgumentError) with a stack trace through OptionParser, and an unknown flag or a flag missing its argument propagated OptionParser::InvalidOption/MissingOption the same way; all three now print what was wrong - the unknown-flag and missing-argument cases with the usage banner - and abort, the way an SSL configuration error already did. A port is also checked against 0..65535 before it reaches HTTP::Server#bind_tcp, which reports one outside that range as Hostname lookup for 127.0.0.1 failed: No address found - an error naming the host for a problem with the port. Port 0 stays valid and still asks the operating system for a free port, and surrounding whitespace is refused rather than trimmed, so --port " 8080" is reported instead of quietly accepted. An application that registers invalid_option or missing_option through Kemal::Config#extra_options still owns them, since Kemal registers its own first; code that rescued the old exceptions around Kemal.run no longer gets the chance, as the process exits at the bad argument.

  • Hand a non-GET request carrying WebSocket upgrade headers to the HTTP route that serves its method, when there is one. ws "/chat" next to post "/chat" answered a POST /chat with Upgrade: websocket as 405, Allow: GET, POST - a 405 naming the method it refused. A POST is a POST; it goes to the route now. A method nothing serves on the path is still a 405, and its Allow can no longer contain the refused method.

  • Link the handler behind use "/prefix", handler to the rest of the chain once, when the chain is built, instead of on every matching request. Kemal::PathHandler wrote handler.next = self.next per request - a write to shared state on the hot path that two requests running in parallel could race on, one re-aiming the handler while the other was about to call through it. A handler instance belongs to one use.

  • Have an error page replace a body written before the error, instead of following it. A route that printed to env.response and then set a status with a registered error handler, or raised, sent the partial body with the error page appended - partial body forbidden page - and a HEAD counted both into Content-Length. The body is buffered until the headers go out, so up to that point it is discarded before the page is written (HTTP::Server::Response#discard_unsent_body); a body that has already reached the client stays, as before, since nothing can replace it.

  • Stop holding a file descriptor per upload for the rest of the request #801. Kemal::FileUpload kept the handle its temporary file was written through open until cleanup, so a request at the max_file_uploads cap held 128 descriptors and eight such requests exhausted the default ulimit -n 1024. The handle is now closed as soon as the upload is spooled. Read an upload with the new FileUpload#open(&), which yields a File and closes it, or move it by its new path; FileUpload#tempfile is deprecated — it still returns an open handle at the file's start, kept until cleanup as before, so existing code keeps working and pays the descriptor only while it does. FileUpload is a class now rather than a struct; nothing depended on copy semantics, and a reference is what every call site assumed.

  • Answer HEAD on a file send_file would send as it is from the file's size, without producing the body. Kemal::HeadRequestHandler learns Content-Length by running the GET handler into a counting sink, so a HEAD on a 20 GB download read 20 GB from disk to throw it away (noted in #803). When the stored bytes go out unchanged - no compression, or a pre-compressed neighbour standing in - the length is the file's own and the read is skipped. The file is still opened, so a HEAD fails where the GET would. A body Kemal compresses on the way out is still produced; its length is only known once it has been.

  • Make the test environment behave like every other one, and let before_all/after_all work in specs #788. Three things differed between a spec run and production:

  • An unmatched route answered an empty 200 under KEMAL_ENV=test. Kemal.run registered the default 404 page only outside that environment, and without a registered error 404 a RouteNotFound rendered nothing. The page is now registered in every environment, and a RouteNotFound with no handler renders a plain 404 Not Found like the other built-in errors do.

  • before_all ran for an unmatched path — a 404, a 405, or a WebSocket path reached without an upgrade — only when a custom error 404/405 handler was registered, which Kemal.run did in production and nothing did under spec. An authentication guard in before_all therefore held in production and not in the tests that were supposed to prove it. It runs for every unmatched path now, in every environment.
  • Kemal's top-level before_all and after_all shadow the describe-level hooks of the spec library, so a spec calling before_all { seed } inside a describe registered a request filter and never ran the block, with no error. Called inside a describe they now register the spec hook; called anywhere else — an application file, a route, an example — they register Kemal's filter as before.

Anything asserting an empty 200 for an unknown path in the test environment, or relying on before_all not running for unmatched paths under spec, has to be updated.

  • Build the Content-Disposition of send_file per RFC 6266 and RFC 8187. The filename was dropped into the quoted-string as it was: a " in it ended the parameter early, a non-ASCII name went out raw where the parameter is defined as ASCII, and a control character made the standard library reject the header with a 500. " and \\ are now escaped, other characters outside printable ASCII become _ in filename, and when that loses anything the original name follows as filename*=UTF-8''…, which user agents prefer. A plain ASCII name produces the same header as before. Kemal::Utils.content_disposition is the builder.

  • params.raw_body returns the body of any request, not only a form or JSON one. It came back empty for text/plain, XML, or a request with no Content-Type at all, so such a body looked absent rather than unread; it is now read and cached the same way, under max_request_body_size. A multipart/form-data body is the one exception and still returns "", since parse_files streams it part by part. JSON detection now goes by media type instead of a string prefix: application/vnd.api+json and other +json types (RFC 6839) parse into params.json, Application/JSON matches, and application/jsonp no longer does.

  • Skip the WebSocket upgrade when a before filter has already answered. A halt in a before_all — an authentication check answering 401 — closed the response, and Kemal::WebSocketHandler went on to attempt the upgrade regardless; the standard library handler raised IO::Error: Closed stream on the closed response. The client had its 401, but every rejected handshake was logged as a server error. The handler now returns once it finds the response closed.

  • Stop confirming that a directory under public/ exists when nothing is served for it. The standard library adds the trailing slash to a directory URL whenever its own directory_listing flag is on, and Kemal never set that flag from serve_static, so with dir_listing and dir_index both off /admin still answered 302 /admin/ while /nope answered 404 — the redirect was the only difference, and it told a scanner which directories are there. The redirect now happens only when a listing or an index.html would be served at the slashed URL; otherwise a directory falls through to the same 404 as a missing path.

  • Drop the bare rescue around url-param decoding. URI.decode passes a malformed percent-escape through unchanged rather than raising, so the rescue could only ever have hidden an unrelated bug as a silently undecoded value. The pass-through is now pinned by a spec.

  • Log an exception at error level before an error MyException handler renders it. Only the generic path and error 500 logged; an exception matched by a class handler went unlogged, so a catch-all error Exception do … end — the usual way to get a JSON 500 page — took every crash out of the log, and any reporter reading Log never saw it. The handler still owns the response. Applications that use exception handlers for expected control flow will see an error line per occurrence; that was already the case for error 500.

  • Send the built-in error body for 400, 405 and 413 as text/plain. Kemal::ExceptionHandler meant to, but only set the type when none was present, and Kemal::InitHandler presets text/html on every response before any handler runs, so Method Not Allowed, Payload Too Large and Bad Request went out as HTML — or as whatever type the route had set before it raised. The body is Kemal's own, so the type now is too. Custom error handlers are unaffected and still own their content type.

  • HTTP::Server::Context#redirect sets Location instead of adding to it. A before filter that redirected with close: false, followed by a route that redirected elsewhere, sent both Location headers; the client got the last one, or the first, or refused the response, depending on the client. The later redirect now replaces the earlier one.

  • Kemal::Config#clear now also resets serve_static, public_folder, static_headers, shutdown_message and shutdown_timeout, so a spec that sets one of them no longer leaks it into the next. CI gains three variant runs on the current compiler — --release, -Dwithout_zlib and -Dwithout_openssl, the two flags with code paths of their own in src/ — and a run with the default execution context widened to four workers (Crystal 1.21; set KEMAL_SPEC_WORKERS to do the same locally), so the request path is exercised from several threads. Specs that need zlib are skipped under -Dwithout_zlib, and every spec file now compiles on its own.

  • Count the discarded body of a HEAD request in an Int64 #803. Kemal::HeadRequestHandler swallows the body a HEAD request generates and counts the bytes to set Content-Length, and the counter was an Int32; Crystal checks integer overflow, so the 2 147 483 648th byte raised OverflowError, which Kemal::ExceptionHandler — further down the chain — turned into a 500. HEAD on a file of 2 GiB or more therefore answered 500 where the matching GET answered 200, with the threshold at exactly Int32::MAX. A route generating a body over that size also reported the partial count as its Content-Length, since NullIO sets the header from the counter when the handler has not set one itself. HTTP::Server::Response#content_length already returns Int64?, so the counter was the only Int32 left in the path.

  • Spool an upload through the handle File.tempfile already opened, instead of opening the path a second time to write #801. One descriptor and one open per upload rather than two, and the file is no longer reopened by name right after being created by descriptor. FileUpload#tempfile still comes back open for reading and positioned at the start. FileUpload#size is now the number of bytes written to disk; it used to be the size the part's Content-Disposition claimed, which the client chooses.

  • Finish the requests in flight before exiting on a termination signal #795. Kemal.stop closed the listeners and then slept for shutdown_timeout, but HTTP::Server#listen returns as soon as the listeners close, so Kemal.run returned, the program ended and the process exited a few milliseconds after SIGTERM — before the sleep ran, cutting off whatever was being served, and for any value of shutdown_timeout. Kemal.run now returns only once every request that was in flight when the server stopped has finished, or shutdown_timeout has elapsed, whichever comes first; with nothing in flight it returns at once. shutdown_timeout therefore changes from a fixed sleep into an upper bound, and its default from 0 to 30.seconds, since 0 would keep cutting requests off. Kemal.stop itself no longer blocks. The signal handler no longer calls exit: Kemal.run returns after the drain and the program carries on to its end, so code after Kemal.run now runs on SIGTERM as it already did on Kemal.stop. A second signal during the drain exits immediately. A WebSocket or SSE connection counts as in flight for as long as it stays open; an application holding such connections waits the full shutdown_timeout unless it closes them itself. The count is exposed as Kemal::InitHandler::INSTANCE.in_flight, and Kemal.config.running is set to false before the listeners close so a health route can report the drain.

  • Count a ws route as GET when collecting the Allow header, since the WebSocket handshake is a GET request (RFC 6455 §4.1). Two fixes:

  • A path served only by ws now answers wrong-method requests with 405 and Allow: GET. Previously Kemal::WebSocketHandler passed a non-handshake request straight through and the route lookup missed, so POST /chat ended as a 404 — or, with no error 404 handler registered, an empty 200.

  • A path carrying both ws and HTTP routes no longer reports an Allow that omits the handshake: with ws "/chat" and post "/chat", PUT /chat answers Allow: GET, POST rather than Allow: POST.

  • Kemal::WebSocketHandler no longer hardcodes Allow: GET when it rejects a non-GET upgrade attempt. Allow describes the methods the resource supports (RFC 9110 §10.2.1), so with ws "/chat" and post "/chat" a POST carrying Upgrade: websocket now answers Allow: GET, POST instead of sending the client away from a verb the path really serves. A path with only a ws route still answers Allow: GET.

HEAD is not advertised for a ws route — the handshake is the only thing served there. And a request whose own method is already in the Allow list is not a 405: a plain GET on a ws path is a handshake missing its Upgrade header, so it stays a 404 instead of being told 405, Allow: GET. 426 Upgrade Required (RFC 9110 §15.5.22) was considered for that case and deliberately not adopted: a plain GET there is a client bug, and 404 versus 426 does not change what the client has to do.

:::crystal
ws "/chat" do |socket, env|
  socket.send("hi")
end

post "/chat" do
  "post"
end

# PUT  /chat -> 405, Allow: GET, POST
# GET  /chat -> 404 (no `Upgrade` header, so not a handshake)
  • Report a static file that exists but cannot be opened as 404, as the standard library's HTTP::StaticFileHandler does. Kemal's override served files through send_file without the stdlib's File::Error rescue, so a file under public/ without read permission answered 500 — with the absolute path on the development error page, and the file's ETag and Last-Modified on the response, confirming to the client that the file was there. The response is now the plain 404 the stdlib sends, with no header derived from the file.

  • Answer HEAD on an sse route with the stream's headers instead of running the handler. HEAD is served from the GET route with the body discarded, and an SSE handler is typically an endless loop that only stops when a write fails because the client has gone. On HEAD nothing it wrote went anywhere, so the loop never saw the client leave: every HEAD to an SSE endpoint pinned a fiber for the life of the process, and a client that sent a few hundred of them exhausted it without authenticating. Kemal::EventStream.serve now sets the headers and returns without yielding when the request method is HEAD.

  • Fix content_for blocks leaking between concurrent requests #789. Captured blocks were kept in a process-global CONTENT_FOR_BLOCKS hash, so two requests rendering the same view at once overwrote each other: whichever fiber reached the layout second ran the other request's block, which wrote into the other request's buffer. One page lost its yield_content output and could pick up the other's instead, with no error raised. Any fiber switch between the view and the layout — a database call in a view, or -Dpreview_mt — triggered it. Blocks are now held in a local that render(view, layout) declares, so each render sees only what it captured itself. CONTENT_FOR_BLOCKS is gone, and content_for/yield_content are only usable inside a render(view, layout) call (directly in the view, or in a partial it renders); the file argument content_for took to tell views apart is no longer needed and has been removed.

  • Fix Range handling in send_file per RFC 9110 §14 and §15.5.17. The parser could not tell an omitted last-pos from 0, required the last-pos to be strictly greater than the first-pos, and dropped ranges that reached the end of the file: bytes=0-0 served the whole file as a 206, bytes=5-5 and bytes=17- fell back to a 200, and bytes=0-4,7-7 silently lost its second part. A last-pos beyond the end is now clamped (bytes=0-99999 → 0-17/18), suffix ranges are supported (bytes=-5 → 13-17/18), a valid range set none of whose ranges is satisfiable is answered with 416 and Content-Range: bytes */<length> instead of 200, and a byte position that overflows Int64 counts as beyond the end of the file instead of 0. Each part of a multipart/byteranges response now carries the media type of the file rather than multipart/byteranges; boundary=.... A malformed Range header, a unit other than bytes, Range on HEAD, and range sets refused as abusive (see Kemal.config.max_ranges) are still served as a plain 200.

  • Fix content coding negotiation in send_file and static file serving, per RFC 9110 §8.4, §12.5.3 and §12.5.5:

  • Content-Encoding: deflate now carries the zlib format of RFC 1950, which is the deflate coding RFC 9110 §8.4.1.2 defines. Kemal sent a bare RFC 1951 stream; browsers sniff that, stricter clients do not.

  • Accept-Encoding qvalues are honored, so gzip;q=0 no longer gets a gzip body. The highest ranked coding wins, and * and identity;q=0 are understood. The parser is Kemal::Utils.select_content_coding.
  • A response whose representation Accept-Encoding selects now carries Vary: Accept-Encoding, whether or not this particular response was compressed, added to any Vary the application already set. Without it a shared cache serves one client the variant it stored for another.
  • A pre-compressed app.js.gz (Crystal 1.17+) is served with the media type of app.js instead of application/octet-stream, is not compressed a second time, and is chosen with the same qvalue rules rather than the standard library's word match. A multi-range request for it falls back to the whole body, since the multipart/byteranges envelope carrying the parts is not itself encoded.
  • The encoded variant gets an entity tag of its own (W/"1700000000-gzip"), so a cache cannot mistake it for the identity representation, and If-None-Match is matched against the tag the request would actually be answered with.

Range responses stay uncompressed, as before. gzip true installs the standard library's HTTP::CompressHandler, which has none of these fixes; prefer serve_static({"gzip" => true}) for static assets.

  • Omit the SSE retry field for negative Time::Span values instead of emitting e.g. retry: -3000, which clients discard per the SSE spec. The value now renders via .to_u64; output for non-negative spans is unchanged.

  • Skip filter tree lookups when no path-scoped filters are registered. Apps using only global filters (before_all and friends) no longer pay 4-6 radix lookups and key allocations per request #781.

  • Cache the Date response header string per second instead of formatting it on every request. The value is unchanged: the string is reused only within the same UTC second #781.

  • (SECURITY) Refuse a request whose method is not an RFC 9110 §5.6.2 token with 400 Bad Request #820. Kemal keys its routing tree on the method concatenated with the path and Crystal's parser passes the method through unvalidated, so a method carrying a / chose where the one ended and the other began: GET/admin + /secret produced the same key as GET + /admin/secret and reached that route, while use "/prefix", only/exclude and before_*/after_* all match on request.path and saw only /secret - an unauthenticated bypass of path-scoped authorization on any route, in a single request. GET/account + / left request.path as /, defeating exact-path guards too. Kemal::RouteHandler now refuses such a method where that key is built, so nothing is routed on a method and a path that cannot both be believed; Kemal::Utils.valid_method? is the check it asks. It is raised like every other client error Kemal produces, so a registered error 400 renders it and the access log records it. The refusal closes the connection (Connection: close), since a request line Kemal and an intermediary read differently is what request smuggling is built on. Unfamiliar but well-formed methods are untouched: a PROPFIND still reaches the router and gets its answer. Thanks @yvzkr for the report :pray:

  • (SECURITY) Cap the number of file parts in a multipart/form-data request #793. max_request_body_size bounds the bytes but said nothing about the parts, and every file part is spooled to its own temporary file whose handle stays open until the request is over: an 8 MB body of one-byte parts held some 100,000 open file descriptors and temporary files for the length of one request, enough to take a process with the default ulimit -n 1024 past accept() from a single unauthenticated request. Kemal.config.max_file_uploads (default 128) now bounds the file parts; a request carrying more is answered with 413 and the body Too many file parts (max N) before the next part is written to disk, and the parts already spooled are cleaned up with the request. Form fields without a filename are not counted. Kemal::Exceptions::PayloadTooLarge.new now takes an optional message for this. Two related leaks are closed as well: a file part whose field name repeats an earlier one used to replace that upload in params.files without removing its temporary file, leaving it on disk for good; and params.all_files did not parse the body on its own, so it was always empty unless params.files had been read first.

  • (SECURITY) Stop reflecting the request into Kemal::Exceptions::RouteNotFound#message. It read Requested path: 'GET:/…' was not found. with the method and path verbatim, and an error 404 handler that returns ex.message — the obvious thing to write — sent that back as text/html, so GET /<script>…</script> was a reflected XSS. The message is now the bare Not Found, as MethodNotAllowed's already was; the request stays reachable through the new RouteNotFound#context. The error docs now show HTML.escape around a message built from request data.

  • (SECURITY) Serve the development error page only in the development environment #791. The choice between the development page — exception message, backtrace with source, response headers, cookies — and the static production page was env == "production", so every other value of KEMAL_ENV got the development page: staging, test, and typos such as prod or Production all disclosed the failure to the client. The check is now env == "development"; an environment Kemal does not recognize gets the production page. Kemal.config.show_exceptions = true shows the development page in another environment, false never shows it, and unset (the default) follows the environment. A registered error 500 handler is unaffected; the setting only chooses between the two built-in pages. Apps that relied on the development page in an environment other than development need to set show_exceptions.

Source: README.md, updated 2026-09-15