-
(SECURITY) Delete uploaded temporary files for every request, not only for requests that reach
Kemal::RouteHandler#776.Kemal::ParamParserspools multipart file parts toFile.tempfileas soon as anything touchesparams— includingparams.bodyon a multipart request, which writes every file part out just to read one form field — but cleanup ran in the route handler'sensure. Anything that answered before the route handler leaked those files permanently: abeforefilter thathalts into a customerrorhandler (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 inKemal::InitHandler, which heads the handler chain, so it runs however the request ends. A handler registered ahead of it withuse handler, 0still owns the cleanup for uploads it parses itself, as that position already opts out of everything elseKemal::InitHandlerdoes. Thanks @canermastan for the report 🙏 -
Add HTTP QUERY method support (RFC 10008) #762:
queryroute DSL,Kemal::Router#query, andbefore_query/after_queryfilters. A QUERY request that has a body but noContent-Typeheader is rejected with400per the RFC; media-type decisions (415/406/422) and theAccept-Queryresponse 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
GETfilters forHEADrequests served by theGETroute (GHSA-jf9q-62h3-924j). Kemal serves aHEADrequest with theGETroute when no explicitHEADroute exists, butKemal::FilterHandlerdispatched verb specific filters on the literal request method.before_getandafter_getwere therefore skipped while theGEThandler still ran, soHEAD /admin/usersbypassed abefore_getauthentication filter — Kemal's documented auth pattern — executed the protected handler along with its side effects, returned the headers that handler set, and left noafter_getaudit record. Filters now run for both the request method and the method of the route that serves it, so theGETfilters guardHEADwhile filters registered forHEADkeep firing. A route registered explicitly forHEADis unaffected. Thanks @JirayuThongchotchaung for the report 🙏 -
(SECURITY) Scope
Kemal::Handleronly/excludeby the route that serves the request as well as by the request method, soHEADcannot slip past middleware scoped toGET. This is the same defect as the filter fix above, in the sibling API:only ["/admin/*"]— theGETdefault — did not matchHEAD /admin/users, so authentication middleware never ran while theGEThandler executed and returned the headers it set. Rules scoped toHEADkeep matching, and verbs that carry their own handler are untouched: aPOSTrule still ignoresHEAD.excludefollows the same rule, so aHEADrequest served by an excludedGETroute is now excluded too — matching what that route already does forGET. -
(SECURITY) Register
Kemal::Routerfilters whose path ends in/*.register_filterstreated the trailing*as a literal path segment, sorouter.before_get "/*"androuter.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::Routerfilter once per path instead of once per route on that path. A path carrying several methods —router.get "/users"plusrouter.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
HEAD→GETfallback for a path when aHEADroute is registered for it afterwards. The stale cache entry kept routingHEADto theGEThandler, and it now also selects which verb scoped filters andonly/excluderules apply. -
(SECURITY) Bound the byte ranges
send_fileserves for a singleRangerequest header. Ranges were served unchecked, and since an open-endedbytes=0-expands to the whole file, a header such asbytes=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 thanKemal.config.max_rangesparts (16 by default) or asks for more bytes in total than the file holds. Requests within those limits are unchanged. Ignored and unsatisfiableRangeheaders now take the same path as a plainGET, 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_pagetemplate 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 restrictiveContent-Security-Policy. Only affectsKemal.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_originsnow requiresOriginto match the requestHost(scheme taken fromOrigin, so reverse-proxy TLS termination keeps working). Missing or emptyOriginis rejected with 403. SetKemal.config.websocket_allowed_origins = ["*"]to opt into allowing any origin, including requests withoutOrigin. 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 inevent/id, normalize CR/LF indata/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/excludeopt-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/excludewith 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 compoundConnection: keep-alive, Upgraderequest 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). MalformedOriginvalues that previously raised fromURI.parsenow reject with 403 instead of 500. -
WebSocket upgrades now require the
GETmethod per RFC 6455 §4.1 #770. Any other method (POST,QUERY, ...) carrying valid upgrade headers previously completed the handshake; it is now rejected with405 Method Not Allowed, anAllow: GETheader, andConnection: close— matching the broader ecosystem (gorilla/websocket, Nodews, python-websockets). -
Respond 400 instead of 500 for malformed request bodies (invalid JSON, unparseable multipart) #772. Thanks @sdogruyol 🙏
-
Fix
Int32overflow in the SSEretryfield for spans beyond ~24.8 days #771. Thanks @sdogruyol 🙏 -
Disable the
X-Powered-Byheader by default. SetKemal.config.powered_by_header = trueto restore the previous behavior. -
Add Crystal-Kemal agent skills for routing, WebSockets, SSE, JSON APIs, middleware, uploads, auth, and related domains #774 #777.