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
The WEBJS_NO_TRUST_PROXY=1 opt-out is documented as the switch for "the container is directly exposed, stop trusting forwarded headers", but it is honored in only two of the three places that read those headers:
Reader
Honors the flag
forwarded.jsreadForwarded() (the request URL rewrite)
yes
headers.jswebRequestIsHttps() (the HSTS scheme gate)
yes
csrf.jsrequestHost() (L71)
no
So an operator who sets the flag because their container IS directly reachable still has one code path resolving its host from a client-settable x-forwarded-host.
This is not currently an exploitable CSRF hole, and the implementing agent should not treat it as one.csrf.js:63-68 already carries an explicit rationale, which holds up on inspection:
a CSRF attack is browser-driven and a browser cannot set x-forwarded-host (or Origin); only a direct non-browser client can, and such a client carries no victim SameSite cookies to abuse.
requestHost is also reached only from the LEGACY fallback branch of verifyOrigin (csrf.js:115), the one that runs when Sec-Fetch-Site is absent. The primary path never calls it.
What is wrong is the inconsistency, on three counts. The trust decision lives in three places with two different postures, so a reader cannot answer "does this app trust forwarded headers?" from one place. The flag does not do what its name and docs imply. And the safety argument rests on "the attacker has no cookies" rather than "we did not trust the header", which is a weaker position to hold as the code moves.
Surfaced while documenting the flag in #1091; the caveat is currently written down in .agents/skills/webjs/references/runtime.md:44 rather than fixed.
Design / approach
Route every forwarded-header read through one seam so the posture is decided once.
Preferred: export the trust decision from forwarded.js (it already owns readForwarded) and have csrf.js and headers.js consume it. requestHost becomes "forwarded host if trusted, else Host, else the url host", which is its current shape minus the unconditional trust.
Alternative: leave the readers alone and only fix the docs. Cheaper, but keeps three postures and the misleading flag name, so it is the fallback if (1) turns out to change behaviour in a way that is not wanted.
Preserve the existing CSRF reasoning in a comment either way; it is correct and non-obvious, and a future reader who deletes it will re-litigate this.
Consider whether the flag deserves a clearer name or a webjs.trustProxy config key alongside it (the config block is the documented home for this kind of switch), but that is optional and should not block the consistency fix.
Implementation notes (for the implementing agent)
Where to edit:
packages/server/src/csrf.jsrequestHost() (L71) is the unconditional read.
packages/server/src/forwarded.jsreadForwarded() (around L175) owns the trust check today; the shared helper belongs here.
packages/server/src/headers.jswebRequestIsHttps() (L203) has its own inline copy of the same process.env check and should consume the shared helper too, so the count of readers goes to one.
Landmines:
requestHost feeds ONLY the no-Sec-Fetch-Site fallback in verifyOrigin (csrf.js:115). Changing it must not alter the primary Sec-Fetch-Site path, which is what nearly every real browser request takes. A test that only exercises modern browsers will not notice a regression here.
Honoring the flag makes the fallback compare Origin against the raw Host instead of the forwarded host. Behind a proxy WITHOUT the flag nothing changes; with the flag set on a proxied deploy (a misconfiguration) a legitimate cross-host request could now be rejected. That is the intended meaning of the flag, but call it out in the changelog.
readForwarded currently returns { host, proto } and applies normalizeProto. requestHost wants the raw host with its own lowercase + comma-split. Do not accidentally route it through the scheme allowlist.
The flag is read from process.env at CALL time, not cached at boot. Keep it that way, since the tests toggle it per-case.
Unit: packages/server/test/csrf/ for the fallback branch with and without the flag; packages/server/test/forwarded/ for the shared helper. Include a counterfactual proving the new assertion fires.
Docs: .agents/skills/webjs/references/runtime.md:44 currently documents the gap as a known limit; update it once the gap is closed. docs/app/docs/deployment/page.ts:63 and docs/app/docs/security/page.ts describe the trust posture.
Acceptance criteria
WEBJS_NO_TRUST_PROXY=1 stops x-forwarded-host from being trusted in the CSRF host resolution
The trust decision is read from ONE place, consumed by all three readers
The primary Sec-Fetch-Site CSRF path is unchanged
The existing rationale for why this was not a CSRF hole is preserved in a comment
Identical behaviour on Node and Bun
A counterfactual proves the new test actually fires
references/runtime.md no longer documents this as a known limit
Problem
The
WEBJS_NO_TRUST_PROXY=1opt-out is documented as the switch for "the container is directly exposed, stop trusting forwarded headers", but it is honored in only two of the three places that read those headers:forwarded.jsreadForwarded()(the request URL rewrite)headers.jswebRequestIsHttps()(the HSTS scheme gate)csrf.jsrequestHost()(L71)So an operator who sets the flag because their container IS directly reachable still has one code path resolving its host from a client-settable
x-forwarded-host.This is not currently an exploitable CSRF hole, and the implementing agent should not treat it as one.
csrf.js:63-68already carries an explicit rationale, which holds up on inspection:requestHostis also reached only from the LEGACY fallback branch ofverifyOrigin(csrf.js:115), the one that runs whenSec-Fetch-Siteis absent. The primary path never calls it.What is wrong is the inconsistency, on three counts. The trust decision lives in three places with two different postures, so a reader cannot answer "does this app trust forwarded headers?" from one place. The flag does not do what its name and docs imply. And the safety argument rests on "the attacker has no cookies" rather than "we did not trust the header", which is a weaker position to hold as the code moves.
Surfaced while documenting the flag in #1091; the caveat is currently written down in
.agents/skills/webjs/references/runtime.md:44rather than fixed.Design / approach
Route every forwarded-header read through one seam so the posture is decided once.
forwarded.js(it already ownsreadForwarded) and havecsrf.jsandheaders.jsconsume it.requestHostbecomes "forwarded host if trusted, elseHost, else the url host", which is its current shape minus the unconditional trust.Preserve the existing CSRF reasoning in a comment either way; it is correct and non-obvious, and a future reader who deletes it will re-litigate this.
Consider whether the flag deserves a clearer name or a
webjs.trustProxyconfig key alongside it (the config block is the documented home for this kind of switch), but that is optional and should not block the consistency fix.Implementation notes (for the implementing agent)
packages/server/src/csrf.jsrequestHost()(L71) is the unconditional read.packages/server/src/forwarded.jsreadForwarded()(around L175) owns the trust check today; the shared helper belongs here.packages/server/src/headers.jswebRequestIsHttps()(L203) has its own inline copy of the sameprocess.envcheck and should consume the shared helper too, so the count of readers goes to one.requestHostfeeds ONLY the no-Sec-Fetch-Sitefallback inverifyOrigin(csrf.js:115). Changing it must not alter the primarySec-Fetch-Sitepath, which is what nearly every real browser request takes. A test that only exercises modern browsers will not notice a regression here.Originagainst the rawHostinstead of the forwarded host. Behind a proxy WITHOUT the flag nothing changes; with the flag set on a proxied deploy (a misconfiguration) a legitimate cross-host request could now be rejected. That is the intended meaning of the flag, but call it out in the changelog.readForwardedcurrently returns{ host, proto }and appliesnormalizeProto.requestHostwants the raw host with its own lowercase + comma-split. Do not accidentally route it through the scheme allowlist.process.envat CALL time, not cached at boot. Keep it that way, since the tests toggle it per-case.packages/stays plain.js+ JSDoc (root AGENTS.md). Node/Bun parity (Support both Bun and Node runtimes (first-class create + run) #508): the behaviour must be identical on both shells.packages/server/test/csrf/for the fallback branch with and without the flag;packages/server/test/forwarded/for the shared helper. Include a counterfactual proving the new assertion fires..agents/skills/webjs/references/runtime.md:44currently documents the gap as a known limit; update it once the gap is closed.docs/app/docs/deployment/page.ts:63anddocs/app/docs/security/page.tsdescribe the trust posture.Acceptance criteria
WEBJS_NO_TRUST_PROXY=1stopsx-forwarded-hostfrom being trusted in the CSRF host resolutionSec-Fetch-SiteCSRF path is unchangedreferences/runtime.mdno longer documents this as a known limit