Skip to content

fix(server): HTML response cache key omits the host, so X-Forwarded-Host poisons it #1097

Description

@vivek7405

Problem

htmlCacheKey (packages/server/src/html-cache.js:134) keys the shared HTML response cache on pathname + sorted search params only, with no host or scheme component:

return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;

The framework trusts X-Forwarded-Host to build ctx.url, so on a page that opts into the cache with export const revalidate = N an attacker can bake an origin of their choosing into the SHARED cached body. Verified end to end against a real app on Bun:

attacker sends  X-Forwarded-Host: evil.example, X-Forwarded-Proto: https
next request, no headers at all  ->  og:image = https://evil.example/public/og.png   POISONED
same probe with WEBJS_NO_TRUST_PROXY=1                                               not poisoned

Every subsequent visitor to that path is served the poisoned HTML until the entry expires. Anything the page derives from ctx.url is affected: og:image / og:url, canonical links, OAuth callback URLs, absolute asset URLs.

This is NOT introduced by #1091 (the node shell has always had it, and the same probe poisons there). #1091 is what makes it reachable on the Bun shell, which is what the deployed apps run, so it is worth closing rather than leaving as a known-latent issue.

Neither Cloudflare nor Railway strips a client-supplied X-Forwarded-Host; they forward it. So the forwarded.js:15-21 threat-model note ("no path for an attacker to inject these headers without going through that proxy") does not hold for X-Forwarded-Host specifically: going through the proxy is exactly how it arrives.

Current real-world exposure is low: no in-repo app sets export const revalidate on a page (only docs prose mentions it), and the cache is opt-in per page. So this is a latent trap for apps that adopt the feature rather than a live incident.

Design / approach

Options, roughly in order of preference:

  1. Add the origin to the cache key. Smallest change, closes the poisoning directly. A single-host deploy is unaffected (one origin, one key); a genuine multi-host deploy gets correctly separated entries instead of cross-serving. Costs a little cache fragmentation if a deploy is reachable under several hostnames.
  2. Do not trust X-Forwarded-Host by default, with an opt-in allowlist (a webjs.trustedHosts config). Closer to the root cause and matches how other frameworks handle it, but a bigger behaviour change and it affects more than the cache.
  3. Refuse to cache when a forwarded host was used. Simple but silently disables the feature exactly where it is most wanted.

(1) and (2) compose. (1) alone is enough to stop the cross-visitor poisoning.

Implementation notes (for the implementing agent)

  • Where: packages/server/src/html-cache.js htmlCacheKey() (L134) is the key builder; the lookup is in ssr.js (readHtmlCache, around L59) and the write is the commitHtmlCache funnel step in dev.js. packages/server/src/forwarded.js owns the trust decision (readForwarded, and the WEBJS_NO_TRUST_PROXY switch).
  • Landmines:
    • The key already folds in the build id, the app-source fingerprint (Fold an app-source fingerprint into the build id (app-only-deploy reload + HTML-cache key) #318), and a generation counter. Read the comments before inserting a component; the shape is load-bearing for deploy-time re-keying, and revalidateAll works by bumping the generation.
    • revalidatePath(path) computes the key from a PATH, not a URL. Adding an origin component must not silently break on-demand eviction, or a mutation stops evicting anything.
    • WEBJS_NO_TRUST_PROXY=1 must keep working as the escape hatch.
    • If option 2 is taken, note that csrf.js requestHost() reads x-forwarded-host unconditionally and does NOT honor WEBJS_NO_TRUST_PROXY, so the trust decision is currently not in one place.
  • Invariants: packages/ stays plain .js + JSDoc. Node/Bun parity (Support both Bun and Node runtimes (first-class create + run) #508): whatever is chosen must behave identically on both shells.
  • Tests: a unit test on the key builder, plus an integration test that poisons through the real handler (request with a hostile X-Forwarded-Host, then a clean request, assert the clean response is not poisoned). A counterfactual must show the test fails against today's key.

Acceptance criteria

  • A request carrying a hostile X-Forwarded-Host cannot influence the cached HTML served to a later request that does not carry it
  • A single-host deploy sees no change in cache behaviour or hit rate
  • revalidatePath / revalidateAll still evict correctly
  • WEBJS_NO_TRUST_PROXY=1 still disables forwarded-header trust
  • Identical behaviour on Node and Bun
  • A counterfactual proves the new test fails against the current key

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

Status
Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions