Skip to content

fix(server): honor X-Forwarded-Proto/Host on the Bun listener #1090

Description

@vivek7405

Problem

Behind a TLS-terminating proxy, an app served by the Bun listener builds every absolute URL with an http:// origin, because the Bun request path never applies the forwarded-header logic the node:http path uses.

Reproducible on the live marketing site (which runs on Bun on Railway, behind Cloudflare):

$ curl -s https://webjs.dev/ | grep 'og:image"'
<meta property="og:image" content="http://webjs.dev/public/og.png">

The host is right (it comes from the Host header) but the scheme is wrong. Some social unfurlers reject or downgrade a non-HTTPS og:image, so correct cards may not render. The blast radius is wider than OG tags: anything built from ctx.url on Bun is affected, including OAuth callback URLs, canonical link tags, sitemap entries, and any app code doing new URL(ctx.url).origin.

This is a Node/Bun parity bug. packages/server/src/forwarded.js urlFromRequest() already implements exactly the right logic (first value of X-Forwarded-Proto / X-Forwarded-Host, WEBJS_NO_TRUST_PROXY=1 opt-out) and its module doc calls out this precise failure mode. It is simply not on the Bun code path.

Design / approach

Apply the same forwarded-header resolution in the Bun listener that the node listener already gets, so the two shells produce an identical ctx.url for an identical request. Per the listener-shell design (#511), shared behaviour belongs in listener-core.js rather than being duplicated per shell.

urlFromRequest() currently takes a node IncomingMessage shape (req.headers as a plain object, possibly array-valued). The Bun path has a web Request (req.headers is a Headers). So the fix needs a header-source-agnostic seam rather than a copy-paste. Suggested: extract the resolution into a function taking a (name) => string | null getter (or accept both shapes), keep urlFromRequest() as the node-shaped wrapper so its callers and tests are untouched, and add a web-Request entry point the Bun shell uses.

Note the Bun shell must not regress the #756 hot-path work: it deliberately avoids new Request(req, ...) clones per request. Prefer computing a corrected URL and threading it, matching how the node shell already passes url alongside the request, over reconstructing the Request.

Implementation notes (for the implementing agent)

  • Where to edit:
    • packages/server/src/forwarded.js urlFromRequest() (L34) is the existing logic + firstHeaderValue() (L54). Add the header-source-agnostic seam here.
    • packages/server/src/listener-bun.js fetch(req, srv) builds const url = new URL(req.url) at L87 (and again at L222 in the WS upgrade path, plus upgradeRequest around L275). These are the sites that skip the forwarded logic.
    • packages/server/src/dev.js startNodeListener() L1726 is the reference correct call.
    • packages/server/src/listener-core.js is where shared-by-both-shells helpers live (Pluggable server-listener seam + a Bun.serve backend #511).
  • Landmines:
    • Two header shapes. urlFromRequest reads req.headers['x-forwarded-proto'] (node object, may be string[]); a web Request needs req.headers.get(...). A naive reuse silently reads undefined and looks like it works while changing nothing.
    • X-Forwarded-Proto can be a comma-separated chain (CDN then LB); take the FIRST value. firstHeaderValue() already does this.
    • Respect WEBJS_NO_TRUST_PROXY=1 (the documented bare-VM opt-out) on the Bun path too, or the two runtimes diverge on the security switch.
    • Do not add a per-request new Request(req, { headers }) clone in the Bun fetch hot path (IMPORTANT: Bun listener per-request overhead (Request clone + zlib bridge) erodes the win #756 removed exactly that for throughput).
    • The WS upgrade path on Bun (L222 / upgradeRequest) has the same gap; the node WS path gets it via websocket.js L45. Fix both or the parity hole just moves.
    • Cloudflare in front of Railway means TWO proxies; the chain-handling above is load-bearing, not theoretical.
  • Invariants to respect: root AGENTS.md runtime parity (WebJs runs on Node 24+ AND Bun, Support both Bun and Node runtimes (first-class create + run) #508) and the packages/server/AGENTS.md listener-shell rule that shared behaviour lives in listener-core.js so the shells cannot drift. packages/ stays plain .js + JSDoc (no .ts).
  • Tests + docs:
    • Unit: extend packages/server/test/forwarded/forwarded.test.js to cover the web-Request shape (proto, host, comma chains, WEBJS_NO_TRUST_PROXY=1).
    • Bun parity is mandatory here (this is a runtime-sensitive listener change): add test/bun/forwarded-proto.mjs + .test.mjs asserting a request carrying X-Forwarded-Proto: https yields an https:// origin under bun, and run node scripts/run-bun-tests.js. packages/server/test/ alone is NOT sufficient, and the pre-commit hook enforces this.
    • A counterfactual must prove the test fires: reverting the Bun fix must fail the new Bun assertion.
    • Docs: the forwarded-proxy behaviour is described in the skill's references/runtime.md (Node vs Bun differences) and the deployment docs; confirm neither claims a Bun gap after the fix.

Acceptance criteria

  • A request with X-Forwarded-Proto: https yields an https:// ctx.url origin on the Bun listener, matching node byte for byte
  • X-Forwarded-Host is honored on Bun too, with comma-separated chains taking the first value
  • WEBJS_NO_TRUST_PROXY=1 disables the trust on Bun exactly as on node
  • The Bun WS upgrade path resolves the same corrected URL
  • No per-request Request clone added to the Bun fetch hot path (IMPORTANT: Bun listener per-request overhead (Request clone + zlib bridge) erodes the win #756 not regressed)
  • A counterfactual proves the new test actually fires
  • Tests at every layer it touches: unit (packages/server/test/forwarded/) AND a test/bun/ cross-runtime assertion
  • Verified against the deployed site: https://webjs.dev/ emits an https:// og:image

Activity

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions