| Name | Modified | Size | Downloads / Week |
|---|---|---|---|
| Parent folder | |||
| README.md | 2026-09-15 | 27.5 kB | |
| v1.14.0 source code.tar.gz | 2026-09-15 | 175.8 kB | |
| v1.14.0 source code.zip | 2026-09-15 | 228.8 kB | |
| Totals: 3 Items | 432.1 kB | 0 | |
-
(BREAKING) Answer a request whose path is routed for another HTTP method with
405 Method Not Allowedand theAllowheader instead of404, per RFC 9110 §15.5.6. With onlyget "/posts"registered,POST /postsandOPTIONS /postsreturned404and reached theerror 404handler; they now return405withAllow: GET, HEADand reacherror 405. Anything asserting404for a wrong-method request — tests, client retry logic, monitoring rules — has to be updated. A path that is not routed at all is still a404;wsroutes are covered by the entry below.HEADappears inAllowwherever aGETroute exists, matching theHEAD->GETfallback. TheAllowheader is set before the error handler runs, so a customerror 405owns the body but cannot drop the header the RFC makes mandatory; without one, the body is the plainMethod Not Allowed. Registeringerror 405also makesbefore_allfilters run for every unmatched request, plain 404s included - the same over-approximation the existingerror 404branch already had, now reachable through a second status code. AutomaticOPTIONSresponses are not part of this change.:::crystal get "/posts" do "posts" end
POST /posts -> 405, Allow: GET, HEAD
GET /nope -> 404
error 405 do |env| #
Kemal::InitHandlerpresetsContent-Type: text/htmlfor every response env.response.content_type = "application/json" {error: "Method not allowed", allow: env.response.headers["Allow"]}.to_json end -
Refuse an invalid command-line argument with a message and exit
1instead of an unhandled exception.kemal -p abcended inUnhandled exception: Invalid Int32: " abc" (ArgumentError)with a stack trace throughOptionParser, and an unknown flag or a flag missing its argument propagatedOptionParser::InvalidOption/MissingOptionthe same way; all three now print what was wrong - the unknown-flag and missing-argument cases with the usage banner - andabort, the way an SSL configuration error already did. A port is also checked against0..65535before it reachesHTTP::Server#bind_tcp, which reports one outside that range asHostname lookup for 127.0.0.1 failed: No address found- an error naming the host for a problem with the port. Port0stays valid and still asks the operating system for a free port, and surrounding whitespace is refused rather than trimmed, so--port " 8080"is reported instead of quietly accepted. An application that registersinvalid_optionormissing_optionthroughKemal::Config#extra_optionsstill owns them, since Kemal registers its own first; code that rescued the old exceptions aroundKemal.runno longer gets the chance, as the process exits at the bad argument. -
Hand a non-
GETrequest carrying WebSocket upgrade headers to the HTTP route that serves its method, when there is one.ws "/chat"next topost "/chat"answered aPOST /chatwithUpgrade: websocketas405, Allow: GET, POST- a 405 naming the method it refused. APOSTis aPOST; it goes to the route now. A method nothing serves on the path is still a405, and itsAllowcan no longer contain the refused method. -
Link the handler behind
use "/prefix", handlerto the rest of the chain once, when the chain is built, instead of on every matching request.Kemal::PathHandlerwrotehandler.next = self.nextper request - a write to shared state on the hot path that two requests running in parallel could race on, one re-aiming the handler while the other was about to call through it. A handler instance belongs to oneuse. -
Have an error page replace a body written before the error, instead of following it. A route that printed to
env.responseand then set a status with a registerederrorhandler, or raised, sent the partial body with the error page appended -partial body forbidden page- and aHEADcounted both intoContent-Length. The body is buffered until the headers go out, so up to that point it is discarded before the page is written (HTTP::Server::Response#discard_unsent_body); a body that has already reached the client stays, as before, since nothing can replace it. -
Stop holding a file descriptor per upload for the rest of the request #801.
Kemal::FileUploadkept the handle its temporary file was written through open until cleanup, so a request at themax_file_uploadscap held 128 descriptors and eight such requests exhausted the defaultulimit -n 1024. The handle is now closed as soon as the upload is spooled. Read an upload with the newFileUpload#open(&), which yields aFileand closes it, or move it by its newpath;FileUpload#tempfileis deprecated — it still returns an open handle at the file's start, kept until cleanup as before, so existing code keeps working and pays the descriptor only while it does.FileUploadis aclassnow rather than astruct; nothing depended on copy semantics, and a reference is what every call site assumed. -
Answer
HEADon a filesend_filewould send as it is from the file's size, without producing the body.Kemal::HeadRequestHandlerlearnsContent-Lengthby running theGEThandler into a counting sink, so aHEADon a 20 GB download read 20 GB from disk to throw it away (noted in #803). When the stored bytes go out unchanged - no compression, or a pre-compressed neighbour standing in - the length is the file's own and the read is skipped. The file is still opened, so aHEADfails where theGETwould. A body Kemal compresses on the way out is still produced; its length is only known once it has been. -
Make the
testenvironment behave like every other one, and letbefore_all/after_allwork in specs #788. Three things differed between a spec run and production: -
An unmatched route answered an empty
200underKEMAL_ENV=test.Kemal.runregistered the default 404 page only outside that environment, and without a registerederror 404aRouteNotFoundrendered nothing. The page is now registered in every environment, and aRouteNotFoundwith no handler renders a plain404 Not Foundlike the other built-in errors do. before_allran for an unmatched path — a 404, a 405, or a WebSocket path reached without an upgrade — only when a customerror 404/405handler was registered, whichKemal.rundid in production and nothing did under spec. An authentication guard inbefore_alltherefore held in production and not in the tests that were supposed to prove it. It runs for every unmatched path now, in every environment.- Kemal's top-level
before_allandafter_allshadow thedescribe-level hooks of thespeclibrary, so a spec callingbefore_all { seed }inside adescriberegistered a request filter and never ran the block, with no error. Called inside adescribethey now register the spec hook; called anywhere else — an application file, a route, an example — 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.
-
Build the
Content-Dispositionofsend_fileper RFC 6266 and RFC 8187. The filename was dropped into the quoted-string as it was: a"in it ended the parameter early, a non-ASCII name went out raw where the parameter is defined as ASCII, and a control character made the standard library reject the header with a500."and\\are now escaped, other characters outside printable ASCII become_infilename, and when that loses anything the original name follows asfilename*=UTF-8''…, which user agents prefer. A plain ASCII name produces the same header as before.Kemal::Utils.content_dispositionis the builder. -
params.raw_bodyreturns the body of any request, not only a form or JSON one. It came back empty fortext/plain, XML, or a request with noContent-Typeat all, so such a body looked absent rather than unread; it is now read and cached the same way, undermax_request_body_size. Amultipart/form-databody is the one exception and still returns"", sinceparse_filesstreams it part by part. JSON detection now goes by media type instead of a string prefix:application/vnd.api+jsonand other+jsontypes (RFC 6839) parse intoparams.json,Application/JSONmatches, andapplication/jsonpno longer does. -
Skip the WebSocket upgrade when a
beforefilter has already answered. Ahaltin abefore_all— an authentication check answering401— closed the response, andKemal::WebSocketHandlerwent on to attempt the upgrade regardless; the standard library handler raisedIO::Error: Closed streamon the closed response. The client had its401, but every rejected handshake was logged as a server error. The handler now returns once it finds the response closed. -
Stop confirming that a directory under
public/exists when nothing is served for it. The standard library adds the trailing slash to a directory URL whenever its owndirectory_listingflag is on, and Kemal never set that flag fromserve_static, so withdir_listinganddir_indexboth off/adminstill answered302 /admin/while/nopeanswered404— the redirect was the only difference, and it told a scanner which directories are there. The redirect now happens only when a listing or anindex.htmlwould be served at the slashed URL; otherwise a directory falls through to the same404as a missing path. -
Drop the bare
rescuearound url-param decoding.URI.decodepasses a malformed percent-escape through unchanged rather than raising, so the rescue could only ever have hidden an unrelated bug as a silently undecoded value. The pass-through is now pinned by a spec. -
Log an exception at
errorlevel before anerror MyExceptionhandler renders it. Only the generic path anderror 500logged; an exception matched by a class handler went unlogged, so a catch-allerror Exception do … end— the usual way to get a JSON 500 page — took every crash out of the log, and any reporter readingLognever saw it. The handler still owns the response. Applications that use exception handlers for expected control flow will see anerrorline per occurrence; that was already the case forerror 500. -
Send the built-in error body for
400,405and413astext/plain.Kemal::ExceptionHandlermeant to, but only set the type when none was present, andKemal::InitHandlerpresetstext/htmlon every response before any handler runs, soMethod Not Allowed,Payload Too LargeandBad Requestwent out as HTML — or as whatever type the route had set before it raised. The body is Kemal's own, so the type now is too. Customerrorhandlers are unaffected and still own their content type. -
HTTP::Server::Context#redirectsetsLocationinstead of adding to it. Abeforefilter that redirected withclose: false, followed by a route that redirected elsewhere, sent bothLocationheaders; the client got the last one, or the first, or refused the response, depending on the client. The later redirect now replaces the earlier one. -
Kemal::Config#clearnow also resetsserve_static,public_folder,static_headers,shutdown_messageandshutdown_timeout, so a spec that sets one of them no longer leaks it into the next. CI gains three variant runs on the current compiler —--release,-Dwithout_zliband-Dwithout_openssl, the two flags with code paths of their own insrc/— and a run with the default execution context widened to four workers (Crystal 1.21; setKEMAL_SPEC_WORKERSto do the same locally), so the request path is exercised from several threads. Specs that need zlib are skipped under-Dwithout_zlib, and every spec file now compiles on its own. -
Count the discarded body of a
HEADrequest in anInt64#803.Kemal::HeadRequestHandlerswallows the body aHEADrequest generates and counts the bytes to setContent-Length, and the counter was anInt32; Crystal checks integer overflow, so the 2 147 483 648th byte raisedOverflowError, whichKemal::ExceptionHandler— further down the chain — turned into a500.HEADon a file of 2 GiB or more therefore answered500where the matchingGETanswered200, with the threshold at exactlyInt32::MAX. A route generating a body over that size also reported the partial count as itsContent-Length, sinceNullIOsets the header from the counter when the handler has not set one itself.HTTP::Server::Response#content_lengthalready returnsInt64?, so the counter was the onlyInt32left in the path. -
Spool an upload through the handle
File.tempfilealready opened, instead of opening the path a second time to write #801. One descriptor and oneopenper upload rather than two, and the file is no longer reopened by name right after being created by descriptor.FileUpload#tempfilestill comes back open for reading and positioned at the start.FileUpload#sizeis now the number of bytes written to disk; it used to be thesizethe part'sContent-Dispositionclaimed, which the client chooses. -
Finish the requests in flight before exiting on a termination signal #795.
Kemal.stopclosed the listeners and then slept forshutdown_timeout, butHTTP::Server#listenreturns as soon as the listeners close, soKemal.runreturned, the program ended and the process exited a few milliseconds afterSIGTERM— before the sleep ran, cutting off whatever was being served, and for any value ofshutdown_timeout.Kemal.runnow returns only once every request that was in flight when the server stopped has finished, orshutdown_timeouthas elapsed, whichever comes first; with nothing in flight it returns at once.shutdown_timeouttherefore changes from a fixed sleep into an upper bound, and its default from0to30.seconds, since0would keep cutting requests off.Kemal.stopitself no longer blocks. The signal handler no longer callsexit:Kemal.runreturns after the drain and the program carries on to its end, so code afterKemal.runnow runs onSIGTERMas it already did onKemal.stop. A second signal during the drain exits immediately. A WebSocket or SSE connection counts as in flight for as long as it stays open; an application holding such connections waits the fullshutdown_timeoutunless it closes them itself. The count is exposed asKemal::InitHandler::INSTANCE.in_flight, andKemal.config.runningis set tofalsebefore the listeners close so a health route can report the drain. -
Count a
wsroute asGETwhen collecting theAllowheader, since the WebSocket handshake is aGETrequest (RFC 6455 §4.1). Two fixes: -
A path served only by
wsnow answers wrong-method requests with405andAllow: GET. PreviouslyKemal::WebSocketHandlerpassed a non-handshake request straight through and the route lookup missed, soPOST /chatended as a404— or, with noerror 404handler registered, an empty200. -
A path carrying both
wsand HTTP routes no longer reports anAllowthat omits the handshake: withws "/chat"andpost "/chat",PUT /chatanswersAllow: GET, POSTrather thanAllow: POST. -
Kemal::WebSocketHandlerno longer hardcodesAllow: GETwhen it rejects a non-GET upgrade attempt.Allowdescribes the methods the resource supports (RFC 9110 §10.2.1), so withws "/chat"andpost "/chat"aPOSTcarryingUpgrade: websocketnow answersAllow: GET, POSTinstead of sending the client away from a verb the path really serves. A path with only awsroute still answersAllow: GET.
HEAD is not advertised for a ws route — the handshake is the only thing served there. And a request whose own method is already in the Allow list is not a 405: a plain GET on a ws path is a handshake missing its Upgrade header, so it stays a 404 instead of being told 405, Allow: GET. 426 Upgrade Required (RFC 9110 §15.5.22) was considered for that case and deliberately not adopted: a plain GET there is a client bug, and 404 versus 426 does not change what the client has to do.
:::crystal
ws "/chat" do |socket, env|
socket.send("hi")
end
post "/chat" do
"post"
end
# PUT /chat -> 405, Allow: GET, POST
# GET /chat -> 404 (no `Upgrade` header, so not a handshake)
-
Report a static file that exists but cannot be opened as
404, as the standard library'sHTTP::StaticFileHandlerdoes. Kemal's override served files throughsend_filewithout the stdlib'sFile::Errorrescue, so a file underpublic/without read permission answered500— with the absolute path on the development error page, and the file'sETagandLast-Modifiedon the response, confirming to the client that the file was there. The response is now the plain404the stdlib sends, with no header derived from the file. -
Answer
HEADon ansseroute with the stream's headers instead of running the handler.HEADis served from theGETroute with the body discarded, and an SSE handler is typically an endless loop that only stops when a write fails because the client has gone. OnHEADnothing it wrote went anywhere, so the loop never saw the client leave: everyHEADto an SSE endpoint pinned a fiber for the life of the process, and a client that sent a few hundred of them exhausted it without authenticating.Kemal::EventStream.servenow sets the headers and returns without yielding when the request method isHEAD. -
Fix
content_forblocks leaking between concurrent requests #789. Captured blocks were kept in a process-globalCONTENT_FOR_BLOCKShash, so two requests rendering the same view at once overwrote each other: whichever fiber reached the layout second ran the other request's block, which wrote into the other request's buffer. One page lost itsyield_contentoutput and could pick up the other's instead, with no error raised. Any fiber switch between the view and the layout — a database call in a view, or-Dpreview_mt— triggered it. Blocks are now held in a local thatrender(view, layout)declares, so each render sees only what it captured itself.CONTENT_FOR_BLOCKSis gone, andcontent_for/yield_contentare only usable inside arender(view, layout)call (directly in the view, or in a partial it renders); thefileargumentcontent_fortook to tell views apart is no longer needed and has been removed. -
Fix
Rangehandling insend_fileper RFC 9110 §14 and §15.5.17. The parser could not tell an omitted last-pos from0, required the last-pos to be strictly greater than the first-pos, and dropped ranges that reached the end of the file:bytes=0-0served the whole file as a206,bytes=5-5andbytes=17-fell back to a200, andbytes=0-4,7-7silently lost its second part. A last-pos beyond the end is now clamped (bytes=0-99999→0-17/18), suffix ranges are supported (bytes=-5→13-17/18), a valid range set none of whose ranges is satisfiable is answered with416andContent-Range: bytes */<length>instead of200, and a byte position that overflowsInt64counts as beyond the end of the file instead of0. Each part of amultipart/byterangesresponse now carries the media type of the file rather thanmultipart/byteranges; boundary=.... A malformedRangeheader, a unit other thanbytes,RangeonHEAD, and range sets refused as abusive (seeKemal.config.max_ranges) are still served as a plain200. -
Fix content coding negotiation in
send_fileand static file serving, per RFC 9110 §8.4, §12.5.3 and §12.5.5: -
Content-Encoding: deflatenow carries the zlib format of RFC 1950, which is thedeflatecoding RFC 9110 §8.4.1.2 defines. Kemal sent a bare RFC 1951 stream; browsers sniff that, stricter clients do not. Accept-Encodingqvalues are honored, sogzip;q=0no longer gets a gzip body. The highest ranked coding wins, and*andidentity;q=0are understood. The parser isKemal::Utils.select_content_coding.- A response whose representation
Accept-Encodingselects now carriesVary: Accept-Encoding, whether or not this particular response was compressed, added to anyVarythe application already set. Without it a shared cache serves one client the variant it stored for another. - A pre-compressed
app.js.gz(Crystal 1.17+) is served with the media type ofapp.jsinstead ofapplication/octet-stream, is not compressed a second time, and is chosen with the same qvalue rules rather than the standard library's word match. A multi-range request for it falls back to the whole body, since themultipart/byterangesenvelope carrying the parts is not itself encoded. - The encoded variant gets an entity tag of its own (
W/"1700000000-gzip"), so a cache cannot mistake it for the identity representation, andIf-None-Matchis matched against the tag the request would actually be answered with.
Range responses stay uncompressed, as before. gzip true installs the standard library's HTTP::CompressHandler, which has none of these fixes; prefer serve_static({"gzip" => true}) for static assets.
-
Omit the SSE
retryfield for negativeTime::Spanvalues instead of emitting e.g.retry: -3000, which clients discard per the SSE spec. The value now renders via.to_u64; output for non-negative spans is unchanged. -
Skip filter tree lookups when no path-scoped filters are registered. Apps using only global filters (
before_alland friends) no longer pay 4-6 radix lookups and key allocations per request #781. -
Cache the
Dateresponse header string per second instead of formatting it on every request. The value is unchanged: the string is reused only within the same UTC second #781. -
(SECURITY) Refuse a request whose method is not an RFC 9110 §5.6.2 token with
400 Bad Request#820. Kemal keys its routing tree on the method concatenated with the path and Crystal's parser passes the method through unvalidated, so a method carrying a/chose where the one ended and the other began:GET/admin+/secretproduced the same key asGET+/admin/secretand reached that route, whileuse "/prefix",only/excludeandbefore_*/after_*all match onrequest.pathand saw only/secret- an unauthenticated bypass of path-scoped authorization on any route, in a single request.GET/account+/leftrequest.pathas/, defeating exact-path guards too.Kemal::RouteHandlernow refuses such a method where that key is built, so nothing is routed on a method and a path that cannot both be believed;Kemal::Utils.valid_method?is the check it asks. It is raised like every other client error Kemal produces, so a registerederror 400renders it and the access log records it. The refusal closes the connection (Connection: close), since a request line Kemal and an intermediary read differently is what request smuggling is built on. Unfamiliar but well-formed methods are untouched: aPROPFINDstill reaches the router and gets its answer. Thanks @yvzkr for the report :pray: -
(SECURITY) Cap the number of file parts in a
multipart/form-datarequest #793.max_request_body_sizebounds the bytes but said nothing about the parts, and every file part is spooled to its own temporary file whose handle stays open until the request is over: an 8 MB body of one-byte parts held some 100,000 open file descriptors and temporary files for the length of one request, enough to take a process with the defaultulimit -n 1024pastaccept()from a single unauthenticated request.Kemal.config.max_file_uploads(default128) now bounds the file parts; a request carrying more is answered with413and the bodyToo many file parts (max N)before the next part is written to disk, and the parts already spooled are cleaned up with the request. Form fields without a filename are not counted.Kemal::Exceptions::PayloadTooLarge.newnow takes an optional message for this. Two related leaks are closed as well: a file part whose field name repeats an earlier one used to replace that upload inparams.fileswithout removing its temporary file, leaving it on disk for good; andparams.all_filesdid not parse the body on its own, so it was always empty unlessparams.fileshad been read first. -
(SECURITY) Stop reflecting the request into
Kemal::Exceptions::RouteNotFound#message. It readRequested path: 'GET:/…' was not found.with the method and path verbatim, and anerror 404handler that returnsex.message— the obvious thing to write — sent that back astext/html, soGET /<script>…</script>was a reflected XSS. The message is now the bareNot Found, asMethodNotAllowed's already was; the request stays reachable through the newRouteNotFound#context. Theerrordocs now showHTML.escapearound a message built from request data. -
(SECURITY) Serve the development error page only in the
developmentenvironment #791. The choice between the development page — exception message, backtrace with source, response headers, cookies — and the static production page wasenv == "production", so every other value ofKEMAL_ENVgot the development page:staging,test, and typos such asprodorProductionall disclosed the failure to the client. The check is nowenv == "development"; an environment Kemal does not recognize gets the production page.Kemal.config.show_exceptions = trueshows the development page in another environment,falsenever shows it, and unset (the default) follows the environment. A registerederror 500handler is unaffected; the setting only chooses between the two built-in pages. Apps that relied on the development page in an environment other thandevelopmentneed to setshow_exceptions.