Skip to content

v1.13.0

Latest

Choose a tag to compare

@sdogruyol sdogruyol released this 24 Aug 11:33
· 2 commits to master since this release
  • (SECURITY) Delete uploaded temporary files for every request, not only for requests that reach Kemal::RouteHandler #776. Kemal::ParamParser spools multipart file parts to File.tempfile as soon as anything touches params — including params.body on a multipart request, which writes every file part out just to read one form field — but cleanup ran in the route handler's ensure. Anything that answered before the route handler leaked those files permanently: a before filter that halts into a custom error handler (Kemal's documented auth pattern), an exception raised in a filter, and middleware that responds without calling the next handler. An unauthenticated client could therefore fill the disk one rejected upload at a time — with default settings, 20 curl requests left 153 MB behind for good. Cleanup now lives in Kemal::InitHandler, which heads the handler chain, so it runs however the request ends. A handler registered ahead of it with use handler, 0 still owns the cleanup for uploads it parses itself, as that position already opts out of everything else Kemal::InitHandler does. Thanks @canermastan for the report 🙏

  • Add HTTP QUERY method support (RFC 10008) #762: query route DSL, Kemal::Router#query, and before_query / after_query filters. A QUERY request that has a body but no Content-Type header is rejected with 400 per the RFC; media-type decisions (415/406/422) and the Accept-Query response header remain in the application's hands. Thanks @canermastan for the request 🙏

query "/search" do |env|
  q = env.params.json["q"]? # or env.params.body for form-encoded queries
  search_products(q).to_json
end
  • (SECURITY) Run the GET filters for HEAD requests served by the GET route (GHSA-jf9q-62h3-924j). Kemal serves a HEAD request with the GET route when no explicit HEAD route exists, but Kemal::FilterHandler dispatched verb specific filters on the literal request method. before_get and after_get were therefore skipped while the GET handler still ran, so HEAD /admin/users bypassed a before_get authentication filter — Kemal's documented auth pattern — executed the protected handler along with its side effects, returned the headers that handler set, and left no after_get audit record. Filters now run for both the request method and the method of the route that serves it, so the GET filters guard HEAD while filters registered for HEAD keep firing. A route registered explicitly for HEAD is unaffected. Thanks @JirayuThongchotchaung for the report 🙏

  • (SECURITY) Scope Kemal::Handler only / exclude by the route that serves the request as well as by the request method, so HEAD cannot slip past middleware scoped to GET. This is the same defect as the filter fix above, in the sibling API: only ["/admin/*"] — the GET default — did not match HEAD /admin/users, so authentication middleware never ran while the GET handler executed and returned the headers it set. Rules scoped to HEAD keep matching, and verbs that carry their own handler are untouched: a POST rule still ignores HEAD. exclude follows the same rule, so a HEAD request served by an excluded GET route is now excluded too — matching what that route already does for GET.

  • (SECURITY) Register Kemal::Router filters whose path ends in /*. register_filters treated the trailing * as a literal path segment, so router.before_get "/*" and router.before_get "/admin/*" matched no route and were silently registered nowhere — a router-scoped filter used for authentication never ran, for any HTTP method. A trailing /* now marks a subtree, so "/admin/*" scopes the filter to the same routes as "/admin", and "/*" covers every route in the router just like "*". Filter paths without a glob are unchanged.

  • Register a Kemal::Router filter once per path instead of once per route on that path. A path carrying several methods — router.get "/users" plus router.post "/users" — got the same filter block appended once per method, so the filter ran that many times for a single request, double-counting rate limits and duplicating audit records.

  • Drop the cached HEADGET fallback for a path when a HEAD route is registered for it afterwards. The stale cache entry kept routing HEAD to the GET handler, and it now also selects which verb scoped filters and only / exclude rules apply.

  • (SECURITY) Bound the byte ranges send_file serves for a single Range request header. Ranges were served unchecked, and since an open-ended bytes=0- expands to the whole file, a header such as bytes=0-,0-,0-,... turned one 16 KB request into a response thousands of times the file's size (the CVE-2011-3192 "Apache Killer" pattern). Affects any app serving static files, which is the default. A range set is now ignored — and the full representation served instead, as RFC 9110 §14.2 allows — when it lists more than Kemal.config.max_ranges parts (16 by default) or asks for more bytes in total than the file holds. Requests within those limits are unchanged. Ignored and unsatisfiable Range headers now take the same path as a plain GET, so they are compressed as usual. Thanks @onurcangnc for the report 🙏

# Allow more parts per Range request, or set to 0 to ignore Range headers entirely
Kemal.config.max_ranges = 16
  • (SECURITY) Escape the request path and the exception message on the development error page. Both reached the exception_page template unescaped, so a crafted URL — or user input interpolated into an exception message, e.g. raise "User #{name} not found" — could run JavaScript in the visitor's browser (reflected XSS). The page is now also served with a restrictive Content-Security-Policy. Only affects Kemal.config.env == "development", which is the default; the production error page never reflected request data. Thanks @onurcangnc for the report 🙏

  • (SECURITY) WebSocket Origin validation is same-origin by default (CSWSH). An empty websocket_allowed_origins now requires Origin to match the request Host (scheme taken from Origin, so reverse-proxy TLS termination keeps working). Missing or empty Origin is rejected with 403. Set Kemal.config.websocket_allowed_origins = ["*"] to opt into allowing any origin, including requests without Origin. Explicit allowlists behave as before. Thanks @YasinSeyhun
    for the report 🙏

# Default: same-origin (secure)
# Kemal.config.websocket_allowed_origins = [] of String

# Explicit allowlist
Kemal.config.websocket_allowed_origins = ["https://myapp.com", "http://localhost:3000"]

# Previous allow-all behavior (opt-in)
Kemal.config.websocket_allowed_origins = ["*"]
  • (SECURITY) Prevent SSE injection in Kemal::EventStream: reject newlines in event/id, normalize CR/LF in data/comment. Thanks @hahwul for the report. Thanks @sdogruyol for the fix 🙏

  • Fix URL params being decoded again on every request when route lookup results are cached. Thanks @hahwul for the report. Thanks @sdogruyol for the fix 🙏

  • Add only / exclude opt-in matching for all HTTP methods ("*") and path prefixes ("/*"). Defaults remain GET + exact path. Clarified docs and the basic-auth custom handler example. Thanks @hahwul for the report. Thanks @sdogruyol 🙏

  • Fix only / exclude with method "*" over-matching every path: Radix treats * as a glob, so the all-methods marker is stored under a safe sentinel. Thanks @hahwul for the report. Thanks @sdogruyol 🙏

  • (SECURITY) Close the HTTP connection after a rejected WebSocket upgrade (403). Without Connection: close, a compound Connection: keep-alive, Upgrade request that fails Origin validation left the connection keep-alive, so a request pipelined behind a reverse proxy that tunnels upgrades could bypass the proxy’s access controls (WebSocket connection smuggling). Malformed Origin values that previously raised from URI.parse now reject with 403 instead of 500.

  • WebSocket upgrades now require the GET method per RFC 6455 §4.1 #770. Any other method (POST, QUERY, ...) carrying valid upgrade headers previously completed the handshake; it is now rejected with 405 Method Not Allowed, an Allow: GET header, and Connection: close — matching the broader ecosystem (gorilla/websocket, Node ws, python-websockets).

  • Respond 400 instead of 500 for malformed request bodies (invalid JSON, unparseable multipart) #772. Thanks @sdogruyol 🙏

  • Fix Int32 overflow in the SSE retry field for spans beyond ~24.8 days #771. Thanks @sdogruyol 🙏

  • Disable the X-Powered-By header by default. Set Kemal.config.powered_by_header = true to restore the previous behavior.

  • Add Crystal-Kemal agent skills for routing, WebSockets, SSE, JSON APIs, middleware, uploads, auth, and related domains #774 #777.