You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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:
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.
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.
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.jshtmlCacheKey() (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).
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.jsrequestHost() reads x-forwarded-host unconditionally and does NOT honor WEBJS_NO_TRUST_PROXY, so the trust decision is currently not in one place.
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
Problem
htmlCacheKey(packages/server/src/html-cache.js:134) keys the shared HTML response cache onpathname+ sorted search params only, with no host or scheme component:The framework trusts
X-Forwarded-Hostto buildctx.url, so on a page that opts into the cache withexport const revalidate = Nan attacker can bake an origin of their choosing into the SHARED cached body. Verified end to end against a real app on Bun:Every subsequent visitor to that path is served the poisoned HTML until the entry expires. Anything the page derives from
ctx.urlis 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 theforwarded.js:15-21threat-model note ("no path for an attacker to inject these headers without going through that proxy") does not hold forX-Forwarded-Hostspecifically: going through the proxy is exactly how it arrives.Current real-world exposure is low: no in-repo app sets
export const revalidateon 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:
X-Forwarded-Hostby default, with an opt-in allowlist (awebjs.trustedHostsconfig). Closer to the root cause and matches how other frameworks handle it, but a bigger behaviour change and it affects more than the cache.(1) and (2) compose. (1) alone is enough to stop the cross-visitor poisoning.
Implementation notes (for the implementing agent)
packages/server/src/html-cache.jshtmlCacheKey()(L134) is the key builder; the lookup is inssr.js(readHtmlCache, around L59) and the write is thecommitHtmlCachefunnel step indev.js.packages/server/src/forwarded.jsowns the trust decision (readForwarded, and theWEBJS_NO_TRUST_PROXYswitch).revalidateAllworks 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=1must keep working as the escape hatch.csrf.jsrequestHost()readsx-forwarded-hostunconditionally and does NOT honorWEBJS_NO_TRUST_PROXY, so the trust decision is currently not in one place.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.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
X-Forwarded-Hostcannot influence the cached HTML served to a later request that does not carry itrevalidatePath/revalidateAllstill evict correctlyWEBJS_NO_TRUST_PROXY=1still disables forwarded-header trust