@webjsdev/server 0.8.56 → 0.8.57

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.57",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
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
  `;
package/src/dev.js CHANGED
@@ -2259,21 +2259,40 @@ async function runWithSegmentMiddleware(req, files, terminal, dev) {
2259
2259
  }
2260
2260
 
2261
2261
  /**
2262
- * Load the optional top-level `middleware.js`.
2262
+ * Root-middleware filename candidates, in resolution order.
2263
+ *
2264
+ * Every other routing convention accepts all four extensions (the router
2265
+ * matches on the STEM, so `app/<segment>/middleware.ts` has always worked),
2266
+ * and both the scaffold and the dev supervisor write / watch `middleware.ts`.
2267
+ * This lookup used to be the single literal `middleware.js`, so a root
2268
+ * `middleware.ts` was silently never loaded: no error, no warning, the app
2269
+ * just ran with no global middleware. TypeScript is the documented default
2270
+ * for an app, so that was the common case, and it went unnoticed because the
2271
+ * failure is invisible (a missing middleware looks exactly like an app that
2272
+ * has none). `.ts` is tried first to match the dev supervisor's order.
2273
+ */
2274
+ const ROOT_MIDDLEWARE_FILES = ['middleware.ts', 'middleware.js', 'middleware.mts', 'middleware.mjs'];
2275
+
2276
+ /**
2277
+ * Load the optional top-level `middleware.{ts,js,mts,mjs}`.
2263
2278
  * @param {string} appDir
2264
2279
  * @param {boolean} dev
2265
2280
  * @param {import('./logger.js').Logger} logger
2266
2281
  */
2267
2282
  async function loadMiddleware(appDir, dev, logger) {
2268
- const file = join(appDir, 'middleware.js');
2269
- if (!(await exists(file))) return null;
2283
+ let file = null;
2284
+ for (const name of ROOT_MIDDLEWARE_FILES) {
2285
+ const candidate = join(appDir, name);
2286
+ if (await exists(candidate)) { file = candidate; break; }
2287
+ }
2288
+ if (!file) return null;
2270
2289
  const url = pathToFileURL(file).toString();
2271
2290
  const bust = dev ? `?t=${Date.now()}-${Math.random().toString(36).slice(2)}` : '';
2272
2291
  try {
2273
2292
  const mod = await import(url + bust);
2274
2293
  return typeof mod.default === 'function' ? mod.default : null;
2275
2294
  } catch (e) {
2276
- logger.error('failed to load middleware.js', { err: String(e) });
2295
+ logger.error('failed to load root middleware', { file, err: String(e) });
2277
2296
  return null;
2278
2297
  }
2279
2298
  }
@@ -2575,7 +2594,7 @@ async function tsResponse(abs, dev, elideOpts, cache, immutable, reportDevError)
2575
2594
  `Replace enum / namespace / parameter-property / legacy-decorator / ` +
2576
2595
  `import = require constructs with their erasable equivalents. ` +
2577
2596
  `Run \`webjs check\` for guidance (no-non-erasable-typescript rule). ` +
2578
- `Docs: https://docs.webjs.dev/docs/typescript`
2597
+ `Docs: https://webjs.dev/docs/typescript`
2579
2598
  // Prod: terse, no path leak, no Node-message leak (Node's
2580
2599
  // message can include source snippets). Operators get the
2581
2600
  // 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