@webjsdev/server 0.8.56 → 0.8.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.56",
3
+ "version": "0.8.58",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -28,7 +28,7 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@webjsdev/core": "^0.7.1",
31
+ "@webjsdev/core": "^0.7.46",
32
32
  "ws": "^8.20.0"
33
33
  },
34
34
  "publishConfig": {
package/src/actions.js CHANGED
@@ -267,7 +267,7 @@ export function serveServerOnlyStub(relPath) {
267
267
  `This file is server-only (a .server.{js,ts} file with no 'use server' directive). ` +
268
268
  `Either add 'use server' at the top of the file to expose its exports as RPC, ` +
269
269
  `or wrap the server-only logic in a separate *.server.{js,ts} action and import that instead. ` +
270
- `Docs: https://docs.webjs.dev/docs/server-actions`;
270
+ `Docs: https://webjs.dev/docs/server-actions`;
271
271
  return `// webjs: server-only module stub for ${relPath} (no 'use server' directive)
272
272
  throw new Error(${JSON.stringify(msg)});
273
273
  `;
@@ -563,8 +563,10 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
563
563
  * Build a GET action's response (#488): the serialized body plus a weak ETag
564
564
  * (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
565
565
  * and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
566
- * relying on the conditional-GET funnel, which EXCLUDES `private` responses (the
567
- * default here); a per-user browser cache may still revalidate to a 304.
566
+ * deferring to the conditional-GET funnel: this branch already hashed the
567
+ * serialized body, so it can answer without the funnel re-reading it. (The
568
+ * funnel would now accept these too, since #1140 stopped excluding `private`,
569
+ * but the short-circuit here still runs first and is cheaper.)
568
570
  * @param {unknown} result
569
571
  * @param {Record<string, unknown>} mod
570
572
  * @param {unknown[]} args
package/src/asset-hash.js CHANGED
@@ -159,10 +159,32 @@ export function assetHashFor(absPath) {
159
159
  const bytes = readFileSync(absPath);
160
160
  const h = createHash('sha256').update(bytes);
161
161
  // An app module's served body is elision-transformed, so fold the verdict
162
- // fingerprint in (see `_elisionFp`). Core / public files are never
163
- // transformed, so they hash over their bytes alone. Empty fp leaves an app
164
- // module's hash at exactly `sha256(bytes)`.
165
- if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)) {
162
+ // fingerprint in (see `_elisionFp`). Core and `public/` files are never
163
+ // transformed, so they hash over their bytes alone, which the two
164
+ // exclusions below enforce rather than merely assume. Empty fp leaves an
165
+ // app module's hash at exactly `sha256(bytes)`.
166
+ //
167
+ // Both exclusions are EXPLICIT because the containment test alone admits
168
+ // them, and the intent above is otherwise silently false:
169
+ //
170
+ // - `public/` sits under `_appDir`. Inert while only module specifiers
171
+ // reached here, but `asset()` (#1194) routes public paths through this
172
+ // function.
173
+ // - `_coreDir` sits under `_appDir` too in a REAL install, where it
174
+ // resolves to `<appDir>/node_modules/@webjsdev/core`. It escapes only
175
+ // in this monorepo, where the workspace symlink lands outside, which
176
+ // is exactly why no in-repo test ever caught it.
177
+ //
178
+ // Neither file set is elision-transformed (core is served verbatim by
179
+ // `fileResponse`), so folding the verdict in buys nothing and costs
180
+ // correctness: any deploy that merely flips an UNRELATED component between
181
+ // elidable and interactive would move their urls, re-downloading the whole
182
+ // core runtime plus every marked stylesheet, image, and script even though
183
+ // not one of those bytes changed. That is the precise cost a content hash
184
+ // exists to avoid.
185
+ if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)
186
+ && !absPath.startsWith(join(_appDir, 'public') + sep)
187
+ && !(_coreDir && absPath.startsWith(_coreDir + sep))) {
166
188
  h.update('\0');
167
189
  h.update(_elisionFp);
168
190
  }
@@ -267,6 +289,46 @@ export function withAssetHash(url, basePath = '') {
267
289
  return `${url}${sepChar}v=${hash}`;
268
290
  }
269
291
 
292
+ /**
293
+ * Resolve one author-marked asset path to its fingerprinted url. This is the
294
+ * body behind the isomorphic `asset()` helper (#1194); `dev.js` installs it
295
+ * via `setAssetUrlProvider` at boot.
296
+ *
297
+ * The `public/` gate is defensive, not decorative. `resolveUrlToFile` maps any
298
+ * same-origin path under the PROJECT ROOT, so an app that passed request- or
299
+ * user-derived data in (`asset(user.avatarPath)`) would otherwise read and
300
+ * publish a content hash for `/.env`, `/db/app.db`, or a `.server.ts`, leaking
301
+ * both that the file exists and a fingerprint of its exact bytes for files the
302
+ * serve path deliberately 404s. Mirror the static-asset route instead: under
303
+ * `public/`, no traversal. Anything else returns unchanged WITHOUT touching
304
+ * the disk, so a rejected path costs nothing and reveals nothing.
305
+ *
306
+ * A fragment is split before hashing. `withAssetHash` appends the query, so
307
+ * passing the whole thing would emit `/x.svg#icon?v=H`: the browser then
308
+ * requests `/x.svg` with no query (losing the immutable caching this exists
309
+ * for) and the fragment becomes `icon?v=H`, matching no element id.
310
+ *
311
+ * @param {string} p the author-supplied path
312
+ * @param {string} [bp] the active base path
313
+ * @returns {string} the fingerprinted url, or `p` unchanged
314
+ */
315
+ export function resolveAssetUrl(p, bp = '') {
316
+ if (typeof p !== 'string' || p[0] !== '/' || p[1] === '/') return p;
317
+ let probe = p;
318
+ if (bp && probe.startsWith(bp + '/')) probe = probe.slice(bp.length);
319
+ const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
320
+ let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
321
+ try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
322
+ if (decoded.includes('..') || !decoded.startsWith('/public/')) return p;
323
+ const h = p.indexOf('#');
324
+ const path = h === -1 ? p : p.slice(0, h);
325
+ const frag = h === -1 ? '' : p.slice(h);
326
+ // An author-supplied query is left alone: it may carry meaning we do not
327
+ // own, and appending would merge with `&`.
328
+ if (path.includes('?')) return p;
329
+ return withAssetHash(path, bp) + frag;
330
+ }
331
+
270
332
  /**
271
333
  * Static `import` specifier with positional indices. Mirrors module-graph.js's
272
334
  * `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
@@ -10,9 +10,15 @@
10
10
  * uniformly.
11
11
  *
12
12
  * What is EXCLUDED, and why:
13
- * - `no-store` / `private` responses (the default for dynamic, per-user
14
- * pages). Never enable a cross-session 304 on private content: a shared
15
- * cache keyed on the URL could serve one user's validator to another.
13
+ * - `no-store` responses (the default for a dynamic page), which forbid
14
+ * storage outright, so there is nothing to validate. `private` is NOT
15
+ * excluded: it forbids SHARED storage, not validation, and the ETag hashes
16
+ * THIS response's body, so two users with different bodies get different
17
+ * ETags and neither can match the other's, while two users with identical
18
+ * bodies are asking about identical bytes, where a 304 discloses nothing
19
+ * (#1140).
20
+ * That is what lets the client router's partial responses stay `private`
21
+ * without losing their 304s.
16
22
  * - Any body the framework did not positively mark as fully buffered. The
17
23
  * funnel only hashes a response that ALREADY carries an `ETag` (a serve
18
24
  * branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
@@ -77,11 +83,28 @@ const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
77
83
 
78
84
  /**
79
85
  * Is this response cacheable enough to carry a validator? Cacheable means a
80
- * 200 whose `Cache-Control` is present and does not forbid storage. A
81
- * `no-store` or `private` response is excluded (private / per-user content
82
- * must never get a cross-session 304). `no-cache` is INCLUDED: it means
83
- * "revalidate before reuse", and a 304 is exactly that revalidation answer,
84
- * so dev's `no-cache` assets still benefit.
86
+ * 200 whose `Cache-Control` is present and does not forbid STORAGE. Only
87
+ * `no-store` is excluded. `no-cache` is INCLUDED: it means "revalidate before
88
+ * reuse", and a 304 is exactly that revalidation answer, so dev's `no-cache`
89
+ * assets still benefit.
90
+ *
91
+ * `private` is INCLUDED too (#1140). It was excluded on the theory that
92
+ * per-user content must never get a "cross-session" 304, but that cannot
93
+ * happen: the ETag is a hash of THIS response's body, and a 304 is returned
94
+ * only when the client's own `If-None-Match` matches it. Two users with
95
+ * different bodies get different ETags, so neither can ever match the other's;
96
+ * two users with identical bodies are asking about identical bytes, where a
97
+ * 304 is the correct answer and carries no body either way. `private` already
98
+ * forbids the one real hazard, a SHARED cache storing the response.
99
+ *
100
+ * Widening this is what makes #1140's fragment downgrade safe to ship. Before
101
+ * that change a reduced fragment INHERITED the page's public header, so it
102
+ * already passed this check and already carried a validator; marking fragments
103
+ * `private` would have taken that away, and since the router fetches them with
104
+ * `cache: 'no-cache'` (#1131), every prefetch and soft navigation would have
105
+ * re-downloaded the whole fragment. The exclusion is dropped rather than
106
+ * special-cased because it was never justified on its own terms either, per
107
+ * the reasoning above.
85
108
  *
86
109
  * @param {Response} res
87
110
  * @returns {boolean}
@@ -90,7 +113,7 @@ function isCacheable(res) {
90
113
  if (res.status !== 200) return false;
91
114
  const cc = res.headers.get('cache-control');
92
115
  if (!cc) return false;
93
- return !/(?:^|,)\s*(?:no-store|private)\s*(?:,|$)/i.test(cc);
116
+ return !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cc);
94
117
  }
95
118
 
96
119
  /**
package/src/dev.js CHANGED
@@ -93,7 +93,8 @@ import { setVendorEntries, setCoreInstall, publishBuildId, setAppSourceId, setBa
93
93
  import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
94
94
  import { propagateTrustedRemoteIp } from './rate-limit.js';
95
95
  import { readAllowedOrigins } from './csrf.js';
96
- import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
96
+ import { setAssetUrlProvider } from '@webjsdev/core';
97
+ import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports, resolveAssetUrl } from './asset-hash.js';
97
98
  import { urlFromRequest } from './forwarded.js';
98
99
  import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
99
100
  import {
@@ -624,6 +625,21 @@ export async function createRequestHandler(opts) {
624
625
  // id is a stable deploy fingerprint independent of per-file content hashes.
625
626
  setAssetRoots({ appDir, coreDir, enabled: !dev });
626
627
 
628
+ // Install the resolver behind the isomorphic `asset()` helper (#1194), the
629
+ // same provider seam `cspNonce()` uses. An author writes
630
+ // `href=${asset('/public/app.css')}` and gets the fingerprinted url on the
631
+ // server; the browser has no provider and returns the path unchanged.
632
+ //
633
+ // The `public/` gate is defensive rather than decorative. `resolveUrlToFile`
634
+ // maps any same-origin path under the PROJECT ROOT, so an app that passed
635
+ // request- or user-derived data here (`asset(user.avatarPath)`) would
636
+ // otherwise read and publish a content hash for `/.env`, `/db/app.db`, or a
637
+ // `.server.ts`, leaking both existence and a fingerprint of the bytes for
638
+ // files the serve path deliberately 404s. Mirror the static-asset route:
639
+ // under `public/`, no traversal. Everything else returns unchanged, having
640
+ // never touched the disk.
641
+ setAssetUrlProvider((p) => resolveAssetUrl(p, basePath()));
642
+
627
643
  // SSR action-result seeding (#472). Install the process-global module load
628
644
  // hook NOW, at boot, before any `'use server'` action module is imported (ESM
629
645
  // caches by URL, so a module loaded before the hook would never be faceted).
@@ -1263,8 +1279,9 @@ export async function createRequestHandler(opts) {
1263
1279
  // into a 304 Not Modified with no body. Applied LAST, after every header
1264
1280
  // (X-Webjs-Build, X-Request-Id, Set-Cookie, CSP) is on the response, so a
1265
1281
  // 304 carries the validators a shared cache and the client router need.
1266
- // A no-store / per-user response, a non-GET/HEAD, and a streaming Suspense
1267
- // body are all skipped (see conditional-get.js). Logged with the final
1282
+ // A no-store response, a non-GET/HEAD, and a streaming Suspense body are
1283
+ // all skipped (see conditional-get.js). A `private` response is NOT
1284
+ // skipped: private forbids shared storage, not validation (#1140). Logged with the final
1268
1285
  // (possibly 304) status. Best-effort: a failure leaves the 200 untouched.
1269
1286
  let conditioned = merged;
1270
1287
  try {
@@ -2259,21 +2276,40 @@ async function runWithSegmentMiddleware(req, files, terminal, dev) {
2259
2276
  }
2260
2277
 
2261
2278
  /**
2262
- * Load the optional top-level `middleware.js`.
2279
+ * Root-middleware filename candidates, in resolution order.
2280
+ *
2281
+ * Every other routing convention accepts all four extensions (the router
2282
+ * matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
2283
+ * and both the scaffold and the dev supervisor write / watch `middleware.ts`.
2284
+ * This lookup used to be the single literal `middleware.js`, so a root
2285
+ * `middleware.ts` was silently never loaded: no error, no warning, the app
2286
+ * just ran with no global middleware. TypeScript is the documented default
2287
+ * for an app, so that was the common case, and it went unnoticed because the
2288
+ * failure is invisible (a missing middleware looks exactly like an app that
2289
+ * has none). `.ts` is tried first to match the dev supervisor's order.
2290
+ */
2291
+ const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
2292
+
2293
+ /**
2294
+ * Load the optional top-level `middleware.{ts,js,mts,mjs}`.
2263
2295
  * @param {string} appDir
2264
2296
  * @param {boolean} dev
2265
2297
  * @param {import('./logger.js').Logger} logger
2266
2298
  */
2267
2299
  async function loadMiddleware(appDir, dev, logger) {
2268
- const file = join(appDir, 'middleware.js');
2269
- if (!(await exists(file))) return null;
2300
+ let file = null;
2301
+ for (const name of ROOT_MIDDLEWARE_FILES) {
2302
+ const candidate = join(appDir, name);
2303
+ if (await exists(candidate)) { file = candidate; break; }
2304
+ }
2305
+ if (!file) return null;
2270
2306
  const url = pathToFileURL(file).toString();
2271
2307
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2272
2308
  try {
2273
2309
  const mod = await import(url + bust);
2274
2310
  return typeof mod.default === 'function' ? mod.default : null;
2275
2311
  } catch (e) {
2276
- logger.error('failed to load middleware.js', { err: String(e) });
2312
+ logger.error('failed to load root middleware', { file, err: String(e) });
2277
2313
  return null;
2278
2314
  }
2279
2315
  }
@@ -2575,7 +2611,7 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
2575
2611
  `Replace enum / namespace / parameter-property / legacy-decorator / ` +
2576
2612
  `import = require constructs with their erasable equivalents. ` +
2577
2613
  `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
2578
- `Docs: https://docs.webjs.dev/docs/typescript`
2614
+ `Docs: https://webjs.dev/docs/typescript`
2579
2615
  // Prod: terse, no path leak, no Node-message leak (Node's
2580
2616
  // message can include source snippets). Operators get the
2581
2617
  // detail in server logs above.
package/src/forwarded.js CHANGED
@@ -32,23 +32,178 @@
32
32
  * @returns {URL}
33
33
  */
34
34
  export function urlFromRequest(req) {
35
- const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
36
- let host = null;
37
- let proto = null;
38
- if (trust) {
39
- host = firstHeaderValue(req.headers['x-forwarded-host']);
40
- proto = firstHeaderValue(req.headers['x-forwarded-proto']);
35
+ const { host, proto } = readForwarded((n) => /** @type {any} */ (req.headers)[n]);
36
+ const rawHost = /** @type {string|undefined} */ (req.headers.host) || '';
37
+ const u = resolveOrigin(proto || 'http', host, rawHost);
38
+ // Assign the request target's parts rather than RESOLVING it against `u`.
39
+ // Resolving is unsafe: a request line may carry `//evil.com/x`, a
40
+ // scheme-relative reference, so `new URL('//evil.com/x', 'https://real-host')`
41
+ // resolves to `https://evil.com/x`, handing over the origin AND silently
42
+ // rewriting the path to `/x` so a different route matches. A request target
43
+ // is a path, never a full reference, so it is assigned as one.
44
+ const m = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(req.url || '/');
45
+ u.pathname = (m && m[1]) || '/';
46
+ u.search = (m && m[2]) || '';
47
+ u.hash = (m && m[3]) || '';
48
+ return u;
49
+ }
50
+
51
+ /**
52
+ * Build the origin both entry points agree on, as an origin-only URL.
53
+ *
54
+ * Sharing this is what keeps the ORIGIN decision identical across the two
55
+ * shells rather than a coincidence of two similar-looking expressions. The
56
+ * layering matters: start from a known-good placeholder, apply the raw `Host`,
57
+ * then let the forwarded host override it. Each assignment goes through
58
+ * `setHost`, which ignores a value it cannot parse, so a junk header falls back
59
+ * to the previous layer rather than 500ing the request or collapsing to
60
+ * localhost.
61
+ *
62
+ * @param {string} proto http / https. `normalizeProto` guarantees it for a
63
+ * forwarded value; the callers' fallbacks pass the url's own scheme, which is
64
+ * http or https for any request a listener shell can receive.
65
+ * @param {string | null} forwardedHost
66
+ * @param {string} rawHost the `Host` header
67
+ * @returns {URL} an origin-only URL
68
+ */
69
+ function resolveOrigin(proto, forwardedHost, rawHost) {
70
+ const u = new URL(`${proto}://localhost`);
71
+ if (rawHost) setHost(u, rawHost);
72
+ if (forwardedHost) setHost(u, forwardedHost);
73
+ return u;
74
+ }
75
+
76
+ /**
77
+ * Assign an authority, clearing any port the previous layer left behind.
78
+ *
79
+ * The `host` setter only updates the port when the new value CARRIES one, so
80
+ * layering `X-Forwarded-Host: docs.webjs.dev` over `Host: container:3000`
81
+ * otherwise yields `docs.webjs.dev:3000`: the public hostname wearing the
82
+ * internal port. Clearing first makes each layer a full replacement.
83
+ *
84
+ * @param {URL} u
85
+ * @param {string} value
86
+ */
87
+ function setHost(u, value) {
88
+ let probe;
89
+ try {
90
+ probe = new URL(`${u.protocol}//${value}`);
91
+ } catch {
92
+ // Not a parseable authority (`a b`, `[`, a port over 65535). Leave the
93
+ // previous layer in place rather than throwing, so a junk header is never a
94
+ // 500 on the fetch path or a failed WS handshake.
95
+ return;
41
96
  }
42
- const finalHost = host || /** @type {string|undefined} */ (req.headers.host) || 'localhost';
43
- const finalProto = proto || 'http';
44
- return new URL(req.url || '/', `${finalProto}://${finalHost}`);
97
+ u.hostname = probe.hostname;
98
+ // Assign the port unconditionally, including the empty string, so a value
99
+ // carrying no port clears one the previous layer left behind.
100
+ u.port = probe.port;
101
+ }
102
+
103
+ /**
104
+ * Apply the forwarded headers to an ALREADY-PARSED url, for a shell whose
105
+ * request is a web `Request` (Bun) rather than a node `IncomingMessage`.
106
+ *
107
+ * `urlFromRequest` above cannot be reused directly: it reads `req.headers` as a
108
+ * plain node object, while a web `Request` exposes a `Headers` instance whose
109
+ * values come from `.get()`. Reusing it against a `Request` silently reads
110
+ * `undefined` for every header, so it LOOKS wired up while changing nothing.
111
+ * Both entry points funnel through `readForwarded` (the trust switch, the
112
+ * allowed schemes, the comma-chain rule) and `resolveOrigin` (the host
113
+ * layering), so the HEADER and ORIGIN decisions cannot drift. What each shell
114
+ * RECEIVES still differs: node gets an origin-form request target it treats as
115
+ * a path, Bun gets a url its own parser already resolved, so an absolute-form
116
+ * request line routes differently between them. The security-relevant part, the
117
+ * origin, is decided here for both.
118
+ *
119
+ * Returns the SAME URL instance when nothing changes, so the caller can use
120
+ * identity to skip rebuilding a request on the hot path. That fast path also
121
+ * requires the url's own authority to already AGREE with the `Host` header:
122
+ * `Bun.serve` reports an absolute-form request line (`GET http://evil/x`) as
123
+ * the request's url, so returning early on "no forwarded headers" would let an
124
+ * unproxied app hand a client full control of `ctx.url.origin`. When they
125
+ * disagree the origin is rebuilt from `Host` and the client's authority is
126
+ * discarded. A normal request has `url.host === Host`, so the optimization is
127
+ * intact for every real request.
128
+ *
129
+ * The origin is rebuilt through the shared `resolveOrigin`, and the path parts
130
+ * are COPIED onto it rather than re-parsed against it. Re-parsing is unsafe:
131
+ * `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
132
+ * reference, so `new URL('//evil.com/x', 'https://real-host')` resolves to
133
+ * `https://evil.com/x`. An attacker needed only the `X-Forwarded-Proto` every
134
+ * TLS-terminating proxy already sets to take over the origin AND silently
135
+ * change which route matched.
136
+ *
137
+ * The host FALLBACK is the `Host` header, not `url.host`, to match
138
+ * `urlFromRequest` exactly: node builds from the raw header string, so with
139
+ * `Host: webjs.dev:80` + `X-Forwarded-Proto: https` it judges the `:80` against
140
+ * https and keeps it. Falling back to the already-normalized `url.host` dropped
141
+ * it (port 80 is http's default), which made the two shells disagree on the
142
+ * exact proto-only shape this helper exists for.
143
+ *
144
+ * @param {URL} url the url as the local listener saw it
145
+ * @param {Headers} headers the web `Request` headers
146
+ * @returns {URL} the corrected url, or `url` itself when unchanged
147
+ */
148
+ export function applyForwarded(url, headers) {
149
+ const { host, proto } = readForwarded((n) => headers.get(n));
150
+ const rawHost = headers.get('host') || url.host;
151
+ if (!host && !proto && url.host === rawHost) return url;
152
+ // `url.protocol` carries its trailing colon; the forwarded header does not.
153
+ const origin = resolveOrigin(proto || url.protocol.slice(0, -1), host, rawHost);
154
+ if (origin.host === url.host && origin.protocol === url.protocol) return url;
155
+ // Copy the path across verbatim. Never re-parse it against the new origin:
156
+ // `url.pathname` for `GET //evil.com/x` is `//evil.com/x`, a scheme-relative
157
+ // reference, so resolving would resolve the authority out of the PATH and
158
+ // hand an attacker the origin (plus a different matched route) using only the
159
+ // `X-Forwarded-Proto` every TLS-terminating proxy already sets.
160
+ origin.pathname = url.pathname;
161
+ origin.search = url.search;
162
+ origin.hash = url.hash;
163
+ return origin;
164
+ }
165
+
166
+ /**
167
+ * Read the forwarded host / proto through a header getter, honoring the
168
+ * `WEBJS_NO_TRUST_PROXY=1` opt-out. The one place the trust decision and the
169
+ * comma-chain rule live, shared by the node and Bun entry points above.
170
+ *
171
+ * @param {(name: string) => string | string[] | undefined | null} getHeader
172
+ * @returns {{ host: string | null, proto: string | null }}
173
+ */
174
+ function readForwarded(getHeader) {
175
+ if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
176
+ return {
177
+ host: firstHeaderValue(getHeader('x-forwarded-host')),
178
+ proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Accept only `http` / `https` as a forwarded scheme (case-insensitively; the
184
+ * URL normal form is lowercase, and comparing a raw `HTTPS` against
185
+ * `url.protocol` otherwise misses the no-op case).
186
+ *
187
+ * Anything else is dropped rather than honored. A non-special scheme is the
188
+ * damaging shape: `X-Forwarded-Proto: javascript` produced
189
+ * `javascript://host/path`, whose `origin` is the literal string `null`, so
190
+ * every absolute URL the app derived became `null/...`. A proxy in front of an
191
+ * HTTP server only ever forwards http or https, so an allowlist costs nothing.
192
+ *
193
+ * @param {string | null} p
194
+ * @returns {string | null}
195
+ */
196
+ function normalizeProto(p) {
197
+ if (!p) return null;
198
+ const v = p.toLowerCase();
199
+ return v === 'http' || v === 'https' ? v : null;
45
200
  }
46
201
 
47
202
  /**
48
203
  * Pick the first comma-separated value from a header that may be a
49
204
  * string, an array of strings, or undefined.
50
205
  *
51
- * @param {string | string[] | undefined} h
206
+ * @param {string | string[] | undefined | null} h
52
207
  * @returns {string | null}
53
208
  */
54
209
  function firstHeaderValue(h) {
@@ -45,7 +45,8 @@ import { EventEmitter } from 'node:events';
45
45
  import { Readable, pipeline } from 'node:stream';
46
46
  import { matchApi } from './router.js';
47
47
  import { registerClient } from './broadcast.js';
48
- import { setTrustedRemoteIp } from './rate-limit.js';
48
+ import { setTrustedRemoteIp, propagateTrustedRemoteIp } from './rate-limit.js';
49
+ import { applyForwarded } from './forwarded.js';
49
50
  import {
50
51
  isCompressible,
51
52
  isEventsPath,
@@ -104,7 +105,7 @@ export function startBunListener(ctx) {
104
105
  // head-start during SSR compute, not the preloads themselves.
105
106
 
106
107
  stampRemoteIp(req, srv);
107
- const resp = await app.handle(req);
108
+ const resp = await app.handle(forwardedRequest(req, url));
108
109
  return compress ? await maybeCompress(resp, req) : resp;
109
110
  } catch (e) {
110
111
  logger.error('request pipeline threw', { err: e instanceof Error ? e.stack : String(e) });
@@ -237,7 +238,11 @@ async function bunUpgrade(req, srv, ctx) {
237
238
  }
238
239
 
239
240
  const wrapper = new BunWsAdapter();
240
- const handlerReq = upgradeRequest(req, url);
241
+ // Behind a proxy the handshake arrives as plain http on the internal hop, so
242
+ // the handler request carries the ORIGINAL scheme + host too (the node WS path
243
+ // gets this from `urlFromRequest` in `buildRequestFromUpgrade`). Routing above
244
+ // keyed on `url.pathname`, which the origin swap leaves untouched.
245
+ const handlerReq = upgradeRequest(req, applyForwarded(url, req.headers));
241
246
  // Stamp the framework-trusted socket IP on the handler request so a `WS`
242
247
  // handler's `clientIp(req)` returns the real peer, not a spoofed inbound
243
248
  // `x-webjs-remote-ip` (#778). `srv.requestIP` must be queried with Bun's
@@ -317,6 +322,78 @@ function bunSseResponse(req, hub, app) {
317
322
  });
318
323
  }
319
324
 
325
+ /**
326
+ * Rebuild the request behind a TLS-terminating reverse proxy so `req.url`
327
+ * carries the ORIGINAL scheme + host (`X-Forwarded-Proto` / `X-Forwarded-Host`).
328
+ *
329
+ * Why the request itself and not just a threaded url: everything downstream
330
+ * (the framework's own `new URL(req.url)` reads AND a user's `route.ts` doing
331
+ * `new URL(req.url).origin`) derives from `req.url`, so correcting only a side
332
+ * channel would fix the framework's absolute URLs and leave app code broken.
333
+ * The node shell gets this for free, since `toWebRequest` builds its `Request`
334
+ * FROM the already-corrected `urlFromRequest(req)`; Bun hands us a `Request`
335
+ * whose url reflects the internal `http://container` view, so the correction
336
+ * has to be applied here for the two shells to agree.
337
+ *
338
+ * Hot path: `applyForwarded` returns the SAME url instance when nothing
339
+ * changes, so an app with no proxy in front (local dev, direct exposure,
340
+ * `WEBJS_NO_TRUST_PROXY=1`) does ZERO extra work and keeps the #756 no-clone
341
+ * behaviour. Behind a proxy this costs one `Request` construction per request,
342
+ * which is still strictly less than the node shell, which constructs one
343
+ * unconditionally. The trusted IP is carried across the rebuild the same way
344
+ * the base-path rewrite in `dev.js` does it (#773), so `clientIp` stays
345
+ * authoritative and the inbound spoofable header is never consulted.
346
+ *
347
+ * @param {Request} req
348
+ * @param {URL} url the url as `Bun.serve` saw it
349
+ * @returns {Request} `req` itself when unchanged, else the corrected request
350
+ */
351
+ function forwardedRequest(req, url) {
352
+ const fwd = applyForwarded(url, req.headers);
353
+ if (fwd === url) return req;
354
+ // SECURITY (#778): drop any client-supplied `x-webjs-remote-ip` before it
355
+ // rides onto the rebuilt request. `trustedRemoteIp` reads the WeakMap first,
356
+ // so the header cannot win today, but every other rebuild site strips it
357
+ // (`dev.js`'s base-path rewrite, `upgradeRequest` below) and leaving it
358
+ // attached would re-arm the spoof the moment any later code path rebuilds
359
+ // this request without carrying the WeakMap across.
360
+ const headers = new Headers(req.headers);
361
+ headers.delete('x-webjs-remote-ip');
362
+ const next = new Request(fwd, { ...requestInitFrom(req), headers });
363
+ propagateTrustedRemoteIp(req, next);
364
+ return next;
365
+ }
366
+
367
+ /**
368
+ * The parts of a `Request` that must survive the rebuild, spelled out because
369
+ * the header strip above needs its own `Headers` and passing the `Request` as
370
+ * the init would ignore them.
371
+ *
372
+ * `duplex: 'half'` is required by the spec whenever a body is present, and
373
+ * `mode: 'navigate'` cannot be passed to the constructor, so it is dropped.
374
+ *
375
+ * @param {Request} req
376
+ */
377
+ function requestInitFrom(req) {
378
+ /** @type {any} */
379
+ const init = {
380
+ method: req.method,
381
+ signal: req.signal,
382
+ credentials: req.credentials,
383
+ cache: req.cache,
384
+ redirect: req.redirect,
385
+ referrer: req.referrer,
386
+ referrerPolicy: req.referrerPolicy,
387
+ integrity: req.integrity,
388
+ };
389
+ if (req.mode && req.mode !== 'navigate') init.mode = req.mode;
390
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
391
+ init.body = req.body;
392
+ init.duplex = 'half';
393
+ }
394
+ return init;
395
+ }
396
+
320
397
  /**
321
398
  * Stamp the framework-trusted remote IP from Bun's `server.requestIP`,
322
399
  * out-of-band via `setTrustedRemoteIp` (#756). The previous implementation
package/src/ssr.js CHANGED
@@ -164,6 +164,11 @@ export async function ssrPage(route, params, url, opts) {
164
164
  frameRes.headers.append('vary', 'X-Webjs-Frame');
165
165
  // #1009: a subtree sliced from a REDUCED render inherits its variance.
166
166
  if (reduced) frameRes.headers.append('vary', 'X-Webjs-Have');
167
+ // No privateFragment call here on purpose: this response is built
168
+ // WITHOUT page metadata, so it is already `no-store` and the call
169
+ // would be dead code with nothing to assert against. The property is
170
+ // locked by a test instead, which fails if the frame response ever
171
+ // starts carrying the page's cacheControl.
167
172
  return frameRes;
168
173
  }
169
174
  }
@@ -304,15 +309,25 @@ export async function ssrPage(route, params, url, opts) {
304
309
  );
305
310
  // REDUCED response (#1009): the X-Webjs-Have short-circuit omitted the
306
311
  // outer-layout chrome, so these bytes are only valid for a request that
307
- // sent a matching `have`. Without `Vary: X-Webjs-Have`, a shared cache
308
- // (CDN edge) could store the reduced body under the URL and serve a
309
- // chrome-less fragment to a fresh full-page navigation (measured live:
310
- // GET / was 73,534 bytes, GET / + have was 57,035, byte-identical headers
311
- // otherwise). Scoped to genuinely reduced responses so a normal page's
312
- // cache key is unchanged. The internal #241 revalidate cache is already
313
- // safe by construction: `cacheEligible` excludes any request that carries
314
- // x-webjs-have, so a reduced body is never stored under the URL-only key.
315
- if (reduced) res.headers.append('vary', 'X-Webjs-Have');
312
+ // sent a matching `have`. Left shared-cacheable, a CDN edge could store the
313
+ // reduced body under the URL and serve a chrome-less fragment to a fresh
314
+ // full-page navigation (measured live: GET / was 73,534 bytes, GET / + have
315
+ // was 57,035, byte-identical headers otherwise).
316
+ //
317
+ // TWO markings, and the order of trust matters (#1140). `privateFragment`
318
+ // is the guarantee: it forbids SHARED storage outright, so no CDN can hold
319
+ // this body at all. `Vary` is belt-and-braces for caches that honour it,
320
+ // NOT the protection, because Cloudflare and others honour only
321
+ // `Accept-Encoding`. Both are scoped to genuinely reduced responses, so a
322
+ // normal page's headers and cache key are unchanged.
323
+ //
324
+ // The internal #241 revalidate cache is already safe by construction:
325
+ // `cacheEligible` excludes any request that carries x-webjs-have, so a
326
+ // reduced body is never stored under the URL-only key.
327
+ if (reduced) {
328
+ res.headers.append('vary', 'X-Webjs-Have');
329
+ privateFragment(res);
330
+ }
316
331
  // Server HTML cache write (#241). The page opted in via `revalidate`, so
317
332
  // FLAG this candidate for the response funnel rather than writing here: the
318
333
  // store decision must see the FINAL response (after segment middleware,
@@ -465,6 +480,82 @@ export async function ssrUnauthorized(route, opts) {
465
480
  return htmlResponse(html, 401, opts.req, opts.url);
466
481
  }
467
482
 
483
+ /**
484
+ * Downgrade a PARTIAL response so no shared cache can store it (#1140).
485
+ *
486
+ * A reduced `X-Webjs-Have` body is sliced by a REQUEST header, so it is valid
487
+ * only for a client that sent it. It is marked `Vary` for that header, but
488
+ * `Vary` is not a guarantee in practice: Cloudflare honours only
489
+ * `Accept-Encoding` and several other shared caches are just as selective.
490
+ * Since a reduced body otherwise INHERITS the page's `Cache-Control`, a page
491
+ * that opted into public caching was handing CDNs a chrome-less fragment under
492
+ * the full page's URL, to be served to whoever navigated there next. Measured
493
+ * on webjs.dev: 71,759 bytes for the document versus 47,375 for the fragment,
494
+ * identical `Cache-Control` on both.
495
+ *
496
+ * The reduced path is the ONLY caller. A `<webjs-frame>` subtree is sliced by a
497
+ * request header too, but its response is built without page metadata, so it is
498
+ * already `no-store` and needs no downgrade; that property is locked by a test
499
+ * rather than by a call here. A NEW partial-response shape does not get this
500
+ * treatment for free: call this from its response site.
501
+ *
502
+ * `private` fixes that at the source instead of relying on `Vary` being
503
+ * respected: no shared cache may store it, while the browser that asked for it
504
+ * still may. The freshness directives are preserved, so a returning client
505
+ * keeps whatever reuse the page declared. `Vary` stays too, as belt-and-braces
506
+ * for caches that do honour it.
507
+ *
508
+ * The response KEEPS its validator. `private` forbids shared storage, not
509
+ * validation, so the funnel still attaches an ETag (see conditional-get.js,
510
+ * which stopped excluding `private` for exactly this reason). That matters:
511
+ * the router fetches partials with `cache: 'no-cache'` (#1131), so without a
512
+ * validator every prefetch and soft navigation would re-download the whole
513
+ * fragment instead of being answered 304.
514
+ *
515
+ * @param {Response} res
516
+ */
517
+ export function privateFragment(res) {
518
+ const cc = res.headers.get('cache-control') || '';
519
+ // No header at all is not "nothing to do": a response with no Cache-Control
520
+ // is heuristically storable by a shared cache (RFC 9111), which is precisely
521
+ // the hazard here. Fail CLOSED.
522
+ if (!cc) {
523
+ res.headers.set('cache-control', 'private');
524
+ return;
525
+ }
526
+ // Split on commas that are NOT inside a quoted argument: a directive may
527
+ // carry one (`no-cache="Set-Cookie, X-Foo"`, `private="x-user"`), and a naive
528
+ // split tears those apart.
529
+ const directives = [];
530
+ let buf = '';
531
+ let inQuotes = false;
532
+ for (const ch of cc) {
533
+ if (ch === '"') inQuotes = !inQuotes;
534
+ if (ch === ',' && !inQuotes) { directives.push(buf.trim()); buf = ''; continue; }
535
+ buf += ch;
536
+ }
537
+ directives.push(buf.trim());
538
+
539
+ // `name` is everything before the first `=`, trimmed, so `s-maxage = 600`
540
+ // and `private="x-user"` are recognised as the directives they are.
541
+ const nameOf = (d) => d.split('=')[0].trim().toLowerCase();
542
+ // Already unshareable: leave it exactly as the author wrote it. Only a BARE
543
+ // `private` counts. The qualified form (`private="x-user"`) marks just the
544
+ // NAMED header fields private per RFC 9111, leaving the response itself
545
+ // storable by a shared cache, so it must still be downgraded.
546
+ const isBare = (d) => !d.includes('=');
547
+ if (directives.some((d) => nameOf(d) === 'no-store' || (nameOf(d) === 'private' && isBare(d)))) return;
548
+
549
+ // `private` joins the drop list because a bare one is prepended below: a
550
+ // qualified `private="x-user"` left in place would emit the directive twice,
551
+ // and a cache that resolves a repeat by taking the last occurrence would read
552
+ // the qualified form, which per RFC 9111 leaves the response shared-storable.
553
+ const SHARED_ONLY = new Set(['public', 's-maxage', 'proxy-revalidate', 'private']);
554
+ const kept = directives.filter((d) => d && !SHARED_ONLY.has(nameOf(d)));
555
+ kept.unshift('private');
556
+ res.headers.set('cache-control', kept.join(', '));
557
+ }
558
+
468
559
  /**
469
560
  * Build an HTML Response. Sets no cookie: action CSRF is an Origin /
470
561
  * Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
@@ -476,8 +567,10 @@ export async function ssrUnauthorized(route, opts) {
476
567
  */
477
568
  function htmlResponse(html, status, req, url, metadata) {
478
569
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
479
- // Default: no caching. Pages are dynamic by default: the developer
480
- // opts in to caching explicitly via metadata.cacheControl.
570
+ // Default: no caching. Pages are dynamic by default: the developer opts in
571
+ // explicitly via metadata.cacheControl. No non-200 guard here, unlike
572
+ // streamingHtmlResponse: every caller of THIS builder passes no metadata, so
573
+ // the value is already the no-store default and a guard would be dead code.
481
574
  headers.set('cache-control', metadata?.cacheControl || 'no-store');
482
575
  // X-Webjs-Build carries the published build id so the client
483
576
  // router can detect post-deploy importmap changes on EVERY
@@ -487,10 +580,11 @@ function htmlResponse(html, status, req, url, metadata) {
487
580
  // applySwap and publishedBuildId() in importmap.js.
488
581
  headers.set('x-webjs-build', publishedBuildId());
489
582
  headers.set('x-webjs-src', appSourceId());
490
- // Buffered (string) body: opt into the conditional-GET funnel so a
491
- // PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
492
- // The funnel still excludes the no-store default, so a private page is
493
- // never ETagged. See conditional-get.js.
583
+ // Buffered (string) body: opt into the conditional-GET funnel.
584
+ // A cacheable page (metadata.cacheControl) gets a weak ETag + 304. The
585
+ // funnel excludes only the no-store default; a `private` page IS validated,
586
+ // which is what keeps the router's partial responses cheap (#1140).
587
+ // See conditional-get.js.
494
588
  headers.set(BUFFERED_MARKER, '1');
495
589
  return new Response(html, { status, headers });
496
590
  }
@@ -650,8 +744,10 @@ async function renderChain(route, ctx, dev, suspenseCtx, have, pageModule) {
650
744
  const body = await renderToString(tree, { ssr: true, suspenseCtx });
651
745
  // REDUCED response (#1009): the outer layouts were skipped, so these
652
746
  // bytes are only valid for a client that already HAS them. The caller
653
- // marks the response `Vary: X-Webjs-Have` so no shared cache can serve
654
- // this fragment to a fresh full-page navigation.
747
+ // marks the response `private` so no shared cache can store it, and
748
+ // additionally `Vary: X-Webjs-Have` for caches that honour it. The
749
+ // `private` is the guarantee, not the Vary: Cloudflare and others honour
750
+ // only `Accept-Encoding` (#1140).
655
751
  return { html: body + (await loadingTemplates(route, ctx, dev)), reduced: true };
656
752
  }
657
753
  const mod = await loadModule(route.layouts[i], dev);
@@ -1955,8 +2051,11 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
1955
2051
  const encoder = new TextEncoder();
1956
2052
  const headers = new Headers({ 'content-type': 'text/html; charset=utf-8' });
1957
2053
  // Default: no caching. Pages are dynamic by default: the developer
1958
- // opts in to caching explicitly via metadata.cacheControl.
1959
- headers.set('cache-control', metadata?.cacheControl || 'no-store');
2054
+ // opts in to caching explicitly via metadata.cacheControl. A non-200 does
2055
+ // NOT inherit it (#1140): the page-action re-render is a 422 carrying the
2056
+ // submitter's own field values and errors, which must never be handed to a
2057
+ // shared cache just because the page opted into public caching.
2058
+ headers.set('cache-control', status === 200 ? (metadata?.cacheControl || 'no-store') : 'no-store');
1960
2059
  // See htmlResponse: published build id on every response for the
1961
2060
  // client router's importmap-mismatch detection on partial swaps.
1962
2061
  headers.set('x-webjs-build', publishedBuildId());