Repository navigation
fix(router)!: ctx.request.headers and getRequestContext().cookie()/cookies() refuse where cookies() does (#976) - #999
Merged
Conversation
…okies() refuse where cookies() does (#976) The identity guards covered cookies(), headers(), the theme reads and a { cache: false } variable, but three raw reads went around them: ctx.request.headers (the same Request in handlers, middleware, loaders and getRequestContext().request) and getRequestContext().cookie()/cookies(). A "use cache" function keys a ctx or Request argument by route and URL only, so a header read in its body stored the first caller's value for every caller; a handler under cache() or on a ppr route stored it in the shared record or shell, and a loader's own cache() without key() stored it in its entry. - The request every ctx exposes gets an own, non-enumerable headers getter (server/request-headers.ts shadowRequestHeaders, guarded by cookie-store.ts guardRequestHeaders) that runs guardIdentityRead: the same scopes, order and error style as headers(), and recorded at the read for a loader cache() fill. createRequestContext installs it, and createHandlerContext for a request the router builds and matches (the progressive-enhancement re-render, a navigation-only capture). No Proxy: fetch(ctx.request) and new Request(ctx.request) copy the headers natively and never call the getter (they are not guarded; the docs say not to make them in a cached body). clone() is shadowed to return a guarded clone. A lazy srvx NodeRequest's _request (Node dev and preview servers, vercel output) and Node's util.inspect hook run with the guard off, so srvx's own clone/body reads and console.log(ctx.request) are not reads; the facade stays lazy, so a buffered rawBody is still read. - Router code reads a request's headers only through requestHeaders(), which bypasses the getter (state cookie rotation, request classification, HMR and prefetch markers, origin guard, cf-ray, ...). - getRequestContext().cookie()/cookies() run guardRawCookieRead; the cookies() store reads the unguarded _readCookie()/_readCookies(). - key(), a store keyGenerator, condition(), tags() and onError run under runIdentityExempt (cache/cache-exec-scope.ts): identity reads there are allowed and record nothing. A ppr capture resolves its doc record key and the record's tags again under the capture context, so a keyGenerator or tags() reading the request (or cookies()) refused every capture of its route. The exemption is async-local, so a "use cache" body, a loader body and a segment funnel end it on entry: a key() that awaited a cached function reading cookies() stored the first visitor's cookie. Breaking: a handler under cache() or on a ppr route, or a "use cache" body, that reads ctx.request.headers (or a clone's headers) now throws or refuses the capture. Copy the value in middleware (ctx.set) and include it in key(), read it in a live loader, or pass it into the "use cache" function as an argument. The test fixtures and the test-app, cloudflare-basic and vite-rsc-demo pages that rendered a partition header from a cached handler now render the middleware copy; vite-rsc-demo's unreferenced PostRoute is removed. getRequestContext().theme has been guarded since #979. Tests: request-headers.test.ts (the getter; fetch, new Request, clone and util.inspect in every refusing scope on undici; the srvx facade, lazy rawBody; onError), identity-exempt-scope.test.ts (a cached body, loader body and funnel started inside an exempt callback are guarded), the SCOPES table in cookie-store.test.ts (three new surfaces, exempt rows, clone recording), request-raw-reads.rsc-test.tsx through serveShellRequest (keyGenerator, condition() and tags() at capture), and dev + production e2e identity-raw-reads in the router test-app and cloudflare-basic (on workerd, fetch, new Request and clone inside a cache() boundary).
commit: |
This was referenced Sep 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #976.
Problem
cookies(),headers(), the theme reads and a{ cache: false }variable were guarded. Three raw reads of the same identity went around that guard:ctx.request.headers, the sameRequestthat handlers, middleware, loaders andgetRequestContext().requestsee;getRequestContext().cookie();getRequestContext().cookies().A
"use cache"function keys actxorRequestargument by route and URL only. So a header read in its body stored the first visitor's value and served it to everyone after. Other places leaked the same way:cache()or on apprroute stored the value in the shared record or shell;cache()withoutkey()stored it in its entry.Nothing threw, and nothing warned.
Before / after
End-to-end consumer example
A handler under a keyed
cache()that renders the header itskey()partitions by:Semantics
cache()andppr; a live loadercache()... cannot be read inside a cache() boundary"use cache"body, and a loader it awaits... cannot be read inside a "use cache" functionpprcapture rendercache()withoutkey()key()keyGenerator,condition()ortags()functioncookies()/headers()refused apprcapture, because a capture resolves them again; raw reads passedonErrorcookies()/headers()refused a capture or threw in a cached scope; raw reads passed"use cache"body, loader body or segment render started from one of the callbacks abovectx.request.clone()new Request(ctx.request),fetch(ctx.request)Cookie/Authorization, so its response is per-visitor. The docs say not to make them in a cached body.console.log(ctx.request)on NodegetRequestContext().themehas been guarded since #979 and is unchanged here.Code changes
src/server/request-headers.ts:shadowRequestHeadersinstalls an own, non-enumerableheadersgetter that runsguardIdentityRead, with the same scopes, order and error style asheaders(). There is no Proxy, soctx.requeststays a realRequest:instanceof,fetchandnew Requestkeep working. The same file also:clone()to return a guarded clone;NodeRequest_requestgetter and Node'sutil.inspecthook so they run with the guard off, which keeps srvx lazy and means a bufferedrawBodyis still read;requestHeaders(), the only way router code reads a request's headers (state cookie rotation, request classification, the HMR and prefetch markers, the origin guard,cf-ray, ...).createRequestContextand increateHandlerContext. The second covers requests the router builds and matches itself: the PE re-render and a navigation-only capture.src/server/cookie-store.ts:guardRequestHeadersandguardRawCookieReadsit on top of the existingguardIdentityRead;cookies()store reads the unguarded_readCookie()/_readCookies(), following the_readThemepattern.src/cache/cache-exec-scope.ts:runIdentityExempt/endIdentityExempt.key(), a storekeyGenerator,condition(),tags()andonErrorrun exempt. Apprcapture resolves the doc record key and tags again under the capture context, so without the exemption akeyGeneratorthat read the request refused every capture of its route."use cache"body, a loader body and a segment render end it on entry. Otherwise akey()that awaited a cached function readingcookies()would store the first visitor's cookie.blog.tsxdropped a "previous URL" line that cached the first visitor's value.PostRouteis removed.use-cache,cache-guideandcaching;guides/use-cache.mdx;feature-map.mdandfeature-file-map.md;Test plan
serveShellRequest(src/testing/__tests__/request-raw-reads.rsc-test.tsx): at capture, a storekeyGenerator,condition()andtags()reading the request still HIT (MISS, HIT, HIT). A handler readingctx.request.headersrefuses the capture.request-headers.test.ts:fetch,new Request,cloneandutil.inspectin every refusing scope, on undici;rawBodyPOST wherejson()returns the buffered body;onError.identity-exempt-scope.test.ts: a"use cache"body, a loader body and a segment render started insidekey(), akeyGeneratororonErrorare guarded.SCOPEStable incookie-store.test.ts: three new surfaces, exempt rows, and clone recording for a loadercache()fill.promise resolved "'s:a'" instead of rejecting. The loader body akey()starts skipped the cache: a loader bound with its own cache() that reads cookies() serves the first visitor's value to everyone #972 fill check:expected undefined to match object { surface: 'cookies()', bodyId: 'L' }.tags()at capture:expected [ 'MISS', 'MISS', 'MISS' ] to deeply equal [ 'MISS', 'HIT', 'HIT' ].clone().headers: 4 failed, includingexpected [Function] to throw an errorinside"use cache", acache()boundary and a capture.bad36a6b.identity-raw-readsin the router test-app (7 + 7);identity-raw-readsin cloudflare-basic (14), which on workerd also coversfetch,new Requestandcloneinside acache()boundary;middleware.tschanged.pnpm run typecheck,pnpm run test:unit:all, lint and format all pass:check:docs-api,check:e2e-bucketingandcheck:e2e-parity --strictpass.*.gen.tsis committed for the test-app.Notes
cache()or on apprroute, or a"use cache"body, that readsctx.request.headers, a clone's headers, orgetRequestContext().cookie()/.cookies(). To migrate, pick one:"use cache"function;key();getRequestContext().cookie()/.cookies()were never on the public type.cookies()answers the same way.new Request(ctx.request)or from afetch(ctx.request)upstream. Closing that needs patching globals, so it is documented instead.util.inspect(handlerCtx)throwsCannot read private member #headersListfromctx.res's guarded stub headers (Cached-scope header lock: ppr = document-level cache() — ONLY middleware writes headers in cached scenarios; handlers/loaders disallowed loudly #713), in any scope. It reproduces onbad36a6b. It will be filed separately.new Request(ctx.request)andfetch(ctx.request)fail on the Node dev/preview server even without router code. The srvx facade passes undici'sinstanceof Requestcheck but lacks its internal state. It will be filed separately.packages/rangojs-router/e2e/test-app/src/urls.gen.tsgot the 7 new route lines, which matchrango generateoutput. The file is otherwise stale on main by 143 lines. No CI step regenerates or diffs it.