1.17.1 — 2026-09-28
Version 1.17.1 is a security-and-correctness patch that closes a full-codebase audit of 1.17.0: three security fixes in server and ssr, and ten bug fixes across core, router, server, reactive, forms, store, component and view.
It contains no API removals and no module status transitions. A handful of fixes change observable behaviour; they are collected under Upgrading.
TIP
The full, per-entry list lives in the CHANGELOG. For how each affected API is meant to be used, follow the module guides linked from each section.
Headlines
serveStaticcannot be tricked into serving files outsiderootthrough a symlinked precompressed sidecar.- The Node adapter no longer crashes the process when a handler throws, and stops streaming when the client goes away.
- Signed CSRF tokens are bound to the session, which closes cookie injection from a sibling subdomain.
- Router params are decoded, and routes with non-ASCII characters match — on the client and on the server.
- Components render under an enforced Trusted Types CSP.
Security
serveStatic sidecar containment
1.17.0 re-checks the realpath of the file it serves, so a symlink inside root that points outside it is refused. With precompressed: true, the .br/.gz sidecar picked afterwards is a different path and was streamed without that check. A sidecar that resolves outside root is now skipped, and the next acceptable encoding — or the identity file — is served instead. See the server guide.
Node adapter: errors and disconnects
createNodeHandler() (and createServer().listen({ runtime: 'node' }), which uses it) returned a promise that node:http never awaits. An error thrown by a handler therefore became an unhandled rejection, which terminates a Node process by default — one throwing request was an outage.
- Handler errors are logged and answered with a plain
500 Internal Server Error. Headers already copied from the failed response (set-cookie,cache-control,content-length) are dropped first. If the response had already started, the connection is destroyed instead. - When the client disconnects,
request.signalaborts and the response body is cancelled, so streaming renders and SSE iterators stop. Previously the write loop waited forever for adrainevent that a closed socket never emits. - An abort that surfaces from a handler after the client left is not logged as an error.
See the SSR guide.
CSRF tokens bound to the session
In signed mode the token was the CSRF cookie value plus its HMAC, and nothing tied that pair to a user. Anyone who can set a cookie for the victim — a sibling subdomain, or a network attacker for a cookie without the __Host- prefix — could plant a validly signed pair minted for themselves.
When session() runs before csrf({ secret }) and the request carries a stored session, the secret now lives in that session (synchronizer token):
app.use(session({ secret: process.env.SESSION_SECRET! }));
app.use(csrf({ secret: process.env.CSRF_SECRET! })); // token bound to the session- The secret is bound to the session id and rotates on
$regenerate(), so a session planted before login cannot carry a known token into the authenticated one. - Visitors without a session keep the signed cookie, so anonymous traffic never creates sessions or evicts real ones from the capped default store. A session created during a request adopts the cookie's secret, so forms rendered before it keep working — unless it was created with
$regenerate()(a login), which always starts with a fresh secret. - The secret is stored before the handler runs, so a token read late — from a streamed render or
ctx.sse()— is still valid. bindToSession: falsekeeps the previous cookie-based behaviour. Withoutsession(), nothing changes.
See Sessions, CSRF, guards, and auth.
Fixes
Core
deepEqual/isEqualno longer treat{},[],Date,Map,SetandRegExpas equal to one another. A kind mismatch used to fall through to the own-key comparison, and all of these have zero own enumerable keys.css()accepts camelCase names:css({ fontSize: '18px' }),css('backgroundColor', 'blue')andcss('fontSize')work on$()and$$(). Custom properties (--brand) and hyphenated names pass through unchanged;WebkitX/webkitX/mozX/msXmap to their prefixed form.toggle()shows an element hidden with thehiddenattribute (an inlinedisplaystill wins over[hidden], except forhidden="until-found"), andhide()→show()restores the element's previous inlinedisplayinstead of clearing it.
Router and server routing
- Client router:
route.paramsvalues are percent-decoded (/user/J%C3%BCrgen→{ name: 'Jürgen' }), routes such as/überor/a b/:idmatch,resolve()round-trips through navigation, and param constraints see the decoded value. - Server: static segments with non-ASCII, space or other encoded characters match instead of returning
404, including routes written with lower-case escapes (/caf%c3%a9).
Reactive and server lifecycle
httphonours an already-abortedsignalwhentimeoutis set — the request used to be sent and resolve normally — and classifies any rejection on an aborted signal asABORTorTIMEOUT, notNETWORK.listen({ signal })rejects with the signal's reason when the signal is already aborted or aborts while the socket is still binding, instead of starting a server the caller had cancelled.
Server sessions
memoryStore()sweeps expired sessions during writes — they used to stay in memory until the same id was read again — and evicts the least recently used entry whenmaxEntriesis exceeded.- The default session store is capped at 10 000 entries, matching the rate limiter's default store.
Forms
email()runs in linear time. The old pattern backtracked quadratically; a 50 KB input blocked the thread for about 5 s.
Component, view and security
- Trusted Types: component render output and
createTemplate()templates are wrapped by the existingbquery-sanitizerpolicy without a second sanitizer pass, and the DOM sanitizer hands its input toDOMParser.parseFromString— itself a Trusted Types sink — the same way. Component rendering andsanitizeHtml()therefore work under an enforcedrequire-trusted-types-for 'script'CSP, with no extra policy name to allow. See the security model.
Documentation and tooling
SECURITY.mdlists1.17.xas the supported line, andbun run check:ai-guidancefails unless exactly the current minor line is marked as supported.- The 1.17.0 release notes carry the actual release date (2026-09-25) and are linked from the sidebar.
Upgrading
Upgrading from 1.17.0 is a drop-in for most apps. Check these if they apply to you:
- Router params are decoded. If you called
decodeURIComponent()onroute.paramsyourself, drop the call — values containing a literal%would otherwise be decoded twice. - CSRF with sessions. With
session()beforecsrf({ secret }), requests with a stored session get nobq.csrfcookie and the token changes when the session id is regenerated. Render a freshcsrfToken(ctx)after login. Clients that read the cookie directly needbindToSession: false. listen({ signal })now rejects when the signal is aborted before or during binding.- Default session store evicts beyond 10 000 live sessions. Pass
store: memoryStore({ ttlMs })for an unbounded one, or a shared store for multi-process deployments. show()without an argument keeps the inlinedisplayof an element that is not inline-hidden, instead of clearing it.email()rejects empty domain labels (a@b..c,a@.b.c,a@b.c.) and addresses longer than 254 characters.
Engines
Publish and local validation target Node.js ≥ 24.0.0 and Bun ≥ 1.4.0. See Supported Runtimes.