Skip to content

fix(router)!: ctx.request.headers and getRequestContext().cookie()/cookies() refuse where cookies() does (#976) - #999

Merged
ivogt merged 1 commit into
mainfrom
fix/identity-raw-reads-976
Sep 30, 2026
Merged

ivogt merged 1 commit into
mainfrom
fix/identity-raw-reads-976

Conversation

@ivogt

@ivogt ivogt commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

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 same Request that handlers, middleware, loaders and getRequestContext().request see;
  • getRequestContext().cookie();
  • getRequestContext().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 visitor's value and served it to everyone after. Other places leaked the same way:

  • a handler under cache() or on a ppr route stored the value in the shared record or shell;
  • a loader's own cache() without key() stored it in its entry.

Nothing threw, and nothing warned.

Before / after

async function getGreeting(ctx: HandlerContext) {
  "use cache";
  return greet(ctx.request.headers.get("accept-language"));
}
// Before: the first caller's accept-language is stored and served to every caller.
// After: throws `ctx.request.headers cannot be read inside a "use cache" function`.

async function getGreeting(language: string | null) {
  "use cache";
  return greet(language);
}
const greeting = await getGreeting(ctx.request.headers.get("accept-language"));
// Migration: read it outside and pass it in; it becomes part of the key.

End-to-end consumer example

A handler under a keyed cache() that renders the header its key() partitions by:

const Tier = createVar<string>();

middleware(
  async (ctx, next) => {
    ctx.set(Tier, ctx.request.headers.get("x-tier") ?? "free");
    return next();
  },
  () => [
    cache(
      {
        ttl: 300,
        // key() may read the request: it picks the entry and is never rendered.
        key: (ctx) => `tier:${ctx.request.headers.get("x-tier") ?? "free"}`,
      },
      // Before: <Pricing tier={ctx.request.headers.get("x-tier")} />. After: that throws.
      () => [path("/pricing", (ctx) => <Pricing tier={ctx.get(Tier)} />)],
    ),
  ],
);

Semantics

Where the read runs Before After
Middleware; a handler outside cache() and ppr; a live loader reads reads (unchanged)
A handler or layout under cache() stored in the shared record throws ... cannot be read inside a cache() boundary
A "use cache" body, and a loader it awaits stored in the entry throws ... cannot be read inside a "use cache" function
A ppr capture render baked into the shell refuses the capture with a once-per-key warning; the route keeps MISSing
A loader's own cache() without key() stored in the entry the fill fails with the identity error and stores nothing
Callback Before After
A route key() reads (a capture reuses the request's result) reads (unchanged)
A store keyGenerator, condition() or tags() function cookies()/headers() refused a ppr capture, because a capture resolves them again; raw reads passed every identity read passes
onError cookies()/headers() refused a capture or threw in a cached scope; raw reads passed every identity read passes
A "use cache" body, loader body or segment render started from one of the callbacks above not applicable guarded as usual; the exemption ends on entry
Copy of the request Behavior
ctx.request.clone() returns a clone guarded the same way
new Request(ctx.request), fetch(ctx.request) never throw, because the platform copies headers without calling the getter. They are not guarded either: a fetch forwards the visitor's Cookie/Authorization, so its response is per-visitor. The docs say not to make them in a cached body.
console.log(ctx.request) on Node not a read

getRequestContext().theme has been guarded since #979 and is unchanged here.

Code changes

  • src/server/request-headers.ts: shadowRequestHeaders installs an own, non-enumerable headers getter that runs guardIdentityRead, with the same scopes, order and error style as headers(). There is no Proxy, so ctx.request stays a real Request: instanceof, fetch and new Request keep working. The same file also:
    • shadows clone() to return a guarded clone;
    • shadows the srvx NodeRequest _request getter and Node's util.inspect hook so they run with the guard off, which keeps srvx lazy and means a buffered rawBody is still read;
    • adds 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, ...).
  • The getter is installed in createRequestContext and in createHandlerContext. The second covers requests the router builds and matches itself: the PE re-render and a navigation-only capture.
  • src/server/cookie-store.ts:
    • guardRequestHeaders and guardRawCookieRead sit on top of the existing guardIdentityRead;
    • the cookies() store reads the unguarded _readCookie()/_readCookies(), following the _readTheme pattern.
  • src/cache/cache-exec-scope.ts: runIdentityExempt / endIdentityExempt.
    • key(), a store keyGenerator, condition(), tags() and onError run exempt. A ppr capture resolves the doc record key and tags again under the capture context, so without the exemption a keyGenerator that read the request refused every capture of its route.
    • The exemption is async-local, so a "use cache" body, a loader body and a segment render end it on entry. Otherwise a key() that awaited a cached function reading cookies() would store the first visitor's cookie.
  • Migrations: 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 blog.tsx dropped a "previous URL" line that cached the first visitor's value.
    • The unreferenced PostRoute is removed.
  • Docs:
    • skills use-cache, cache-guide and caching;
    • the docs site guides/use-cache.mdx;
    • feature-map.md and feature-file-map.md;
    • the CHANGELOG Breaking entry.

Test plan

  • Userland, through serveShellRequest (src/testing/__tests__/request-raw-reads.rsc-test.tsx): at capture, a store keyGenerator, condition() and tags() reading the request still HIT (MISS, HIT, HIT). A handler reading ctx.request.headers refuses the capture.
  • Unit:
    • request-headers.test.ts:
      • the getter;
      • fetch, new Request, clone and util.inspect in every refusing scope, on undici;
      • the srvx facade, including a lazy rawBody POST where json() returns the buffered body;
      • onError.
    • identity-exempt-scope.test.ts: a "use cache" body, a loader body and a segment render started inside key(), a keyGenerator or onError are guarded.
    • The SCOPES table in cookie-store.test.ts: three new surfaces, exempt rows, and clone recording for a loader cache() fill.
  • Red before green, on the prior revision with only the new tests added:
    • the exemption leak: 8 failed / 1 passed, e.g. promise resolved "'s:a'" instead of rejecting. The loader body a key() 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, including expected [Function] to throw an error inside "use cache", a cache() boundary and a capture.
    • the headers getter and the cookie surfaces: red on bad36a6b.
  • E2e, dev + production:
    • identity-raw-reads in the router test-app (7 + 7);
    • identity-raw-reads in cloudflare-basic (14), which on workerd also covers fetch, new Request and clone inside a cache() boundary;
    • the semantic matrix (25 dev + 28 production), since middleware.ts changed.
  • pnpm run typecheck, pnpm run test:unit:all, lint and format all pass:
    • router: 6924 + rsc 415;
    • cloudflare-basic: 62 + 17;
    • vite-rsc-demo: 25 + 1;
    • mini: 25 + 3;
    • shared-e2e: 8.
  • check:docs-api, check:e2e-bucketing and check:e2e-parity --strict pass. *.gen.ts is committed for the test-app.

Notes

  • Breaking for a handler under cache() or on a ppr route, or a "use cache" body, that reads ctx.request.headers, a clone's headers, or getRequestContext().cookie()/.cookies(). To migrate, pick one:
    • pass the value into the "use cache" function;
    • copy it in middleware and include it in key();
    • read it in a live loader.
  • getRequestContext().cookie()/.cookies() were never on the public type. cookies() answers the same way.
  • Not caught: header reads from new Request(ctx.request) or from a fetch(ctx.request) upstream. Closing that needs patching globals, so it is documented instead.
  • Pre-existing and not changed here: util.inspect(handlerCtx) throws Cannot read private member #headersList from ctx.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 on bad36a6b. It will be filed separately.
  • srvx: new Request(ctx.request) and fetch(ctx.request) fail on the Node dev/preview server even without router code. The srvx facade passes undici's instanceof Request check but lacks its internal state. It will be filed separately.
  • packages/rangojs-router/e2e/test-app/src/urls.gen.ts got the 7 new route lines, which match rango generate output. The file is otherwise stale on main by 143 lines. No CI step regenerates or diffs it.

…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).
@pkg-pr-new

pkg-pr-new Bot commented Sep 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@999

commit: 8d00181

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cache: ctx.request.headers and getRequestContext().cookie()/cookies()/theme bypass the cache and capture identity guards

1 participant