Kemal is a Fast, Effective, Simple web framework for Crystal.

Hello everyone,

Kemal 1.14.0 is out :tada: This release is about HTTP correctness and security: wrong-method requests now answer 405, invalid methods are refused, multipart uploads are bounded, and several leaky edges around error pages, static files, and WebSockets are closed. It also adds a real graceful shutdown. If you run Kemal in production, upgrading is strongly recommended :shield:

Wrong method is 405 :no_entry_sign:

A path that exists for another HTTP method used to answer 404. With only get "/posts" registered, POST /posts and OPTIONS /posts reached the error 404 handler. They now return 405 Method Not Allowed with an Allow header, per RFC 9110 §15.5.6. A path that is not routed at all is still a 404. HEAD appears in Allow wherever a GET route exists.

get "/posts" do
  "posts"
end

# POST /posts -> 405, Allow: GET, HEAD
# GET /nope   -> 404

error 405 do |env|
  env.response.content_type = "application/json"
  {error: "Method not allowed", allow: env.response.headers["Allow"]}.to_json
end

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. Tests, clients, and monitoring that asserted 404 for a wrong-method request need to look for 405 instead.

A ws route counts as GET when collecting Allow. A path served only by WebSocket now answers a plain POST with 405, Allow: GET instead of 404. With ws "/chat" and post "/chat", PUT /chat answers Allow: GET, POST. A GET without an Upgrade header stays a 404 — it is a handshake missing its headers, not a wrong method.

A non-GET that carries WebSocket upgrade headers is handed to the HTTP route that serves its method, when there is one. POST /chat with Upgrade: websocket is a POST; it goes to post "/chat" instead of a 405 that named the method it refused.

Invalid methods cannot pick a route :lock:

Kemal keys its routing tree on the method concatenated with the path, and Crystal’s parser passed the method through unvalidated. 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 matched on request.path and saw only /secret — an unauthenticated bypass of path-scoped authorization in a single request.

A request whose method is not an RFC 9110 §5.6.2 token is now refused with 400 Bad Request and Connection: close (#820). Unfamiliar but well-formed methods are untouched: a PROPFIND still reaches the router. Thanks @yvzkr :pray:

Multipart uploads are bounded :wastebasket:

max_request_body_size bounds the bytes but said nothing about the parts. Every file part is spooled to its own temporary file, so an 8 MB body of one-byte parts held some 100,000 open descriptors and temp 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 (#793). A request carrying more is answered with 413 before the next part is written to disk. Form fields without a filename are not counted. Duplicate field names no longer leak the replaced temp file, and params.all_files parses the body on its own.

Kemal.config.max_file_uploads = 128

The handle behind each upload is now closed as soon as the file is spooled (#801). Read it 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, so existing code keeps working and pays the descriptor only while it does. FileUpload is a class now rather than a struct.

post "/upload" do |env|
  upload = env.params.files["image"]
  upload.open do |file|
    process(file)
  end
end

Error pages no longer leak the request :warning:

Kemal::Exceptions::RouteNotFound#message used to read Requested path: 'GET:/…' was not found. with the method and path verbatim. 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; the request stays reachable through the new RouteNotFound#context.

The development error page — exception message, backtrace, headers, cookies — was shown whenever KEMAL_ENV was not "production". staging, test, and typos such as prod all disclosed the failure to the client. The check is now env == "development" (#791); an environment Kemal does not recognize gets the production page.

# Show the development page in another environment
Kemal.config.show_exceptions = true

A registered error 500 handler is unaffected. Static files that exist but cannot be opened now answer 404 with no ETag or Last-Modified, instead of a 500 that confirmed the file was there. Directories under public/ no longer 302 to a trailing slash when nothing would be served at the slashed URL — that redirect was the only difference between /admin and /nope.

Graceful shutdown :stopwatch:

Kemal.stop closed the listeners and then slept for shutdown_timeout, but HTTP::Server#listen returns as soon as the listeners close, so the process exited a few milliseconds after SIGTERM — before the sleep ran, cutting off whatever was being served.

Kemal.run now returns only once every request that was in flight has finished, or shutdown_timeout has elapsed, whichever comes first (#795). The default is 30.seconds (0 would keep cutting requests off). Kemal.stop itself no longer blocks. The signal handler no longer calls exit: code after Kemal.run now runs on SIGTERM as it already did on Kemal.stop. A second signal during the drain exits immediately.

Kemal.config.shutdown_timeout = 30.seconds

A WebSocket or SSE connection counts as in flight for as long as it stays open. Kemal.config.running is set to false before the listeners close, so a health route can report the drain.

HEAD no longer reads the file :package:

Kemal::HeadRequestHandler learned 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 (#803). When send_file would send the stored bytes unchanged, the length is now the file’s own and the read is skipped. The counter is an Int64, so a file of 2 GiB or more no longer answers 500 from an overflow.

HEAD on an sse route sets the stream’s headers and returns without running the handler. An SSE handler is typically an endless loop that only stops when a write fails; on HEAD nothing it wrote went anywhere, so every HEAD pinned a fiber for the life of the process.

Specs match production :white_check_mark:

Three things differed between a spec run and production (#788):

  • An unmatched route answered an empty 200 under KEMAL_ENV=test. It is now a 404 in every environment.
  • before_all ran for an unmatched path only when a custom error 404 / 405 handler was registered — which Kemal.run did in production and nothing did under spec. An authentication guard therefore held in production and not in the tests that were supposed to prove it. It runs for every unmatched path now.
  • Kemal’s top-level before_all / after_all shadowed the describe-level hooks of the spec library. Called inside a describe they now register the spec hook; called anywhere else 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.

Range and content coding, per the RFC :straight_ruler:

send_file Range handling now follows RFC 9110 §14: suffix ranges (bytes=-5), clamping past the end of the file, 416 with Content-Range: bytes */<length> when nothing is satisfiable, and each part of a multipart/byteranges response carrying the media type of the file.

Content coding negotiation follows RFC 9110 §8.4 and §12.5.3:

  • Content-Encoding: deflate now carries the zlib format of RFC 1950 (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
  • A negotiated representation carries Vary: Accept-Encoding
  • A pre-compressed app.js.gz is served with the media type of app.js, is not compressed a second time, and gets an entity tag of its own

Prefer serve_static({"gzip" => true}) for static assets. gzip true installs the standard library’s HTTP::CompressHandler, which has none of these fixes.

send_file also builds Content-Disposition per RFC 6266 and RFC 8187: quotes are escaped, non-ASCII names go out as filename*=UTF-8''…, and a control character no longer makes the standard library reject the header with a 500.

Bodies, filters, and polish :wrench:

  • Error body: An error page now replaces a body written before the error, instead of appending to it. Built-in bodies for 400, 405, and 413 go out as text/plain.
  • params.raw_body: Returns the body of any request, not only a form or JSON one. JSON detection goes by media type: application/vnd.api+json and other +json types parse into params.json; application/jsonp no longer does.
  • WebSocket after halt: A before filter that already answered (e.g. 401) no longer attempts the upgrade and logs IO::Error: Closed stream.
  • content_for: Blocks no longer leak between concurrent requests (#789). They live in a local that render(view, layout) declares; the file argument is gone.
  • use "/prefix": The handler is linked into the chain once, when the chain is built, instead of on every matching request — a write to shared state that two fibers could race on.
  • CLI: An invalid argument prints a message and exits 1 instead of an unhandled exception. Ports are checked against 0..65535 before bind.
  • redirect: Sets Location instead of adding a second one.
  • Hot path: Skip filter tree lookups when no path-scoped filters are registered, and cache the Date response header string per UTC second (#781).

Upgrade notes :arrow_up:

A few defaults and answers changed. Check these if they apply:

# File part cap (default 128). Raise it for bulk-upload endpoints.
Kemal.config.max_file_uploads = 128

# Drain in-flight requests on SIGTERM (default 30 seconds).
Kemal.config.shutdown_timeout = 30.seconds

# Development error page outside KEMAL_ENV=development
Kemal.config.show_exceptions = true
  • Tests, clients, and monitors that expected 404 for a wrong-method request should expect 405 and an Allow header.
  • Specs that asserted an empty 200 for an unknown path under KEMAL_ENV=test should assert 404. before_all now runs for unmatched paths in every environment.
  • before_all / after_all inside a describe are spec hooks again; call them outside describe for Kemal filters.
  • Prefer FileUpload#open or #path over the deprecated #tempfile.
  • content_for / yield_content only work inside render(view, layout); the file argument is removed.
  • RouteNotFound#message is Not Found. Use ex.context if you need the request, and HTML.escape if you interpolate it into HTML.

Full change history and PR links are in the CHANGELOG. Release tag: v1.14.0.

For end-to-end sample apps (blog, JSON API, WebSockets, OAuth, and more), see Kemal by Example on GitHub.

Thanks to @sdogruyol, @hahwul, @yvzkr, @urunsiyabend, and @renich for this release :heart:

You can help sustain development via GitHub Sponsors :pray:

Happy Crystalling :heart: