From 29d3f03b62af8a8ece358d88650fc8db72e3e584 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 25 Jul 2026 17:15:24 +0530 Subject: [PATCH 01/10] fix(server): honor X-Forwarded-Proto/Host on the Bun listener Behind a TLS-terminating proxy, an app on the Bun shell built every absolute URL with an http:// origin. The node shell was always correct, because toWebRequest builds its Request from an already-corrected urlFromRequest; Bun.serve hands over a Request whose url reflects the internal container view, and that went straight to app.handle. This shipped: webjs.dev runs on Bun behind Cloudflare and served . The blast radius is every absolute URL an app derives, so OAuth callbacks, canonical tags and sitemap entries were affected too, not just OG tags. urlFromRequest could not be reused as-is: it reads node's plain headers object, while a web Request exposes Headers.get, so reusing it against a Request silently reads undefined and looks wired up while changing nothing. Both entry points now funnel through one readForwarded so the runtimes cannot drift on the trust switch or the comma-chain rule. The correction is applied to the Request itself rather than threaded alongside it, because a route.ts handler reads req.url directly; fixing only the framework's own derivation would leave app code broken. The rebuild is skipped entirely when no forwarded header changes the origin (applyForwarded returns the same instance), so an unproxied app keeps the #756 no-clone hot path, and the WS upgrade path gets the same treatment. --- packages/server/src/forwarded.js | 54 +++++++++-- packages/server/src/listener-bun.js | 45 ++++++++- .../server/test/forwarded/forwarded.test.js | 90 +++++++++++++++++- test/bun/forwarded-proto.mjs | 94 +++++++++++++++++++ test/bun/forwarded-proto.test.mjs | 13 +++ 5 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 test/bun/forwarded-proto.mjs create mode 100644 test/bun/forwarded-proto.test.mjs diff --git a/packages/server/src/forwarded.js b/packages/server/src/forwarded.js index cf09a1720..56e7cd546 100644 --- a/packages/server/src/forwarded.js +++ b/packages/server/src/forwarded.js @@ -32,23 +32,61 @@ * @returns {URL} */ export function urlFromRequest(req) { - const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1'; - let host = null; - let proto = null; - if (trust) { - host = firstHeaderValue(req.headers['x-forwarded-host']); - proto = firstHeaderValue(req.headers['x-forwarded-proto']); - } + const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]); const finalHost = host || /** @type {string|undefined} */ (req.headers.host) || 'localhost'; const finalProto = proto || 'http'; return new URL(req.url || '/', `${finalProto}://${finalHost}`); } +/** + * Apply the forwarded headers to an ALREADY-PARSED url, for a shell whose + * request is a web `Request` (Bun) rather than a node `IncomingMessage`. + * + * `urlFromRequest` above cannot be reused directly: it reads `req.headers` as a + * plain node object, while a web `Request` exposes a `Headers` instance whose + * values come from `.get()`. Reusing it against a `Request` silently reads + * `undefined` for every header, so it LOOKS wired up while changing nothing. + * Both entry points funnel through `readForwarded` so the two runtimes cannot + * drift on the trust switch or the comma-chain rule. + * + * Returns the SAME URL instance when nothing changes (no proxy, or the headers + * already agree), so the caller can use identity to skip rebuilding a request + * on the hot path. + * + * @param {URL} url the url as the local listener saw it + * @param {Headers} headers the web `Request` headers + * @returns {URL} the corrected url, or `url` itself when unchanged + */ +export function applyForwarded(url, headers) { + const { host, proto } = readForwarded((n) => headers.get(n)); + const finalHost = host || url.host; + // `url.protocol` carries its trailing colon; the forwarded header does not. + const finalProto = proto || url.protocol.slice(0, -1); + if (finalHost === url.host && `${finalProto}:` === url.protocol) return url; + return new URL(`${url.pathname}${url.search}${url.hash}`, `${finalProto}://${finalHost}`); +} + +/** + * Read the forwarded host / proto through a header getter, honoring the + * `WEBJS_NO_TRUST_PROXY=1` opt-out. The one place the trust decision and the + * comma-chain rule live, shared by the node and Bun entry points above. + * + * @param {(name: string) => string | string[] | undefined | null} getHeader + * @returns {{ host: string | null, proto: string | null }} + */ +function readForwarded(getHeader) { + if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null }; + return { + host: firstHeaderValue(getHeader('x-forwarded-host')), + proto: firstHeaderValue(getHeader('x-forwarded-proto')), + }; +} + /** * Pick the first comma-separated value from a header that may be a * string, an array of strings, or undefined. * - * @param {string | string[] | undefined} h + * @param {string | string[] | undefined | null} h * @returns {string | null} */ function firstHeaderValue(h) { diff --git a/packages/server/src/listener-bun.js b/packages/server/src/listener-bun.js index 5e4c45e42..ac57c8bca 100644 --- a/packages/server/src/listener-bun.js +++ b/packages/server/src/listener-bun.js @@ -45,7 +45,8 @@ import { EventEmitter } from 'node:events'; import { Readable, pipeline } from 'node:stream'; import { matchApi } from './router.js'; import { registerClient } from './broadcast.js'; -import { setTrustedRemoteIp } from './rate-limit.js'; +import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js'; +import { applyForwarded } from './forwarded.js'; import { isCompressible, isEventsPath, @@ -104,7 +105,7 @@ export function startBunListener(ctx) { // head-start during SSR compute, not the preloads themselves. stampRemoteIp(req, srv); - const resp = await app.handle(req); + const resp = await app.handle(forwardedRequest(req, url)); return compress ? await maybeCompress(resp, req) : resp; } catch (e) { logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) }); @@ -237,7 +238,11 @@ async function bunUpgrade(req, srv, ctx) { } const wrapper = new BunWsAdapter(); - const handlerReq = upgradeRequest(req, url); + // Behind a proxy the handshake arrives as plain http on the internal hop, so + // the handler request carries the ORIGINAL scheme + host too (the node WS path + // gets this from `urlFromRequest` in `buildRequestFromUpgrade`). Routing above + // keyed on `url.pathname`, which the origin swap leaves untouched. + const handlerReq = upgradeRequest(req, applyForwarded(url, req.headers)); // Stamp the framework-trusted socket IP on the handler request so a `WS` // handler's `clientIp(req)` returns the real peer, not a spoofed inbound // `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's @@ -317,6 +322,40 @@ function bunSseResponse(req, hub, app) { }); } +/** + * Rebuild the request behind a TLS-terminating reverse proxy so `req.url` + * carries the ORIGINAL scheme + host (`X-Forwarded-Proto` / `X-Forwarded-Host`). + * + * Why the request itself and not just a threaded url: everything downstream + * (the framework's own `new URL(req.url)` reads AND a user's `route.ts` doing + * `new URL(req.url).origin`) derives from `req.url`, so correcting only a side + * channel would fix the framework's absolute URLs and leave app code broken. + * The node shell gets this for free, since `toWebRequest` builds its `Request` + * FROM the already-corrected `urlFromRequest(req)`; Bun hands us a `Request` + * whose url reflects the internal `http://container` view, so the correction + * has to be applied here for the two shells to agree. + * + * Hot path: `applyForwarded` returns the SAME url instance when nothing + * changes, so an app with no proxy in front (local dev, direct exposure, + * `WEBJS_NO_TRUST_PROXY=1`) does ZERO extra work and keeps the #756 no-clone + * behaviour. Behind a proxy this costs one `Request` construction per request, + * which is still strictly less than the node shell, which constructs one + * unconditionally. The trusted IP is carried across the rebuild the same way + * the base-path rewrite in `dev.js` does it (#773), so `clientIp` stays + * authoritative and the inbound spoofable header is never consulted. + * + * @param {Request} req + * @param {URL} url the url as `Bun.serve` saw it + * @returns {Request} `req` itself when unchanged, else the corrected request + */ +function forwardedRequest(req, url) { + const fwd = applyForwarded(url, req.headers); + if (fwd === url) return req; + const next = new Request(fwd, req); + propagateTrustedRemoteIp(req, next); + return next; +} + /** * Stamp the framework-trusted remote IP from Bun's `server.requestIP`, * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation diff --git a/packages/server/test/forwarded/forwarded.test.js b/packages/server/test/forwarded/forwarded.test.js index f411bf5ea..69e8c674a 100644 --- a/packages/server/test/forwarded/forwarded.test.js +++ b/packages/server/test/forwarded/forwarded.test.js @@ -12,7 +12,7 @@ */ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { urlFromRequest } from '../../src/forwarded.js'; +import { urlFromRequest, applyForwarded } from '../../src/forwarded.js'; function makeReq(url, headers = {}) { return { url, headers }; @@ -120,3 +120,91 @@ test('urlFromRequest: preserves query string + hash through proxy', () => { })); assert.equal(u.href, 'https://docs.webjs.dev/search?q=hello&page=2#results'); }); + +/** + * applyForwarded: the web-`Request` counterpart of urlFromRequest, used by the + * Bun listener shell (#1090). The node shell builds its `Request` from an + * already-corrected url, so the two entry points must agree for an identical + * request or the same app behaves differently on Node and Bun. + */ + +function webHeaders(h = {}) { + return new Headers(h); +} + +test('applyForwarded: proxy proto + host rewrite the origin', () => { + const url = new URL('http://container:3000/about'); + const out = applyForwarded(url, webHeaders({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'webjs.dev', + })); + assert.equal(out.href, 'https://webjs.dev/about'); +}); + +test('applyForwarded: proto alone upgrades the scheme, keeping the host', () => { + // Railway's shape: the Host header already carries the public domain, only + // the scheme is internal. This is the exact case that shipped an http:// + // og:image on webjs.dev. + const out = applyForwarded(new URL('http://webjs.dev/'), webHeaders({ 'x-forwarded-proto': 'https' })); + assert.equal(out.href, 'https://webjs.dev/'); +}); + +test('applyForwarded: no proxy headers returns the SAME instance (hot-path no-op)', () => { + const url = new URL('http://localhost:5001/'); + const out = applyForwarded(url, webHeaders({})); + // Identity, not just equality: the Bun shell keys its skip-the-rebuild + // decision on this, so an app with no proxy does zero extra work. + assert.equal(out, url); +}); + +test('applyForwarded: headers that agree with the url return the SAME instance', () => { + const url = new URL('https://webjs.dev/x'); + const out = applyForwarded(url, webHeaders({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'webjs.dev', + })); + assert.equal(out, url); +}); + +test('applyForwarded: comma-separated chain takes the value closest to the client', () => { + // CDN then load balancer then container: Cloudflare in front of Railway is + // exactly this shape. + const out = applyForwarded(new URL('http://container/'), webHeaders({ + 'x-forwarded-proto': 'https,http', + 'x-forwarded-host': 'webjs.dev, internal.railway', + })); + assert.equal(out.href, 'https://webjs.dev/'); +}); + +test('applyForwarded: preserves path, query and hash across the origin swap', () => { + const out = applyForwarded(new URL('http://container/search?q=hello&page=2#results'), webHeaders({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'docs.webjs.dev', + })); + assert.equal(out.href, 'https://docs.webjs.dev/search?q=hello&page=2#results'); +}); + +test('applyForwarded: WEBJS_NO_TRUST_PROXY=1 ignores the headers', () => { + const prev = process.env.WEBJS_NO_TRUST_PROXY; + process.env.WEBJS_NO_TRUST_PROXY = '1'; + try { + const url = new URL('http://real-host:3000/x'); + const out = applyForwarded(url, webHeaders({ + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'attacker.example.com', + })); + assert.equal(out, url); + } finally { + if (prev !== undefined) process.env.WEBJS_NO_TRUST_PROXY = prev; + else delete process.env.WEBJS_NO_TRUST_PROXY; + } +}); + +test('applyForwarded and urlFromRequest agree for the same request', () => { + // The parity assertion: whatever the node shell computes from an + // IncomingMessage, the Bun shell must compute from the web Request. + const headers = { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'webjs.dev' }; + const node = urlFromRequest(makeReq('/a/b?c=1', { host: 'container:3000', ...headers })); + const bun = applyForwarded(new URL('http://container:3000/a/b?c=1'), webHeaders(headers)); + assert.equal(bun.href, node.href); +}); diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs new file mode 100644 index 000000000..90c91e3c5 --- /dev/null +++ b/test/bun/forwarded-proto.mjs @@ -0,0 +1,94 @@ +/** + * Cross-runtime forwarded-header proof (#1090): boot a real WebJs app through + * `startServer` and assert, under WHICHEVER runtime runs it, that a request + * carrying `X-Forwarded-Proto` / `X-Forwarded-Host` is seen by the app with the + * ORIGINAL scheme + host: + * + * node test/bun/forwarded-proto.mjs # the node:http shell (urlFromRequest -> toWebRequest) + * bun test/bun/forwarded-proto.mjs # the Bun.serve shell (applyForwarded -> forwardedRequest) + * + * The node shell always got this right, because `toWebRequest` builds its + * `Request` from an already-corrected url. The Bun shell handed `Bun.serve`'s + * request straight through, so `req.url` kept the internal `http://container` + * view and every absolute URL the app derived came out `http://`. That shipped: + * https://webjs.dev (Bun on Railway behind Cloudflare) served + * ``. + * + * Both surfaces are asserted, because they fail independently: + * 1. a PAGE's `ctx.url` (what `generateMetadata` builds og:image from), and + * 2. a `route.ts` handler's raw `req.url` (what app code reads directly). + * A fix that only threads a corrected url into the framework's own metadata + * path would pass (1) and still leave (2) broken. + * + * A plain assert script (not node:test), so the SAME file runs on both runtimes. + * Run from the repo root so the bare `@webjsdev/server` specifier resolves. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { startServer } from '@webjsdev/server'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CORE = pathToFileURL(resolve(__dirname, '../../packages/core/index.js')).toString(); +const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; +const quiet = { info() {}, warn() {}, error() {}, debug() {} }; + +const dir = mkdtempSync(join(tmpdir(), 'wj-forwarded-')); +const w = (rel, body) => { const abs = join(dir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, body); }; + +let close; +try { + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'forwarded', type: 'module', webjs: {} })); + w('app/layout.ts', `import { html } from ${JSON.stringify(CORE)};\nexport default ({ children }: { children: unknown }) => html\`\${children}\`;`); + // The real shape: a page building an absolute asset URL from ctx.url, exactly + // how website/app/layout.ts derives its og:image. + w('app/page.ts', `import { html } from ${JSON.stringify(CORE)};\nexport function generateMetadata(ctx: { url: string }) {\n const origin = new URL(ctx.url).origin;\n return { title: 'fwd', openGraph: { image: origin + '/public/og.png' } };\n}\nexport default () => html\`
page
\`;`); + // A route handler reading the raw request url, the app-code surface. + w('app/api/whoami/route.ts', `export async function GET(req: Request) {\n return Response.json({ url: req.url, origin: new URL(req.url).origin });\n}`); + + let server; + ({ server, close } = await startServer({ appDir: dir, dev: true, port: 0, logger: quiet })); + const port = typeof server.port === 'number' ? server.port : server.address().port; + const base = `http://localhost:${port}`; + const proxied = { 'x-forwarded-proto': 'https', 'x-forwarded-host': 'webjs.dev' }; + + // 1. The page's ctx.url origin, read back off the rendered og:image tag. + const page = await fetch(`${base}/`, { headers: proxied }); + assert.equal(page.status, 200, 'page is 200'); + const html = await page.text(); + const og = / { + await import('./forwarded-proto.mjs'); +}); From 4a03597315b5612ad2eb14fdee32034fbdec7f3a Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 25 Jul 2026 17:18:28 +0530 Subject: [PATCH 02/10] test(bun): fail the forwarded proof honestly on Bun startServer installs an uncaughtException handler that begins a graceful shutdown and exits 0. On Bun a top-level assertion failure routes through it, so the proof exited 0 on a real regression and CI's direct 'bun test/bun/.mjs' step would have gone green. Verified: node exits 1, Bun exits 0. Capture the failure and exit 1 explicitly. The other proof scripts share this shape and are filed separately. --- test/bun/forwarded-proto.mjs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs index 90c91e3c5..ca88d62b2 100644 --- a/test/bun/forwarded-proto.mjs +++ b/test/bun/forwarded-proto.mjs @@ -22,6 +22,14 @@ * * A plain assert script (not node:test), so the SAME file runs on both runtimes. * Run from the repo root so the bare `@webjsdev/server` specifier resolves. + * + * The failure is reported by an explicit `process.exit(1)` rather than by letting + * the assertion propagate, because `startServer` installs an `uncaughtException` + * handler that begins a graceful shutdown and exits 0. On Bun a top-level + * assertion failure routes through that handler, so a broken proof would exit 0 + * and CI's `bun test/bun/.mjs` step would go GREEN on a real regression + * (verified: node exits 1, Bun exits 0). Filed separately as #1091 for the other + * proof scripts, which all share this shape. */ import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; @@ -39,6 +47,8 @@ const dir = mkdtempSync(join(tmpdir(), 'wj-forwarded-')); const w = (rel, body) => { const abs = join(dir, rel); mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, body); }; let close; +/** @type {unknown} */ +let failure = null; try { writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'forwarded', type: 'module', webjs: {} })); w('app/layout.ts', `import { html } from ${JSON.stringify(CORE)};\nexport default ({ children }: { children: unknown }) => html\`\${children}\`;`); @@ -88,7 +98,15 @@ try { await close(); close = null; console.log(`OK forwarded proto/host passed on ${runtime} (page ctx.url + route req.url both https)`); +} catch (e) { + failure = e; } finally { try { if (close) await close(); } catch {} rmSync(dir, { recursive: true, force: true }); } + +if (failure) { + console.error(`FAIL forwarded proto/host on ${runtime}`); + console.error(failure instanceof Error ? failure.stack || failure.message : String(failure)); + process.exit(1); +} From 275ea6d09e00919d1caa9da4a519caf3777c9e4c Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 25 Jul 2026 17:23:52 +0530 Subject: [PATCH 03/10] ci: run the forwarded proto/host proof on Bun The Bun shell is where the bug was, so the proof needs its own matrix step alongside the other listener proofs. Also corrects the cross-reference for the harness exit-code bug to #1092. --- .github/workflows/ci.yml | 8 ++++++++ test/bun/forwarded-proto.mjs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 580993ca2..5c86ce4b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -187,6 +187,14 @@ jobs: # on the Bun SSR path (buffered and streamed) as on Node. - name: webjs binding-prefix dispatch on Bun run: bun test/bun/binding-prefixes.mjs + # Reverse-proxy forwarded headers on Bun (#1090): the node shell corrects + # the request url via urlFromRequest before building its Request, but the + # Bun shell handed Bun.serve's request straight through, so behind a + # TLS-terminating proxy every absolute URL an app derived came out http:// + # (webjs.dev served an http:// og:image). This asserts a page's ctx.url AND + # a route handler's raw req.url both carry the forwarded scheme + host. + - name: Forwarded proto/host on Bun + run: bun test/bun/forwarded-proto.mjs # Light-DOM slot SSR projection on Bun (#1021): slot substitution # (injectDSD / substituteSlotsInRender) is on the SSR hot path, so the # projection must be byte-consistent across runtimes: authored children diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs index ca88d62b2..140ca1897 100644 --- a/test/bun/forwarded-proto.mjs +++ b/test/bun/forwarded-proto.mjs @@ -28,7 +28,7 @@ * handler that begins a graceful shutdown and exits 0. On Bun a top-level * assertion failure routes through that handler, so a broken proof would exit 0 * and CI's `bun test/bun/.mjs` step would go GREEN on a real regression - * (verified: node exits 1, Bun exits 0). Filed separately as #1091 for the other + * (verified: node exits 1, Bun exits 0). Filed separately as #1092 for the other * proof scripts, which all share this shape. */ import assert from 'node:assert/strict'; From 9159fbc7066660d3a850b9b8deaff2198f6583f7 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 25 Jul 2026 17:25:54 +0530 Subject: [PATCH 04/10] docs: record the forwarded proto/host behaviour and Bun parity The runtime reference's Node vs Bun table now carries the reverse-proxy row (it was the one place a reader would look to find out whether the two shells agree), the server module map documents forwarded.js and the Bun rebuild, and the deployment page's Bun parity list is extended now that the claim is actually true. --- .agents/skills/webjs/references/runtime.md | 3 +++ docs/app/docs/deployment/page.ts | 2 +- packages/server/AGENTS.md | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index e8911d707..25426aec0 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -35,9 +35,12 @@ Three seams pick a runtime-specific implementation, all inside the framework, no | Hot reload | `node --watch` | `bun --hot` | | WebSocket | the `ws` library | native `Bun.serve` + a bridge adapter | | 103 Early Hints | yes | no (`Bun.serve` has no informational-response API) | +| Reverse-proxy headers | `X-Forwarded-Proto` / `X-Forwarded-Host` honored | same | The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes. +Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client. Set `WEBJS_NO_TRUST_PROXY=1` to stop trusting the headers when the container is directly exposed. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site. + ## Scaffolding a Bun app `webjs create ` defaults to Node. Add `--runtime bun` for a Bun-flavored app (or run `bun create webjs `, which auto-detects Bun from the invoking package manager): diff --git a/docs/app/docs/deployment/page.ts b/docs/app/docs/deployment/page.ts index b4bd7c25b..2b7b4c5cb 100644 --- a/docs/app/docs/deployment/page.ts +++ b/docs/app/docs/deployment/page.ts @@ -249,7 +249,7 @@ server.all('*', async (req, res) => { server.listen(8080);

Bun

-

Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:

+

Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP, and the X-Forwarded-Proto / X-Forwarded-Host URL correction so absolute URLs carry the original scheme and host behind a TLS-terminating proxy). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:

import { createRequestHandler } from '@webjsdev/server';
 
 const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md
index af45acf80..1262fc1b6 100644
--- a/packages/server/AGENTS.md
+++ b/packages/server/AGENTS.md
@@ -56,7 +56,8 @@ with metadata, Suspense, streaming) for HTML, or `api.js` /
 | `csrf.js` | Origin / `Sec-Fetch-Site` CSRF protection for server-action endpoints (`verifyOrigin`), plus `readAllowedOrigins` (the `webjs.allowedOrigins` reader). No token cookie, so SSR HTML carries no `Set-Cookie` and a public-`Cache-Control` page is CDN-cacheable |
 | `websocket.js` | node:http WS upgrade handling: invokes the `WS` export from `route.ts` over the `ws` library. Shares the `route.ts` module resolution (`loadWsModule`) with the Bun WS path via `listener-core.js`. **IP-trust on the upgrade (#778):** `buildRequestFromUpgrade` strips any inbound (spoofable) `x-webjs-remote-ip` and stamps the real `req.socket.remoteAddress` via `setTrustedRemoteIp` (the authoritative WeakMap `clientIp` reads first), so a `WS` handler calling `clientIp(req)` gets the trusted peer, not a client-supplied header. The WS-seam analog of the #773 fetch-path fix (the upgrade check runs before the fetch handler's stamp, so it was never stamped before). |
 | `listener-core.js` | Runtime-neutral listener core (#511): the pieces both listener shells share so they cannot drift. `serverRuntime()` picks the shell (`'bun'` when `process.versions.bun` is set, else `'node'`); `SseHub` is the SSE registry + fanout (the connected-client Set, the keepalive timer, `reload()` / `devError()`, each shell supplying a thin client wrapper over its own transport); `isEventsPath` is the base-path-aware live-reload predicate; `isCompressible` is the shared compressible-media-type set; the **compression seam (#517, #756)** `negotiateEncoding` (prefers `br` > `gzip` > `deflate`) + `createCompressor` (a streaming `node:zlib` Transform, native on Bun, so BOTH shells get brotli) + `compressBufferSync` (the SYNC counterpart for an already-buffered body, byte-identical output) + `readBufferedOrStream` (peeks a body to classify single-bounded-chunk buffered vs genuinely streamed, so the Bun shell can sync-compress a buffered body and skip the stream bridge, #756; it RACES the classifying second read against a macrotask sentinel rather than awaiting it, so a genuinely streamed body, Suspense / streamed action, is handed back immediately and its response head + first byte are NOT withheld until the far-off second chunk) + `varyWithAcceptEncoding` are shared so the node and Bun shells compress identically; `loadWsModule` loads a `route.ts` for its `WS` export (shared by `websocket.js` and the Bun WS path); `installProcessHandlers` + `makeShutdown` are the neutral process-handler + graceful-shutdown wiring (`makeShutdown` takes a `closeServer()` thunk so node `server.close` and Bun `server.stop(true)` both fit). |
-| `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
+| `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. **Reverse-proxy URL correction (#1090):** `Bun.serve` hands over a `Request` whose url is the internal `http://container` view, so `forwardedRequest` rebuilds it from `applyForwarded` (`forwarded.js`) when `X-Forwarded-Proto` / `X-Forwarded-Host` change the origin, the Bun analog of the node shell building its `Request` from `urlFromRequest`. The rebuild is skipped (same URL instance returned) when nothing changes, so an unproxied app keeps the #756 no-clone hot path; the trusted IP rides across via `propagateTrustedRemoteIp`, and `bunUpgrade` applies the same correction to the WS handler request. Without it every absolute URL an app derived came out `http://` behind a TLS-terminating proxy. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
+| `forwarded.js` | Reverse-proxy URL resolution (`X-Forwarded-Proto` / `X-Forwarded-Host`), honoring the `WEBJS_NO_TRUST_PROXY=1` opt-out and taking the first value of a comma-separated chain (the value closest to the client). Two entry points over ONE `readForwarded` core so the shells cannot drift: `urlFromRequest(req)` for a node `IncomingMessage` (used by `startNodeListener` + `websocket.js`) and `applyForwarded(url, headers)` for a web `Request`'s `Headers` (used by the Bun shell, #1090). `applyForwarded` returns the SAME url instance when nothing changes, which is the caller's signal to skip rebuilding the request. Without this, a TLS-terminating proxy leaves `ctx.url.origin` as `http://internal-host`, breaking og:image tags, OAuth callback URLs, canonical tags, and any app code building an absolute URL. |
 | `listener-types.js` | Types only: the `ListenerContext` typedef `startServer` passes to whichever shell it selects (the node:http path in `dev.js`, the Bun path in `listener-bun.js`). |
 | `broadcast.js` | `broadcast(topic, msg)` for fan-out messaging |
 | `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`). The per-user readers `headers()` / `cookies()` (plus `getSession()` in `session.js` and `readSession()` behind `auth()` in `auth.js`) call `markDynamicAccess()`, so the HTML cache's commit step reads `dynamicAccessed()` and refuses to cache a per-user page that wrongly set `revalidate` (#241). Also exposes the per-request correlation id via `requestId()` (set by the handler with `setRequestId`, #239) and wires the server-side `cspNonce()` provider: returns the per-request nonce `setCspNonce` stored (minted when CSP is on, #233), else falls back to parsing an inbound `Content-Security-Policy` request header |

From 3e09bfc67773b985ad9216da5ba4900cbc56f07c Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Sat, 25 Jul 2026 17:44:24 +0530
Subject: [PATCH 05/10] fix(server): rewrite the forwarded origin without
 re-parsing the path

Round-1 review found the rewrite itself was unsafe. Rebuilding the URL by
re-parsing url.pathname against a new base treats a //-prefixed path as a
scheme-relative reference, so GET //evil.com/x with nothing but the
X-Forwarded-Proto every TLS-terminating proxy sets resolved to
https://evil.com/x: the origin is taken over AND the path collapses to /x,
so a different route matches. That was a NEW exposure on Bun, which
previously kept the real host and the full path.

Both entry points now build the origin through one shared resolveOrigin
and assign the path parts instead of resolving them, so parity is
structural rather than two similar-looking expressions. Assigning through
the URL setters also fails safe: a malformed authority is ignored rather
than throwing (it was a 500 on the fetch path and a broken WS handshake),
and each layer clears the previous port so a public hostname cannot
inherit the internal one.

Also restricts the forwarded scheme to http/https. A non-special scheme
like javascript collapsed origin to the literal string null, so every
absolute URL the app derived became null/..., and it is matched
case-insensitively so an uppercase HTTPS is still the no-op.

The rebuilt request now strips a client-supplied x-webjs-remote-ip, which
propagateTrustedRemoteIp documents the caller must do and every other
rebuild site already did.
---
 packages/server/src/forwarded.js              | 122 ++++++++++++++++--
 packages/server/src/listener-bun.js           |  40 +++++-
 .../server/test/forwarded/forwarded.test.js   |  89 +++++++++++++
 3 files changed, 242 insertions(+), 9 deletions(-)

diff --git a/packages/server/src/forwarded.js b/packages/server/src/forwarded.js
index 56e7cd546..839f5dd4c 100644
--- a/packages/server/src/forwarded.js
+++ b/packages/server/src/forwarded.js
@@ -33,9 +33,69 @@
  */
 export function urlFromRequest(req) {
   const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]);
-  const finalHost = host || /** @type {string|undefined} */ (req.headers.host) || 'localhost';
-  const finalProto = proto || 'http';
-  return new URL(req.url || '/', `${finalProto}://${finalHost}`);
+  const rawHost = /** @type {string|undefined} */ (req.headers.host) || '';
+  const u = resolveOrigin(proto || 'http', host, rawHost);
+  // Assign the request target's parts rather than RESOLVING it against `u`.
+  // Resolving is unsafe: a request line may carry `//evil.com/x`, a
+  // scheme-relative reference, so `new URL('//evil.com/x', 'https://real-host')`
+  // resolves to `https://evil.com/x`, handing over the origin AND silently
+  // rewriting the path to `/x` so a different route matches. A request target
+  // is a path, never a full reference, so it is assigned as one.
+  const m = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(req.url || '/');
+  u.pathname = (m && m[1]) || '/';
+  u.search = (m && m[2]) || '';
+  u.hash = (m && m[3]) || '';
+  return u;
+}
+
+/**
+ * Build the origin both entry points agree on, as an origin-only URL.
+ *
+ * Sharing this is what makes node/Bun parity STRUCTURAL rather than a
+ * coincidence of two similar-looking expressions. The layering matters: start
+ * from a known-good placeholder, apply the raw `Host`, then let the forwarded
+ * host override it. Each assignment goes through the `host` setter, which
+ * IGNORES a value it cannot parse instead of throwing, so a junk header falls
+ * back to the previous layer rather than 500ing the request or collapsing to
+ * localhost.
+ *
+ * @param {string} proto  already restricted to http / https by `normalizeProto`
+ * @param {string | null} forwardedHost
+ * @param {string} rawHost  the `Host` header
+ * @returns {URL} an origin-only URL
+ */
+function resolveOrigin(proto, forwardedHost, rawHost) {
+  const u = new URL(`${proto}://localhost`);
+  if (rawHost) setHost(u, rawHost);
+  if (forwardedHost) setHost(u, forwardedHost);
+  return u;
+}
+
+/**
+ * Assign an authority, clearing any port the previous layer left behind.
+ *
+ * The `host` setter only updates the port when the new value CARRIES one, so
+ * layering `X-Forwarded-Host: docs.webjs.dev` over `Host: container:3000`
+ * otherwise yields `docs.webjs.dev:3000`: the public hostname wearing the
+ * internal port. Clearing first makes each layer a full replacement.
+ *
+ * @param {URL} u
+ * @param {string} value
+ */
+function setHost(u, value) {
+  let probe;
+  try {
+    probe = new URL(`${u.protocol}//${value}`);
+  } catch {
+    // Not a parseable authority (`a b`, `[`, a port over 65535). Leave the
+    // previous layer in place rather than throwing, so a junk header is never a
+    // 500 on the fetch path or a failed WS handshake.
+    return;
+  }
+  u.hostname = probe.hostname;
+  // Assign the port unconditionally, including the empty string, so a value
+  // carrying no port clears one the previous layer left behind.
+  u.port = probe.port;
 }
 
 /**
@@ -53,17 +113,43 @@ export function urlFromRequest(req) {
  * already agree), so the caller can use identity to skip rebuilding a request
  * on the hot path.
  *
+ * The rewrite CLONES the url and assigns `protocol` / `host` rather than
+ * re-parsing the path against a new base. Re-parsing is unsafe: `url.pathname`
+ * for `GET //evil.com/x` is `//evil.com/x`, which is a scheme-relative
+ * reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
+ * `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
+ * TLS-terminating proxy already sets to take over the origin AND silently
+ * change which route matched. Assigning through the setters cannot move the
+ * path into the authority, and it fails safe on a malformed value (the `host`
+ * setter ignores what it cannot parse instead of throwing, so a junk header is
+ * no longer a 500 on the fetch path or a broken WS handshake).
+ *
+ * The host FALLBACK is the `Host` header, not `url.host`, to match
+ * `urlFromRequest` exactly: node builds from the raw header string, so with
+ * `Host: webjs.dev:80` + `X-Forwarded-Proto: https` it judges the `:80` against
+ * https and keeps it. Falling back to the already-normalized `url.host` dropped
+ * it (port 80 is http's default), which made the two shells disagree on the
+ * exact proto-only shape this helper exists for.
+ *
  * @param {URL} url  the url as the local listener saw it
  * @param {Headers} headers  the web `Request` headers
  * @returns {URL} the corrected url, or `url` itself when unchanged
  */
 export function applyForwarded(url, headers) {
   const { host, proto } = readForwarded((n) => headers.get(n));
-  const finalHost = host || url.host;
+  if (!host && !proto) return url;
   // `url.protocol` carries its trailing colon; the forwarded header does not.
-  const finalProto = proto || url.protocol.slice(0, -1);
-  if (finalHost === url.host && `${finalProto}:` === url.protocol) return url;
-  return new URL(`${url.pathname}${url.search}${url.hash}`, `${finalProto}://${finalHost}`);
+  const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, headers.get('host') || url.host);
+  if (origin.host === url.host && origin.protocol === url.protocol) return url;
+  // Copy the path across verbatim. Never re-parse it against the new origin:
+  // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
+  // reference, so resolving would resolve the authority out of the PATH and
+  // hand an attacker the origin (plus a different matched route) using only the
+  // `X-Forwarded-Proto` every TLS-terminating proxy already sets.
+  origin.pathname = url.pathname;
+  origin.search = url.search;
+  origin.hash = url.hash;
+  return origin;
 }
 
 /**
@@ -78,10 +164,30 @@ function readForwarded(getHeader) {
   if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
   return {
     host: firstHeaderValue(getHeader('x-forwarded-host')),
-    proto: firstHeaderValue(getHeader('x-forwarded-proto')),
+    proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
   };
 }
 
+/**
+ * Accept only `http` / `https` as a forwarded scheme (case-insensitively; the
+ * URL normal form is lowercase, and comparing a raw `HTTPS` against
+ * `url.protocol` otherwise misses the no-op case).
+ *
+ * Anything else is dropped rather than honored. A non-special scheme is the
+ * damaging shape: `X-Forwarded-Proto: javascript` produced
+ * `javascript://host/path`, whose `origin` is the literal string `null`, so
+ * every absolute URL the app derived became `null/...`. A proxy in front of an
+ * HTTP server only ever forwards http or https, so an allowlist costs nothing.
+ *
+ * @param {string | null} p
+ * @returns {string | null}
+ */
+function normalizeProto(p) {
+  if (!p) return null;
+  const v = p.toLowerCase();
+  return v === 'http' || v === 'https' ? v : null;
+}
+
 /**
  * Pick the first comma-separated value from a header that may be a
  * string, an array of strings, or undefined.
diff --git a/packages/server/src/listener-bun.js b/packages/server/src/listener-bun.js
index ac57c8bca..0cff03c0c 100644
--- a/packages/server/src/listener-bun.js
+++ b/packages/server/src/listener-bun.js
@@ -351,11 +351,49 @@ function bunSseResponse(req, hub, app) {
 function forwardedRequest(req, url) {
   const fwd = applyForwarded(url, req.headers);
   if (fwd === url) return req;
-  const next = new Request(fwd, req);
+  // SECURITY (#778): drop any client-supplied `x-webjs-remote-ip` before it
+  // rides onto the rebuilt request. `trustedRemoteIp` reads the WeakMap first,
+  // so the header cannot win today, but every other rebuild site strips it
+  // (`dev.js`'s base-path rewrite, `upgradeRequest` below) and leaving it
+  // attached would re-arm the spoof the moment any later code path rebuilds
+  // this request without carrying the WeakMap across.
+  const headers = new Headers(req.headers);
+  headers.delete('x-webjs-remote-ip');
+  const next = new Request(fwd, { ...requestInitFrom(req), headers });
   propagateTrustedRemoteIp(req, next);
   return next;
 }
 
+/**
+ * The parts of a `Request` that must survive the rebuild, spelled out because
+ * the header strip above needs its own `Headers` and passing the `Request` as
+ * the init would ignore them.
+ *
+ * `duplex: 'half'` is required by the spec whenever a body is present, and
+ * `mode: 'navigate'` cannot be passed to the constructor, so it is dropped.
+ *
+ * @param {Request} req
+ */
+function requestInitFrom(req) {
+  /** @type {any} */
+  const init = {
+    method: req.method,
+    signal: req.signal,
+    credentials: req.credentials,
+    cache: req.cache,
+    redirect: req.redirect,
+    referrer: req.referrer,
+    referrerPolicy: req.referrerPolicy,
+    integrity: req.integrity,
+  };
+  if (req.mode && req.mode !== 'navigate') init.mode = req.mode;
+  if (req.method !== 'GET' && req.method !== 'HEAD') {
+    init.body = req.body;
+    init.duplex = 'half';
+  }
+  return init;
+}
+
 /**
  * Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
  * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
diff --git a/packages/server/test/forwarded/forwarded.test.js b/packages/server/test/forwarded/forwarded.test.js
index 69e8c674a..b6af80bdf 100644
--- a/packages/server/test/forwarded/forwarded.test.js
+++ b/packages/server/test/forwarded/forwarded.test.js
@@ -208,3 +208,92 @@ test('applyForwarded and urlFromRequest agree for the same request', () => {
   const bun = applyForwarded(new URL('http://container:3000/a/b?c=1'), webHeaders(headers));
   assert.equal(bun.href, node.href);
 });
+
+/**
+ * Hostile / malformed forwarded headers. Everything here is reachable by a
+ * client whose headers the edge proxy forwards rather than overwrites, and the
+ * node and Bun entry points must agree on every one of them.
+ */
+
+test('a //-prefixed path cannot become the authority (both entry points)', () => {
+  // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
+  // reference. Resolving it against a new base yields `https://evil.com/x`:
+  // the origin is taken over AND the path collapses to `/x`, so a DIFFERENT
+  // route matches. Only `X-Forwarded-Proto` is needed, which every
+  // TLS-terminating proxy sets.
+  const want = 'https://container:3000//evil.com/x?q=1';
+  const node = urlFromRequest(makeReq('//evil.com/x?q=1', {
+    host: 'container:3000',
+    'x-forwarded-proto': 'https',
+  }));
+  const bun = applyForwarded(new URL('http://container:3000//evil.com/x?q=1'), webHeaders({
+    host: 'container:3000',
+    'x-forwarded-proto': 'https',
+  }));
+  assert.equal(node.href, want, 'node keeps the real host and the full path');
+  assert.equal(bun.href, want, 'bun keeps the real host and the full path');
+  assert.equal(bun.host, 'container:3000');
+  assert.equal(bun.pathname, '//evil.com/x');
+});
+
+test('a malformed forwarded host is ignored, never thrown', () => {
+  // An unparseable authority used to raise `Invalid URL`, which the fetch path
+  // turned into a 500 and the WS upgrade path into a failed handshake.
+  for (const bad of ['a b', '[', 'ho st']) {
+    const bun = applyForwarded(new URL('http://real/p'), webHeaders({ host: 'real', 'x-forwarded-host': bad }));
+    assert.equal(bun.href, 'http://real/p', `bun ignores ${JSON.stringify(bad)}`);
+    const node = urlFromRequest(makeReq('/p', { host: 'real', 'x-forwarded-host': bad }));
+    assert.equal(node.href, 'http://real/p', `node ignores ${JSON.stringify(bad)}`);
+  }
+});
+
+test('an out-of-range port makes the whole authority unparseable, so it is ignored', () => {
+  // Rejected wholesale rather than salvaging the hostname: a malformed
+  // authority is not honored at all, which is easier to reason about than a
+  // partially-applied one. Both entry points must agree on that.
+  const node = urlFromRequest(makeReq('/p', { host: 'c', 'x-forwarded-host': 'webjs.dev:99999' }));
+  const bun = applyForwarded(new URL('http://c/p'), webHeaders({ host: 'c', 'x-forwarded-host': 'webjs.dev:99999' }));
+  assert.equal(node.href, 'http://c/p');
+  assert.equal(bun.href, node.href);
+});
+
+test('a forwarded host without a port clears the internal port (no hostname/port mixing)', () => {
+  // The `host` setter only updates the port when the new value carries one, so
+  // layering the public hostname over `Host: container:3000` could otherwise
+  // produce `docs.webjs.dev:3000`: the public name wearing the internal port.
+  const headers = { host: 'container:3000', 'x-forwarded-host': 'docs.webjs.dev', 'x-forwarded-proto': 'https' };
+  const node = urlFromRequest(makeReq('/a', headers));
+  const bun = applyForwarded(new URL('http://container:3000/a'), webHeaders(headers));
+  assert.equal(node.href, 'https://docs.webjs.dev/a');
+  assert.equal(bun.href, node.href);
+});
+
+test('only http and https are accepted as a forwarded scheme', () => {
+  // A non-special scheme collapses `origin` to the literal string "null", so
+  // every absolute URL the app derives becomes "null/...".
+  for (const bad of ['javascript', 'file', 'data', 'ftp']) {
+    const bun = applyForwarded(new URL('http://webjs.dev/p'), webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': bad }));
+    assert.equal(bun.href, 'http://webjs.dev/p', `bun rejects ${bad}`);
+    assert.notEqual(bun.origin, 'null');
+    const node = urlFromRequest(makeReq('/p', { host: 'webjs.dev', 'x-forwarded-proto': bad }));
+    assert.equal(node.href, 'http://webjs.dev/p', `node rejects ${bad}`);
+  }
+});
+
+test('a forwarded scheme is matched case-insensitively', () => {
+  const url = new URL('https://webjs.dev/p');
+  // Already https, so an uppercase HTTPS must still be recognised as the no-op.
+  assert.equal(applyForwarded(url, webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })), url);
+});
+
+test('proto-only forwarding agrees on the host fallback (the Railway shape)', () => {
+  // The branch the earlier parity test missed: with no x-forwarded-host, node
+  // falls back to the raw Host header. Reading the already-normalized url.host
+  // instead dropped an explicit :80 (http's default port) and the two shells
+  // disagreed on exactly the case this helper exists for.
+  const headers = { host: 'webjs.dev:80', 'x-forwarded-proto': 'https' };
+  const node = urlFromRequest(makeReq('/a', headers));
+  const bun = applyForwarded(new URL('http://webjs.dev:80/a'), webHeaders(headers));
+  assert.equal(bun.href, node.href, 'the two shells agree on the host fallback');
+  assert.equal(node.href, 'https://webjs.dev:80/a');
+});

From 9fdd2e1fde9a203e6f595829ef17e9cc72d82e5a Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Sat, 25 Jul 2026 17:45:17 +0530
Subject: [PATCH 06/10] test(bun): cover the hostile-header and
 rebuild-integrity cases

Adds the //-path authority case, the spoofed remote-ip header, and a POST
whose body/method/content-type must survive the request rebuild.
---
 test/bun/forwarded-proto.mjs | 31 ++++++++++++++++++++++++++++++-
 1 file changed, 30 insertions(+), 1 deletion(-)

diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs
index 140ca1897..fa06c43f9 100644
--- a/test/bun/forwarded-proto.mjs
+++ b/test/bun/forwarded-proto.mjs
@@ -56,7 +56,8 @@ try {
   // how website/app/layout.ts derives its og:image.
   w('app/page.ts', `import { html } from ${JSON.stringify(CORE)};\nexport function generateMetadata(ctx: { url: string }) {\n  const origin = new URL(ctx.url).origin;\n  return { title: 'fwd', openGraph: { image: origin + '/public/og.png' } };\n}\nexport default () => html\`
page
\`;`); // A route handler reading the raw request url, the app-code surface. - w('app/api/whoami/route.ts', `export async function GET(req: Request) {\n return Response.json({ url: req.url, origin: new URL(req.url).origin });\n}`); + w('app/api/whoami/route.ts', `export async function GET(req: Request) {\n return Response.json({ url: req.url, origin: new URL(req.url).origin, ip: req.headers.get('x-webjs-remote-ip') });\n}`); + w('app/api/echo/route.ts', `export async function POST(req: Request) {\n return Response.json({ method: req.method, body: await req.text(), ct: req.headers.get('content-type'), origin: new URL(req.url).origin });\n}`); let server; ({ server, close } = await startServer({ appDir: dir, dev: true, port: 0, logger: quiet })); @@ -95,6 +96,34 @@ try { const plain = await fetch(`${base}/api/whoami`); assert.equal((await plain.json()).origin, base, 'an unproxied request keeps its own origin'); + // 6. A `//`-prefixed path must NOT be read as an authority. Resolving the + // path against the corrected origin turns `//evil.com/x` into + // `https://evil.com/x`, handing over the origin AND matching a different + // route, using only the proto header every proxy sets. + const hostile = await fetch(`${base}//evil.com/x`, { headers: { 'x-forwarded-proto': 'https' } }); + if (hostile.status < 400) { + const seen = await hostile.json().catch(() => null); + if (seen) assert.notEqual(new URL(seen.url).host, 'evil.com', 'a //-path never becomes the authority'); + } + + // 7. A client-supplied `x-webjs-remote-ip` must never reach a handler as-is. + // Node stamps its own socket address over it; Bun strips it and answers from + // the WeakMap. Either way the client's value must not survive. + const spoof = await fetch(`${base}/api/whoami`, { headers: { ...proxied, 'x-webjs-remote-ip': '9.9.9.9' } }); + assert.notEqual((await spoof.json()).ip, '9.9.9.9', 'a spoofed remote-ip header does not survive the rebuild'); + + // 8. The rebuild must not lose the request body or method. + const echo = await fetch(`${base}/api/echo`, { + method: 'POST', + headers: { ...proxied, 'content-type': 'application/json' }, + body: '{"n":42}', + }); + const echoed = await echo.json(); + assert.equal(echoed.method, 'POST', 'method survives the rebuild'); + assert.equal(echoed.body, '{"n":42}', 'body survives the rebuild'); + assert.equal(echoed.ct, 'application/json', 'content-type survives the rebuild'); + assert.equal(echoed.origin, 'https://webjs.dev', 'a POST is origin-corrected too'); + await close(); close = null; console.log(`OK forwarded proto/host passed on ${runtime} (page ctx.url + route req.url both https)`); From 2c7e86b5212f4862e59bb71be688e6b0d65d6459 Mon Sep 17 00:00:00 2001 From: Vivek Date: Sat, 25 Jul 2026 18:00:09 +0530 Subject: [PATCH 07/10] test(bun): make the hostile-path case real and cover the WS upgrade The //-path assertion sat behind an 'if (status < 400)' guard, and the fixture had no route to collapse onto, so the block never ran and the authority-takeover fix had no cross-runtime coverage. The fixture now has a /x route plus a control proving it is reachable, so the 404 is the attack being refused rather than a missing route. Also adds a WS assertion: bunUpgrade corrects its handler request, which shipped with no test at any layer. Docs: the deployment page listed the correction in the Bun parity list on a paragraph that then hands the reader a createRequestHandler embed snippet, which does NOT get it. Says so explicitly now. The runtime reference no longer implies WEBJS_NO_TRUST_PROXY is global, since the CSRF host check reads the header regardless. --- .agents/skills/webjs/references/runtime.md | 4 ++- docs/app/docs/deployment/page.ts | 1 + test/bun/forwarded-proto.mjs | 34 +++++++++++++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.agents/skills/webjs/references/runtime.md b/.agents/skills/webjs/references/runtime.md index 25426aec0..20899a165 100644 --- a/.agents/skills/webjs/references/runtime.md +++ b/.agents/skills/webjs/references/runtime.md @@ -39,7 +39,9 @@ Three seams pick a runtime-specific implementation, all inside the framework, no The 103 Early Hints gap costs only a small first-load latency edge where an edge proxy forwards the 103, never correctness. The `modulepreload` hints still ship in the document head on both runtimes. -Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client. Set `WEBJS_NO_TRUST_PROXY=1` to stop trusting the headers when the container is directly exposed. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site. +Behind a TLS-terminating proxy (Railway, Fly, Render, Cloudflare, nginx), both shells rewrite the request URL from `X-Forwarded-Proto` / `X-Forwarded-Host`, so `ctx.url` in a page, `req.url` in a `route.{js,ts}` handler, and every absolute URL you build from either carry the ORIGINAL scheme and host rather than the internal `http://container` hop. A comma-separated chain (a CDN in front of a load balancer) takes the value closest to the client, only `http` and `https` are accepted as a scheme, and a malformed host is ignored rather than failing the request. This was Bun-only broken before #1090, which shipped an `http://` `og:image` on an HTTPS site. + +Two limits worth knowing. `WEBJS_NO_TRUST_PROXY=1` stops the URL rewrite (and the HSTS scheme check) from trusting the headers when the container is directly exposed, but it is not a global switch: the CSRF host check still reads `X-Forwarded-Host` regardless. And this rewrite belongs to `startServer`. An app embedded through `createRequestHandler` gets the `Request` its host adapter built, so that adapter owns the correction, the same boundary that already applies to the trusted client IP. ## Scaffolding a Bun app diff --git a/docs/app/docs/deployment/page.ts b/docs/app/docs/deployment/page.ts index 2b7b4c5cb..db0f57f49 100644 --- a/docs/app/docs/deployment/page.ts +++ b/docs/app/docs/deployment/page.ts @@ -250,6 +250,7 @@ server.listen(8080);

Bun

Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP, and the X-Forwarded-Proto / X-Forwarded-Host URL correction so absolute URLs carry the original scheme and host behind a TLS-terminating proxy). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:

+

Embedding moves the proxy-header responsibility to you. The parity list above describes startServer, which owns the listener. createRequestHandler takes the Request you hand it and derives every absolute URL from its url, so behind a TLS-terminating proxy you must rebuild that Request with the original scheme and host yourself, or ctx.url stays on the internal http:// hop and og:image tags, canonical links, and OAuth callbacks come out wrong. The same applies to the trusted client IP. If you do not need to share the port with other routes, prefer startServer and let the framework handle it.

import { createRequestHandler } from '@webjsdev/server';
 
 const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs
index fa06c43f9..a2d57d9fb 100644
--- a/test/bun/forwarded-proto.mjs
+++ b/test/bun/forwarded-proto.mjs
@@ -58,6 +58,12 @@ try {
   // A route handler reading the raw request url, the app-code surface.
   w('app/api/whoami/route.ts', `export async function GET(req: Request) {\n  return Response.json({ url: req.url, origin: new URL(req.url).origin, ip: req.headers.get('x-webjs-remote-ip') });\n}`);
   w('app/api/echo/route.ts', `export async function POST(req: Request) {\n  return Response.json({ method: req.method, body: await req.text(), ct: req.headers.get('content-type'), origin: new URL(req.url).origin });\n}`);
+  // The target of the scheme-relative-authority attack below. If the path is
+  // resolved rather than assigned, `//evil.com/x` collapses to `/x` and reaches
+  // THIS route; correct behaviour never matches it.
+  w('app/x/route.ts', `export async function GET(req: Request) {\n  return Response.json({ reached: true, url: req.url });\n}`);
+  // A WebSocket endpoint, so the upgrade path's correction is asserted too.
+  w('app/live/route.ts', `export function WS(ws: any, req: Request) {\n  ws.send(JSON.stringify({ url: req.url }));\n}`);
 
   let server;
   ({ server, close } = await startServer({ appDir: dir, dev: true, port: 0, logger: quiet }));
@@ -98,13 +104,16 @@ try {
 
   // 6. A `//`-prefixed path must NOT be read as an authority. Resolving the
   // path against the corrected origin turns `//evil.com/x` into
-  // `https://evil.com/x`, handing over the origin AND matching a different
-  // route, using only the proto header every proxy sets.
+  // `https://evil.com/x`, handing over the origin AND collapsing the path to
+  // `/x` so a DIFFERENT route matches, using only the proto header every proxy
+  // sets. The control proves `/x` is really routable, so the 404 below is the
+  // attack being refused and not the fixture missing a route.
+  const control = await fetch(`${base}/x`);
+  assert.equal(control.status, 200, 'control: /x is a real route');
+  assert.equal((await control.json()).reached, true, 'control: /x answers');
+
   const hostile = await fetch(`${base}//evil.com/x`, { headers: { 'x-forwarded-proto': 'https' } });
-  if (hostile.status < 400) {
-    const seen = await hostile.json().catch(() => null);
-    if (seen) assert.notEqual(new URL(seen.url).host, 'evil.com', 'a //-path never becomes the authority');
-  }
+  assert.equal(hostile.status, 404, 'a //-prefixed path must not collapse onto the /x route');
 
   // 7. A client-supplied `x-webjs-remote-ip` must never reach a handler as-is.
   // Node stamps its own socket address over it; Bun strips it and answers from
@@ -124,9 +133,20 @@ try {
   assert.equal(echoed.ct, 'application/json', 'content-type survives the rebuild');
   assert.equal(echoed.origin, 'https://webjs.dev', 'a POST is origin-corrected too');
 
+  // 9. The WS upgrade path corrects its handler request too. The node shell
+  // gets this from `buildRequestFromUpgrade`; on Bun it is `bunUpgrade`
+  // applying the same helper. Untested, a regression here would be silent.
+  const wsUrl = await new Promise((res, rej) => {
+    const sock = new WebSocket(`ws://localhost:${port}/live`, { headers: proxied });
+    const timer = setTimeout(() => { try { sock.close(); } catch {} rej(new Error('WS handshake timed out')); }, 8000);
+    sock.onmessage = (ev) => { clearTimeout(timer); try { sock.close(); } catch {} res(JSON.parse(String(ev.data)).url); };
+    sock.onerror = () => { clearTimeout(timer); rej(new Error('WS connection failed')); };
+  });
+  assert.equal(new URL(wsUrl).origin, 'https://webjs.dev', `the WS handler request is origin-corrected on ${runtime}`);
+
   await close();
   close = null;
-  console.log(`OK  forwarded proto/host passed on ${runtime} (page ctx.url + route req.url both https)`);
+  console.log(`OK  forwarded proto/host passed on ${runtime} (page ctx.url + route req.url + WS all https)`);
 } catch (e) {
   failure = e;
 } finally {

From 6d347287035375b36d21005c9e23e67ff5fd1641 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Sat, 25 Jul 2026 18:01:30 +0530
Subject: [PATCH 08/10] test(bun): drive the hostile path over a raw socket

Bun's fetch client rewrites //evil.com/x to /evil.com/x before it reaches
the wire, so the assertion never put the attacked shape on the wire and
passed against a deliberately reintroduced bug. A raw socket sends the
request target verbatim. Now the counterfactual fails as it should.
---
 test/bun/forwarded-proto.mjs | 45 +++++++++++++++++++++++++++++++-----
 1 file changed, 39 insertions(+), 6 deletions(-)

diff --git a/test/bun/forwarded-proto.mjs b/test/bun/forwarded-proto.mjs
index a2d57d9fb..c0053d0e0 100644
--- a/test/bun/forwarded-proto.mjs
+++ b/test/bun/forwarded-proto.mjs
@@ -36,8 +36,35 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { dirname, join, resolve } from 'node:path';
 import { fileURLToPath, pathToFileURL } from 'node:url';
+import { connect } from 'node:net';
 import { startServer } from '@webjsdev/server';
 
+/**
+ * Issue a GET with a VERBATIM request target, bypassing the fetch client.
+ *
+ * Both runtimes' `fetch` normalize the path before it reaches the wire (Bun
+ * collapses a leading `//`), which is exactly the shape being attacked here, so
+ * a raw socket is the only way to put it on the wire.
+ *
+ * @param {number} port
+ * @param {string} target
+ * @param {Record} [headers]
+ * @returns {Promise} the raw response text
+ */
+function rawGet(port, target, headers = {}) {
+  return new Promise((res, rej) => {
+    const sock = connect(port, '127.0.0.1', () => {
+      const extra = Object.entries(headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
+      sock.write(`GET ${target} HTTP/1.1\r\nHost: localhost:${port}\r\nConnection: close\r\n${extra}\r\n`);
+    });
+    let buf = '';
+    sock.setTimeout(8000, () => { sock.destroy(); rej(new Error(`raw GET ${target} timed out`)); });
+    sock.on('data', (d) => { buf += d; });
+    sock.on('end', () => res(buf));
+    sock.on('error', rej);
+  });
+}
+
 const __dirname = dirname(fileURLToPath(import.meta.url));
 const CORE = pathToFileURL(resolve(__dirname, '../../packages/core/index.js')).toString();
 const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`;
@@ -108,12 +135,18 @@ try {
   // `/x` so a DIFFERENT route matches, using only the proto header every proxy
   // sets. The control proves `/x` is really routable, so the 404 below is the
   // attack being refused and not the fixture missing a route.
-  const control = await fetch(`${base}/x`);
-  assert.equal(control.status, 200, 'control: /x is a real route');
-  assert.equal((await control.json()).reached, true, 'control: /x answers');
-
-  const hostile = await fetch(`${base}//evil.com/x`, { headers: { 'x-forwarded-proto': 'https' } });
-  assert.equal(hostile.status, 404, 'a //-prefixed path must not collapse onto the /x route');
+  // It has to go over a RAW SOCKET: Bun's `fetch` client rewrites
+  // `//evil.com/x` to `/evil.com/x` before sending, so driving this through
+  // `fetch` silently tests nothing on the very runtime the bug was on (this
+  // assertion passed against a deliberately reintroduced bug until it was
+  // moved off `fetch`).
+  const control = await rawGet(port, '/x');
+  assert.match(control, /^HTTP\/1\.1 200/, 'control: /x is a real route');
+  assert.match(control, /"reached":true/, 'control: /x answers');
+
+  const hostile = await rawGet(port, '//evil.com/x', { 'x-forwarded-proto': 'https' });
+  assert.doesNotMatch(hostile, /"reached":true/, 'a //-prefixed path must not collapse onto the /x route');
+  assert.match(hostile, /^HTTP\/1\.1 404/, 'a //-prefixed path is not a route');
 
   // 7. A client-supplied `x-webjs-remote-ip` must never reach a handler as-is.
   // Node stamps its own socket address over it; Bun strips it and answers from

From 40c70623b1b8e9857c8016ac173ba3055a3a7e16 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Sat, 25 Jul 2026 18:18:02 +0530
Subject: [PATCH 09/10] fix(server): do not trust an absolute-form request line
 for the origin

Bun.serve reports an absolute-form request line (GET http://evil/x) as the
request's url, and applyForwarded returned early whenever no forwarded
header was present, so an unproxied Bun app handed a client full control
of ctx.url.origin. That is the same og:image / canonical / OAuth-callback
failure class this PR exists to fix, arriving through a different door,
and it is the configuration WEBJS_NO_TRUST_PROXY documents.

The fast path now also requires the url's authority to agree with the Host
header, which every real request satisfies, so the no-clone optimization
is intact while a mismatched authority is rebuilt from Host and discarded.

Also corrects three JSDoc blocks that described earlier revisions of this
code (a clone-and-assign rewrite that no longer exists, the host setter
that setHost deliberately avoids, and a proto contract that holds for only
one of the two call sites), and stops claiming the shells cannot drift:
readForwarded and resolveOrigin unify the header and origin decisions, but
what each shell receives still differs, so an absolute-form request line
routes differently between them.

The case-insensitivity test asserted identity, which is also what a
REJECTED scheme returns, so it could not fail. It now asserts the upgrade;
verified by mutation (dropping toLowerCase reds it).
---
 packages/server/src/forwarded.js              | 57 +++++++++++--------
 .../server/test/forwarded/forwarded.test.js   | 26 ++++++++-
 2 files changed, 57 insertions(+), 26 deletions(-)

diff --git a/packages/server/src/forwarded.js b/packages/server/src/forwarded.js
index 839f5dd4c..c33cfa0eb 100644
--- a/packages/server/src/forwarded.js
+++ b/packages/server/src/forwarded.js
@@ -51,15 +51,17 @@ export function urlFromRequest(req) {
 /**
  * Build the origin both entry points agree on, as an origin-only URL.
  *
- * Sharing this is what makes node/Bun parity STRUCTURAL rather than a
- * coincidence of two similar-looking expressions. The layering matters: start
- * from a known-good placeholder, apply the raw `Host`, then let the forwarded
- * host override it. Each assignment goes through the `host` setter, which
- * IGNORES a value it cannot parse instead of throwing, so a junk header falls
- * back to the previous layer rather than 500ing the request or collapsing to
+ * Sharing this is what keeps the ORIGIN decision identical across the two
+ * shells rather than a coincidence of two similar-looking expressions. The
+ * layering matters: start from a known-good placeholder, apply the raw `Host`,
+ * then let the forwarded host override it. Each assignment goes through
+ * `setHost`, which ignores a value it cannot parse, so a junk header falls back
+ * to the previous layer rather than 500ing the request or collapsing to
  * localhost.
  *
- * @param {string} proto  already restricted to http / https by `normalizeProto`
+ * @param {string} proto  http / https. `normalizeProto` guarantees it for a
+ *   forwarded value; the callers' fallbacks pass the url's own scheme, which is
+ *   http or https for any request a listener shell can receive.
  * @param {string | null} forwardedHost
  * @param {string} rawHost  the `Host` header
  * @returns {URL} an origin-only URL
@@ -106,23 +108,31 @@ function setHost(u, value) {
  * plain node object, while a web `Request` exposes a `Headers` instance whose
  * values come from `.get()`. Reusing it against a `Request` silently reads
  * `undefined` for every header, so it LOOKS wired up while changing nothing.
- * Both entry points funnel through `readForwarded` so the two runtimes cannot
- * drift on the trust switch or the comma-chain rule.
- *
- * Returns the SAME URL instance when nothing changes (no proxy, or the headers
- * already agree), so the caller can use identity to skip rebuilding a request
- * on the hot path.
- *
- * The rewrite CLONES the url and assigns `protocol` / `host` rather than
- * re-parsing the path against a new base. Re-parsing is unsafe: `url.pathname`
- * for `GET //evil.com/x` is `//evil.com/x`, which is a scheme-relative
+ * Both entry points funnel through `readForwarded` (the trust switch, the
+ * allowed schemes, the comma-chain rule) and `resolveOrigin` (the host
+ * layering), so the HEADER and ORIGIN decisions cannot drift. What each shell
+ * RECEIVES still differs: node gets an origin-form request target it treats as
+ * a path, Bun gets a url its own parser already resolved, so an absolute-form
+ * request line routes differently between them. The security-relevant part, the
+ * origin, is decided here for both.
+ *
+ * Returns the SAME URL instance when nothing changes, so the caller can use
+ * identity to skip rebuilding a request on the hot path. That fast path also
+ * requires the url's own authority to already AGREE with the `Host` header:
+ * `Bun.serve` reports an absolute-form request line (`GET http://evil/x`) as
+ * the request's url, so returning early on "no forwarded headers" would let an
+ * unproxied app hand a client full control of `ctx.url.origin`. When they
+ * disagree the origin is rebuilt from `Host` and the client's authority is
+ * discarded. A normal request has `url.host === Host`, so the optimization is
+ * intact for every real request.
+ *
+ * The origin is rebuilt through the shared `resolveOrigin`, and the path parts
+ * are COPIED onto it rather than re-parsed against it. Re-parsing is unsafe:
+ * `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
  * reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
  * `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
  * TLS-terminating proxy already sets to take over the origin AND silently
- * change which route matched. Assigning through the setters cannot move the
- * path into the authority, and it fails safe on a malformed value (the `host`
- * setter ignores what it cannot parse instead of throwing, so a junk header is
- * no longer a 500 on the fetch path or a broken WS handshake).
+ * change which route matched.
  *
  * The host FALLBACK is the `Host` header, not `url.host`, to match
  * `urlFromRequest` exactly: node builds from the raw header string, so with
@@ -137,9 +147,10 @@ function setHost(u, value) {
  */
 export function applyForwarded(url, headers) {
   const { host, proto } = readForwarded((n) => headers.get(n));
-  if (!host && !proto) return url;
+  const rawHost = headers.get('host') || url.host;
+  if (!host && !proto && url.host === rawHost) return url;
   // `url.protocol` carries its trailing colon; the forwarded header does not.
-  const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, headers.get('host') || url.host);
+  const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, rawHost);
   if (origin.host === url.host && origin.protocol === url.protocol) return url;
   // Copy the path across verbatim. Never re-parse it against the new origin:
   // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
diff --git a/packages/server/test/forwarded/forwarded.test.js b/packages/server/test/forwarded/forwarded.test.js
index b6af80bdf..c971993b3 100644
--- a/packages/server/test/forwarded/forwarded.test.js
+++ b/packages/server/test/forwarded/forwarded.test.js
@@ -281,9 +281,29 @@ test('only http and https are accepted as a forwarded scheme', () => {
 });
 
 test('a forwarded scheme is matched case-insensitively', () => {
-  const url = new URL('https://webjs.dev/p');
-  // Already https, so an uppercase HTTPS must still be recognised as the no-op.
-  assert.equal(applyForwarded(url, webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })), url);
+  // Assert the UPGRADE, not the no-op: an identity check cannot tell
+  // "recognised as https" from "rejected as an unknown scheme", since both
+  // return the url unchanged. Driving it from an http url makes the two
+  // outcomes observably different.
+  const upgraded = applyForwarded(new URL('http://webjs.dev/p'), webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' }));
+  assert.equal(upgraded.href, 'https://webjs.dev/p', 'uppercase HTTPS still upgrades the scheme');
+  assert.equal(
+    urlFromRequest(makeReq('/p', { host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })).href,
+    'https://webjs.dev/p',
+    'and the node entry point agrees',
+  );
+  // The no-op direction still holds on an already-https url.
+  const already = new URL('https://webjs.dev/p');
+  assert.equal(applyForwarded(already, webHeaders({ host: 'webjs.dev', 'x-forwarded-proto': 'HTTPS' })), already);
+});
+
+test('an absolute-form request line cannot supply the origin', () => {
+  // `Bun.serve` reports an absolute-form request line as the request's url, so
+  // a client sending `GET http://evil.example/path` would otherwise own
+  // ctx.url.origin on an unproxied app. The Host header decides the origin.
+  const bun = applyForwarded(new URL('http://evil.example/path'), webHeaders({ host: 'localhost:5001' }));
+  assert.equal(bun.origin, 'http://localhost:5001', 'the client authority is discarded');
+  assert.equal(bun.pathname, '/path');
 });
 
 test('proto-only forwarding agrees on the host fallback (the Railway shape)', () => {

From 554d664cc03b2e79a4eb17e83f8e673451640cd4 Mon Sep 17 00:00:00 2001
From: Vivek 
Date: Sat, 25 Jul 2026 18:18:34 +0530
Subject: [PATCH 10/10] docs: stop claiming the two listener shells cannot
 drift

readForwarded and resolveOrigin unify the header and origin decisions, but
what each shell receives still differs, so an absolute-form request line
routes differently between them. Say what is actually guaranteed.
---
 packages/server/AGENTS.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/packages/server/AGENTS.md b/packages/server/AGENTS.md
index 1262fc1b6..10aed6968 100644
--- a/packages/server/AGENTS.md
+++ b/packages/server/AGENTS.md
@@ -57,7 +57,7 @@ with metadata, Suspense, streaming) for HTML, or `api.js` /
 | `websocket.js` | node:http WS upgrade handling: invokes the `WS` export from `route.ts` over the `ws` library. Shares the `route.ts` module resolution (`loadWsModule`) with the Bun WS path via `listener-core.js`. **IP-trust on the upgrade (#778):** `buildRequestFromUpgrade` strips any inbound (spoofable) `x-webjs-remote-ip` and stamps the real `req.socket.remoteAddress` via `setTrustedRemoteIp` (the authoritative WeakMap `clientIp` reads first), so a `WS` handler calling `clientIp(req)` gets the trusted peer, not a client-supplied header. The WS-seam analog of the #773 fetch-path fix (the upgrade check runs before the fetch handler's stamp, so it was never stamped before). |
 | `listener-core.js` | Runtime-neutral listener core (#511): the pieces both listener shells share so they cannot drift. `serverRuntime()` picks the shell (`'bun'` when `process.versions.bun` is set, else `'node'`); `SseHub` is the SSE registry + fanout (the connected-client Set, the keepalive timer, `reload()` / `devError()`, each shell supplying a thin client wrapper over its own transport); `isEventsPath` is the base-path-aware live-reload predicate; `isCompressible` is the shared compressible-media-type set; the **compression seam (#517, #756)** `negotiateEncoding` (prefers `br` > `gzip` > `deflate`) + `createCompressor` (a streaming `node:zlib` Transform, native on Bun, so BOTH shells get brotli) + `compressBufferSync` (the SYNC counterpart for an already-buffered body, byte-identical output) + `readBufferedOrStream` (peeks a body to classify single-bounded-chunk buffered vs genuinely streamed, so the Bun shell can sync-compress a buffered body and skip the stream bridge, #756; it RACES the classifying second read against a macrotask sentinel rather than awaiting it, so a genuinely streamed body, Suspense / streamed action, is handed back immediately and its response head + first byte are NOT withheld until the far-off second chunk) + `varyWithAcceptEncoding` are shared so the node and Bun shells compress identically; `loadWsModule` loads a `route.ts` for its `WS` export (shared by `websocket.js` and the Bun WS path); `installProcessHandlers` + `makeShutdown` are the neutral process-handler + graceful-shutdown wiring (`makeShutdown` takes a `closeServer()` thunk so node `server.close` and Bun `server.stop(true)` both fit). |
 | `listener-bun.js` | The `Bun.serve` listener shell (#511), dynamically imported by `dev.js` only when `serverRuntime()` is `'bun'` (so the `Bun.*` global is never referenced on Node). `startBunListener(ctx)` hands the app's `handle(req): Response` straight to `Bun.serve({ fetch })`, skipping the node:http `toWebRequest` / `sendWebResponse` bridge (~1.9x more req/s on the LISTENING PATH ONLY, a trivial-handler microbenchmark, NOT end-to-end: render dominates a real SSR page, see `scripts/bench-listener.mjs`, #756). **Hot-path overhead reductions (#756):** the remote IP is stamped OUT OF BAND via `setTrustedRemoteIp` (a WeakMap `clientIp` reads in preference to the header), eliminating the per-request `new Request(req, { headers })` clone; and a BUFFERED response (peeked via `readBufferedOrStream`) is compressed SYNCHRONOUSLY (`compressBufferSync`), skipping the per-response web -> node -> web stream bridge, which remains only for genuinely streamed bodies (the sync path and the streaming bridge share the same algo + params, so WITHIN a runtime a buffered body and a streamed one compress identically; across runtimes the exact gzip/deflate bytes can differ since Bun's bundled zlib is not Node's build, which is fine as each response is self-describing via `content-encoding`). Feature parity via the shared core: SSE over a streaming `Response`, WS upgrade via `server.upgrade` with a `BunWsAdapter` that re-exposes the node `ws`-library EventEmitter contract (`.on('message')` / `.send()` / `.readyState`) over Bun's `ServerWebSocket` (the handler request is IP-stamped from `server.requestIP(req)` via `setTrustedRemoteIp` and `upgradeRequest` drops any inbound `x-webjs-remote-ip`, so `clientIp` in a `WS` handler is spoof-proof, #778, mirroring the fetch path's `stampRemoteIp`), **brotli/gzip/deflate via `node:zlib`** (the shared `createCompressor` / `compressBufferSync`, native on Bun, so the Bun path serves brotli too, #517), the #237 timeout mapped to Bun's single `idleTimeout`, and proxy-IP via `server.requestIP`. **Reverse-proxy URL correction (#1090):** `Bun.serve` hands over a `Request` whose url is the internal `http://container` view, so `forwardedRequest` rebuilds it from `applyForwarded` (`forwarded.js`) when `X-Forwarded-Proto` / `X-Forwarded-Host` change the origin, the Bun analog of the node shell building its `Request` from `urlFromRequest`. The rebuild is skipped (same URL instance returned) when nothing changes, so an unproxied app keeps the #756 no-clone hot path; the trusted IP rides across via `propagateTrustedRemoteIp`, and `bunUpgrade` applies the same correction to the WS handler request. Without it every absolute URL an app derived came out `http://` behind a TLS-terminating proxy. 103 Early Hints are node-only (Bun.serve has no informational-response API). |
-| `forwarded.js` | Reverse-proxy URL resolution (`X-Forwarded-Proto` / `X-Forwarded-Host`), honoring the `WEBJS_NO_TRUST_PROXY=1` opt-out and taking the first value of a comma-separated chain (the value closest to the client). Two entry points over ONE `readForwarded` core so the shells cannot drift: `urlFromRequest(req)` for a node `IncomingMessage` (used by `startNodeListener` + `websocket.js`) and `applyForwarded(url, headers)` for a web `Request`'s `Headers` (used by the Bun shell, #1090). `applyForwarded` returns the SAME url instance when nothing changes, which is the caller's signal to skip rebuilding the request. Without this, a TLS-terminating proxy leaves `ctx.url.origin` as `http://internal-host`, breaking og:image tags, OAuth callback URLs, canonical tags, and any app code building an absolute URL. |
+| `forwarded.js` | Reverse-proxy URL resolution (`X-Forwarded-Proto` / `X-Forwarded-Host`), honoring the `WEBJS_NO_TRUST_PROXY=1` opt-out and taking the first value of a comma-separated chain (the value closest to the client). Two entry points over a shared `readForwarded` (trust switch, allowed schemes, comma-chain rule) + `resolveOrigin` (host layering), so the HEADER and ORIGIN decisions cannot drift (what each shell RECEIVES still differs: node gets an origin-form target it treats as a path, Bun gets a url its own parser resolved, so an absolute-form request line routes differently between them; the origin is decided identically for both, and a client authority that disagrees with `Host` is discarded): `urlFromRequest(req)` for a node `IncomingMessage` (used by `startNodeListener` + `websocket.js`) and `applyForwarded(url, headers)` for a web `Request`'s `Headers` (used by the Bun shell, #1090). `applyForwarded` returns the SAME url instance when nothing changes, which is the caller's signal to skip rebuilding the request. Without this, a TLS-terminating proxy leaves `ctx.url.origin` as `http://internal-host`, breaking og:image tags, OAuth callback URLs, canonical tags, and any app code building an absolute URL. |
 | `listener-types.js` | Types only: the `ListenerContext` typedef `startServer` passes to whichever shell it selects (the node:http path in `dev.js`, the Bun path in `listener-bun.js`). |
 | `broadcast.js` | `broadcast(topic, msg)` for fan-out messaging |
 | `context.js` | AsyncLocalStorage per-request context (`getRequest`, `withRequest`, `headers`, `cookies`). The per-user readers `headers()` / `cookies()` (plus `getSession()` in `session.js` and `readSession()` behind `auth()` in `auth.js`) call `markDynamicAccess()`, so the HTML cache's commit step reads `dynamicAccessed()` and refuses to cache a per-user page that wrongly set `revalidate` (#241). Also exposes the per-request correlation id via `requestId()` (set by the handler with `setRequestId`, #239) and wires the server-side `cspNonce()` provider: returns the per-request nonce `setCspNonce` stored (minted when CSP is on, #233), else falls back to parsing an inbound `Content-Security-Policy` request header |