| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-10-17 | 11.4 kB | |
| v1.15.0 source code.tar.gz | 2026-10-17 | 192.6 kB | |
| v1.15.0 source code.zip | 2026-10-17 | 247.2 kB | |
| Totals: 3 Items | 451.3 kB | 0 | |
- (SECURITY) Never route a request-target that does not start with
/, and refuse a malformed one (GHSA-x97g-6h7h-q9w6). The route key is the method followed by the path with nothing in between, so the request lineGE T/admin/secretbuilt the key ofGET /admin/secretand was served by that route, while every path-scoped guard readT/admin/secretand stood aside:use "/admin", Auth,before_all "/admin/*", aKemal::Routermounted at/admin, prefixonly/excluderules. Any verb worked -DELET E/admin/users/42ran thedeleteroute. Such a request is now answered400 Bad RequestwithConnection: close, through a registerederror 400like the refusal of a malformed method (#820). The route tree,context.routeand theonly/excluderules of aKemal::Handlerno longer resolve a method and a path that cannot both be believed, so a handler that answers by itself behindonly ["/metrics"]no longer servedGE T/metrics- norGET/metrics /, which the method check of [#820] left reaching it. Absolute-form (http://host/path), authority-form (CONNECT host:port) andOPTIONS *targets are well formed, routed nowhere and still get404. - (SECURITY) Run
Kemal::Routerfilters for every request the router's routes serve,wsroutes included (GHSA-89xc-29wg-6mm5). A router's filters were registered by path only for its HTTP and SSE routes, so arouter.beforeorrouter.before_getauthentication guard let a WebSocket handshake to the same router through - an unauthenticated client reached thewshandler, also undernamespaceand mounted sub-routers. Registering them by path also let filters elsewhere decide whether they ran, and a filter path was compared with the spelling of the router's route paths, so a guard could silently not run for HTTP routes too. Router filters now travel with the router's routes, and a filter path is matched against the request path (router.before "/users/:id"covers aget "/users/me"). Thanks @barisern for the report :pray: - (BREAKING) Filters around a WebSocket handshake, following the fix above:
- The
GETfilters run for a handshake - top-level and router, including a pathlessbefore_get, which therefore runs on every handshake. A filter that assumes an HTTP route, such asenv.route.path, raises there. - A filter refuses a handshake with
halt, or with a status a registerederrorhandler covers; the refusal closes the connection. A status alone still upgrades, as it still runs an HTTP route's handler. afterfilters do not run for a handshake, since they would run once the101is on the wire - also where an HTTP route shares the path, which used to run them.- (BREAKING) A router's filters apply to the router's own routes only. A top-level route or
wson the same path no longer runs them; guard it with top-level filters. For a request no route serves, the routerbeforefilters of a route on the path still run, so a router guard or CORS preflight answer keeps covering the methods the router does not serve. Router filters run after the top-level filters of the same step. - (BREAKING) Run every top-level path filter whose path matches the request, in the order their paths were first registered, instead of only the most specific one. Global filters (
before_alland friends without a path) still run first, router filters last. Before upgrading, check the filters whose paths overlap: - A more specific filter path no longer exempts its paths from a broader guard. With
before_all "/api/*" { halt 401 unless authenticated }and anybefore_all "/api/login"filter,POST /api/loginused to get past the guard and now answers401. Skip such paths inside the guard itself, e.g.next if env.request.path == "/api/login". - Filters on overlapping paths all run, so side effects such as logging, counters or rate limits may run more than once per request:
before_get "/admin"next tobefore_get "/admin/"both run for either spelling, since each matches both. One block registered for several paths at once, as withbefore_all ["/admin", "/admin/*"], still runs once. - The filter on the path registered later has the last word.
before_all "/api/html" { |env| env.response.content_type = "text/html" }followed bybefore_all "/api/*" { |env| env.response.content_type = "application/json" }now answers/api/htmlwith JSON; register the more specific path after the broader one. A block added to a path later joins that path's place, so registering/api/htmlagain after/api/*does not help.afterfilters run in the same order, not reversed. - Why: path filters shared one radix tree that ran the single best match, so a broader guard silently did not run wherever a more specific filter path matched too: with
before_all "/api/*" { halt 401 }andbefore_all "/api/public" { ... },GET /api/publicskipped the guard and got200, andbefore_get "/users/:id"was skipped forGET /users/meoncebefore_get "/users/me"existed. A filter path now matches what it matches on its own: the shared tree could also lose filters altogether, sobefore_get "/users/:id"andbefore_get "/users/:uid/posts"together ran for neither/users/5nor/users/5/posts, and it refused some combinations at boot withRadix::Tree::SharedKeyError- addbefore_get "/users/me"to those two - which are now accepted.Kemal::FilterHandler#treeno longer holds the path filters. - (BREAKING) Run no
beforefilter after one that closed the response withhaltorredirect- not later blocks on the same path, path filters after a haltingbefore_all, thebefore_getstep after thebefore_allone, or router filters - just as it already kept the route handler from running.afterfilters still all run. Before upgrading: - A
beforefilter that has to see refused requests - audit logging of failed logins, a metrics counter - no longer runs for them when it comes after the guard. Move it to anafterfilter. - Refuse with
halt. A guard that only sets a status for a registerederrorhandler leaves the response open, so the filters after it still run and can change the status. - An
afterfilter that sets a header or the content type has to checkenv.response.closed?first: on a halted response it raisesIO::Error, which skips theafterfilters behind it,after_alllogging included. - Why: the filters after a halt kept running on a response already sent. With
before_all "/api/*" { halt 401 }andbefore_get "/api/public" { |env| env.response.content_type = "application/json" }, the client got its401butcontent_type=raisedIO::Erroron the closed response, which skippedafter_all, and a later filter's work - a database lookup, a counter - ran for a request the guard had refused. With overlapping path filters now all running, a halting guard with a filter behind it is the common case. - End the connection when an exception arrives after the response headers have gone out, instead of reading the next request on it.
Kemal::ExceptionHandlercould no longer render an error page at that point and returned as if the request had succeeded, so under keep-alive the server answered the next request on the same connection. A route that setContent-Length: 100, flushedpartialand raised had the next pipelined response spliced into the 93 missing bytes -partialHTTP/1.1 200 OK...- which a client, or a proxy pooling the connection, read as one body. The connection now closes after the cut, as it does when an exception escapes the handlers altogether, so the client sees the response end short (curl: (18) end of response with 93 bytes missing). A chunked response, which the server still ends with its final chunk, and a responsehaltalready closed keep their connection - anafterfilter touching a header of a large streamed response (#759) costs nothing. An error page for an exception raised before anything is sent is unchanged. - Leave a request path that names no static file to the routes instead of redirecting it.
StaticFileHandlerruns ahead of the routes and compared the decoded path with its canonical form, so an encoded/or a byte that is not UTF-8 in a route param never reached the route: withget "/u/:id",/u/a%2F%2Fbanswered302 /u/a/band/u/%FFanswered302 /u/%EF%BF%BD. A non-canonical path is now redirected only when it names a file underpublic_folder, or when it has a.or..segment - so/u/..%2F..%2Fetcstill answers302 /etcand no route receives a dot segment the handler would have resolved away. - Fix
content_forin a partial rendered withrender "partial.ecr"insiderender(view, layout). The partial was rendered into aString::Builder, and its block kept writing into that builder, which had already been turned into the partial's string by the time the layout'syield_contentran the block. So<% content_for "meta" do %><title>From partial</title><% end %>in a partial left<%= yield_content "meta" %>empty, and the late write grew the finished builder, freeing the memory behind the string the partial had returned: a view that kept that string and printed it afteryield_contentsent whatever the heap held there - route keys, CLI option names - instead of the partial. Acontent_forblock now receives the bufferyield_contentcollects it in as an argument, so the block reaches the layout from a partial as it does from the view, and a partial is rendered into anIO::Memory, so its string can no longer be overwritten. - Honour
If-Rangeinsend_fileand the static file handler, per RFC 9110 §13.1.5. ARangewas served whenever it parsed, soRange: bytes=0-3withIf-Range: "stale-etag"orIf-Range: Mon, 01 Jan 2001 00:00:00 GMTgot206and the first bytes of the current file, which a resuming client appended to the prefix it had of an older one, corrupting the download silently. ARangeis now only honoured whenIf-Rangenames the response's validator: an entity tag by strong comparison (so Kemal's weakW/"..."tags never match), a date only when it equals the response'sLast-Modified. Otherwise the full file is sent with200. A route callingsend_filewithout settingLast-ModifiedorETaghas nothing to compare, so a request carryingIf-Rangegets the full file there. - Close the connection after every refused WebSocket handshake, not only Kemal's own
403and405. Once the Origin and method checks passed, a wrongSec-WebSocket-Version(426) or a missingSec-WebSocket-Key(400) was answered by Crystal'sHTTP::WebSocketHandlerwithoutConnection: close, so withConnection: keep-alive, Upgradea request pipelined behind the handshake was served:GET /chatwithSec-WebSocket-Version: 1337followed byGET /internalgot426and then200 OKwith the internal response. Kemal now refuses both itself withConnection: close(the426still carriesSec-WebSocket-Version: 13), after the Origin check, and the pipelined request is never read. Completes the fix for GHSA-gvhf-4v24-f8r5.