Kemal is a Fast, Effective, Simple web framework for Crystal.
Hello everyone,
Kemal 1.13.0 is out
This is a security-focused release: upload cleanup, HEAD filter bypasses, Range request limits, WebSocket origin defaults, and several XSS / injection fixes. It also adds first-class HTTP QUERY support. If you run Kemal in production, upgrading is strongly recommended ![]()
Upload temp files are always cleaned up
Multipart file parts are spooled to File.tempfile as soon as anything touches params — including params.body on a multipart request. Cleanup used to run only in Kemal::RouteHandler, so anything that answered before the route handler leaked those files for good: a before filter that halts into a custom error handler (Kemal’s documented auth pattern), an exception in a filter, or middleware that responds without calling the next handler.
An unauthenticated client could fill the disk one rejected upload at a time. Cleanup now lives in Kemal::InitHandler, which heads the handler chain, so it runs however the request ends (#776). Thanks @canermastan ![]()
HTTP QUERY method
Kemal now supports the HTTP QUERY method (RFC 10008) with a query route DSL, Kemal::Router#query, and before_query / after_query filters (#762). Thanks @canermastan ![]()
query "/search" do |env|
q = env.params.json["q"]? # or env.params.body for form-encoded queries
search_products(q).to_json
end
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.
HEAD no longer bypasses GET filters
Kemal serves a HEAD request with the GET route when no explicit HEAD route exists — but filters and only / exclude rules used to match the literal request method. before_get authentication was skipped while the GET handler still ran, so HEAD /admin/users could execute a protected handler, return its headers, and skip after_get audit records.
Filters and Kemal::Handler only / exclude now run for both the request method and the method of the route that serves it. HEAD is guarded by GET rules; rules scoped to HEAD keep firing; verbs with their own handler are untouched. Thanks @JirayuThongchotchaung ![]()
Router-scoped filters whose path ends in /* are registered correctly as well. register_filters treated the trailing * as a literal segment, so router.before_get "/*" and router.before_get "/admin/*" matched no route and were silently registered nowhere. A trailing /* now marks a subtree.
Bounded Range requests
send_file used to serve byte ranges unchecked. An open-ended bytes=0- expands to the whole file, so a header such as bytes=0-,0-,0-,... turned one small request into a response thousands of times the file’s size — the CVE-2011-3192 “Apache Killer” pattern. This 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. Thanks @onurcangnc ![]()
# Allow more parts per Range request, or set to 0 to ignore Range headers entirely
Kemal.config.max_ranges = 16
Development error page XSS
The request path and the exception message reached the development exception_page template unescaped. 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. The page is now escaped and served with a restrictive Content-Security-Policy.
This only affects Kemal.config.env == "development", which is the default; the production error page never reflected request data. Thanks @onurcangnc ![]()
WebSocket origin is same-origin by default
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 ["*"] to opt into allowing any origin, including requests without Origin. Explicit allowlists behave as before.
# 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 = ["*"]
Rejected upgrades now send Connection: close, so a pipelined request behind a reverse proxy cannot smuggle past the proxy’s access controls. Malformed Origin values that previously raised from URI.parse now reject with 403 instead of 500.
WebSocket upgrades also require the GET method per RFC 6455 §4.1 (#770). Any other method carrying valid upgrade headers is rejected with 405 Method Not Allowed.
SSE, bodies, and polish
-
SSE injection:
Kemal::EventStreamrejects newlines inevent/idand normalizes CR/LF indata/comment. Thanks @hahwul
-
URL params: Route lookup cache no longer decodes URL params again on every request. Thanks @hahwul
-
Malformed bodies: Invalid JSON and unparseable multipart now respond
400instead of500(#772). -
SSE
retry: Fixed anInt32overflow for spans beyond ~24.8 days (#771). -
only/exclude: Opt-in matching for all HTTP methods ("*") and path prefixes ("/*"). Defaults remain GET + exact path. -
X-Powered-By: Disabled by default. SetKemal.config.powered_by_header = trueto restore the previous behavior. - Agent skills: Crystal-Kemal agent skills for routing, WebSockets, SSE, JSON APIs, middleware, uploads, auth, and related domains (#774, #777).
Upgrade notes
Two defaults changed. Apps that relied on the old behavior need a one-line config:
# If browsers or non-browser clients connect to WebSockets from other origins
Kemal.config.websocket_allowed_origins = ["*"]
# If you still want the X-Powered-By: Kemal header
Kemal.config.powered_by_header = true
Full change history and PR links are in the CHANGELOG. Release tag: v1.13.0.
For end-to-end sample apps (blog, JSON API, WebSockets, OAuth, and more), see Kemal by Example on GitHub.
Thanks to @sdogruyol, @hahwul, @onurcangnc, @canermastan, and @JirayuThongchotchaung for this release ![]()
You can help sustain development via GitHub Sponsors ![]()
Happy Crystalling ![]()
