@webjsdev/server 0.8.58 → 0.8.60

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/src/forwarded.js CHANGED
@@ -12,13 +12,38 @@
12
12
  * breaks OG / og:image tags, OAuth callback URLs, and any user code
13
13
  * that builds absolute URLs.
14
14
  *
15
- * Threat model: in webjs's typical deployment topology, the
16
- * container's HTTP port is only reachable through the trusted edge
17
- * proxy. There's no path for an attacker to inject these headers
18
- * without going through that proxy. For self-hosted bare-VM deploys
19
- * where the container is somehow directly exposed, set
20
- * `WEBJS_NO_TRUST_PROXY=1` to fall back to the raw `Host` header and
21
- * `http://` default.
15
+ * Threat model: the two headers have DIFFERENT trust properties, so do
16
+ * not reason about them as one.
17
+ *
18
+ * `X-Forwarded-Proto` is written by the proxy that terminates TLS, which
19
+ * OVERWRITES whatever the client sent. In WebJs's typical deployment
20
+ * topology the container's HTTP port is only reachable through that
21
+ * trusted edge, so the value this code reads is the edge's, not the
22
+ * client's. The guarantee is the overwrite, not the topology: an edge
23
+ * that APPENDS instead would put the client's entry first in the chain,
24
+ * and the comma rule below takes the first entry.
25
+ *
26
+ * `X-Forwarded-Host` is NOT covered by that argument. Cloudflare and
27
+ * Railway FORWARD a client-supplied `X-Forwarded-Host` rather than
28
+ * overwriting it, so going through the trusted proxy is exactly how a
29
+ * hostile value arrives. Treat a forged forwarded host as reachable in
30
+ * the NORMAL topology, not just on a directly-exposed container.
31
+ * Consequence, and the rule to apply to your own code: anything SHARED
32
+ * that is derived from the resulting origin must be KEYED by that origin
33
+ * rather than assumed constant. That is what `html-cache.js` does, folding
34
+ * `url.origin` into every cache key (#1097), after a single request
35
+ * carrying a hostile host could poison a `revalidate` page for every
36
+ * later visitor.
37
+ *
38
+ * `WEBJS_NO_TRUST_PROXY=1` makes THIS module ignore both headers and
39
+ * fall back to the raw `Host` header and the `http://` default. It is
40
+ * the remedy for a directly-exposed container, and it also narrows the
41
+ * forwarded-host exposure above, so it is not only a bare-VM concern.
42
+ * That switch is `trustProxy()` below, and it is now the ONE place the
43
+ * posture is decided: `csrf.js`'s `requestHost` used to read
44
+ * `x-forwarded-host` without consulting it, which is what #1104
45
+ * centralized. See `trustProxy()` for the full consumer list and for the
46
+ * direction rule that resolves it against the per-call `trustProxy` option.
22
47
  *
23
48
  * Header semantics:
24
49
  * - `X-Forwarded-Host` / `X-Forwarded-Proto` can be a comma-separated
@@ -163,16 +188,49 @@ export function applyForwarded(url, headers) {
163
188
  return origin;
164
189
  }
165
190
 
191
+ /**
192
+ * Is a reverse proxy in front of this process trusted to speak for the client?
193
+ *
194
+ * The ONE place the ORIGIN posture is decided (#1104): every reader of the
195
+ * forwarded HOST and PROTO goes through this, namely the URL rewrite here, the
196
+ * HSTS scheme gate in `headers.js`, and the CSRF host resolution in `csrf.js`.
197
+ * It used to be three separate reads with two different answers, so
198
+ * `WEBJS_NO_TRUST_PROXY=1` turned off two of them and an operator could not
199
+ * tell from any single file whether their app trusted forwarded headers.
200
+ *
201
+ * SCOPE: `clientIp` in `rate-limit.js` is a consumer too (#1254), so the switch
202
+ * now covers every forwarded-header read in the package, the client-IP ones
203
+ * (`x-forwarded-for` / `cf-connecting-ip` / `x-real-ip`) included. The name
204
+ * collision is still real: `trustProxy()` here is env-backed and package-wide,
205
+ * `trustProxy` there is a per-call boolean. They point the same direction, and
206
+ * the DIRECTION RULE is what resolves them. This switch only ever SUBTRACTS
207
+ * trust; the per-call option can only add it while this switch is unset. So
208
+ * `rateLimit({ trustProxy: true })` under `WEBJS_NO_TRUST_PROXY=1` resolves to
209
+ * the framework-stamped peer, because an env switch that two subsystems honour
210
+ * and one ignores cannot tell an operator their posture, and because the
211
+ * failure mode of an over-firing override is a shared bucket (visible,
212
+ * self-inflicted) rather than a limiter keyed on a header the client chooses.
213
+ *
214
+ * Read from `process.env` at CALL time, never cached at boot, so a test (and a
215
+ * runtime that mutates its own env) can toggle it per case.
216
+ *
217
+ * @returns {boolean}
218
+ */
219
+ export function trustProxy() {
220
+ return process.env.WEBJS_NO_TRUST_PROXY !== '1';
221
+ }
222
+
166
223
  /**
167
224
  * 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.
225
+ * `trustProxy()` opt-out. The one place the HEADER SHAPE rules (the
226
+ * comma-chain, the scheme allowlist) live, shared by the node and Bun entry
227
+ * points above.
170
228
  *
171
229
  * @param {(name: string) => string | string[] | undefined | null} getHeader
172
230
  * @returns {{ host: string | null, proto: string | null }}
173
231
  */
174
232
  function readForwarded(getHeader) {
175
- if (process.env.WEBJS_NO_TRUST_PROXY === '1') return { host: null, proto: null };
233
+ if (!trustProxy()) return { host: null, proto: null };
176
234
  return {
177
235
  host: firstHeaderValue(getHeader('x-forwarded-host')),
178
236
  proto: normalizeProto(firstHeaderValue(getHeader('x-forwarded-proto'))),
package/src/headers.js CHANGED
@@ -29,6 +29,8 @@
29
29
  * touch the response pipeline again.
30
30
  */
31
31
 
32
+ import { trustProxy } from './forwarded.js';
33
+
32
34
  /**
33
35
  * Baseline security headers, as literal name -> value pairs. Set only
34
36
  * when absent, so an app override always wins. HSTS is NOT here: it is
@@ -190,18 +192,18 @@ export function applySecurityHeaders(res, ctx) {
190
192
 
191
193
  /**
192
194
  * Detect whether the original client request was HTTPS, from a web
193
- * `Request` (the shape `handle()` works with). Honors the same
194
- * reverse-proxy trust posture as `forwarded.js`: read
195
- * `X-Forwarded-Proto` only when proxy trust is on
196
- * (`WEBJS_NO_TRUST_PROXY !== '1'`); otherwise fall back to the request
197
- * URL's scheme. Never trusts the header blindly.
195
+ * `Request` (the shape `handle()` works with). Reads `X-Forwarded-Proto`
196
+ * only when `trustProxy()` says a proxy speaks for the client; otherwise
197
+ * falls back to the request URL's scheme. Never trusts the header blindly.
198
+ *
199
+ * The posture comes from `forwarded.js` rather than an inline `process.env`
200
+ * read (#1104), so this gate cannot drift from the URL rewrite it shadows.
198
201
  *
199
202
  * @param {Request} req
200
203
  * @returns {boolean}
201
204
  */
202
205
  export function webRequestIsHttps(req) {
203
- const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
204
- if (trust) {
206
+ if (trustProxy()) {
205
207
  const proto = req.headers.get('x-forwarded-proto');
206
208
  if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
207
209
  }
package/src/html-cache.js CHANGED
@@ -27,8 +27,9 @@
27
27
  import { getStore } from './cache.js';
28
28
  import { STREAM_MARKER } from './conditional-get.js';
29
29
  import { createHash } from 'node:crypto';
30
- import { publishedBuildId } from './importmap.js';
31
- import { dynamicAccessed } from './context.js';
30
+ import { publishedBuildId, basePath } from './importmap.js';
31
+ import { stripBasePath } from './base-path.js';
32
+ import { dynamicAccessed, getRequest } from './context.js';
32
33
 
33
34
  /** Namespace prefix for every cached-HTML key, so a flush can target it. */
34
35
  const KEY_PREFIX = 'webjs:html:';
@@ -109,11 +110,37 @@ export function readRevalidate(pageModule) {
109
110
  }
110
111
 
111
112
  /**
112
- * The cache key for a request: the FULL URL (path + search string), since
113
- * `searchParams` change page output. Normalized to path + sorted query so
114
- * `?a=1&b=2` and `?b=2&a=1` share an entry. Two more discriminators are
115
- * folded into the namespace:
113
+ * The origin component of a cache key. `url.origin` is already the normalized
114
+ * lowercase `scheme://host[:port]` form for an http(s) URL; a non-special
115
+ * scheme serializes to the literal string `null`, which is a fine constant key
116
+ * component (such a URL never reaches a cacheable page render anyway).
116
117
  *
118
+ * @param {URL} url
119
+ * @returns {string}
120
+ */
121
+ function originKey(url) {
122
+ return url.origin;
123
+ }
124
+
125
+ /**
126
+ * The cache key for a request: the ORIGIN plus the full path + search string,
127
+ * since both `ctx.url.origin` and `searchParams` change page output.
128
+ * Normalized to path + sorted query so `?a=1&b=2` and `?b=2&a=1` share an
129
+ * entry. Four discriminators are folded into the namespace:
130
+ *
131
+ * - the origin (#1097). This cache is SHARED across visitors, and `ctx.url`
132
+ * is built from `X-Forwarded-Host` / `X-Forwarded-Proto`, which neither
133
+ * Cloudflare nor Railway strips from a client request. Without the origin
134
+ * in the key, one request carrying `X-Forwarded-Host: evil.example` baked
135
+ * an attacker-chosen origin into the shared body, and every later visitor
136
+ * to that path was served it until the entry expired: a poisoned
137
+ * `og:image`, canonical link, OAuth callback URL, or absolute asset URL.
138
+ * With the origin in the key, a clean request resolves its origin from the
139
+ * proxy-set `Host` and reads its OWN entry, so the hostile body is
140
+ * unreachable from any origin an attacker does not already control. A
141
+ * single-host deploy has exactly one origin and so exactly one key per URL,
142
+ * unchanged hit rate; a genuine multi-host deploy gets correctly separated
143
+ * entries instead of cross-serving one host's HTML to another;
117
144
  * - the in-process generation, so `revalidateAll()` (a generation bump)
118
145
  * makes every prior key unreachable in one step;
119
146
  * - the published build id (the importmap fingerprint), so a NEW DEPLOY
@@ -143,7 +170,7 @@ export function htmlCacheKey(url) {
143
170
  // the importmap-only build id misses. Empty (dev / no fingerprint) collapses
144
171
  // to the prior key shape, so an unconfigured app is byte-identical.
145
172
  const appfp = _appSourceFp ? `${_appSourceFp}:` : '';
146
- return `${KEY_PREFIX}${build}:${appfp}${_generation}:${url.pathname}${search}`;
173
+ return `${KEY_PREFIX}${build}:${appfp}${_generation}:${originKey(url)}:${url.pathname}${search}`;
147
174
  }
148
175
 
149
176
  /**
@@ -221,17 +248,59 @@ export function isCacheableResponse(res, guards = {}) {
221
248
  * ONLY the no-query entry; pass the exact `path?query` to target a
222
249
  * specific query variant, or call `revalidateAll()` to clear everything.
223
250
  *
224
- * @param {string} path e.g. '/blog' or '/blog?page=2'
251
+ * ORIGIN RESOLUTION (#1097). Cache keys carry the request's origin, so a bare
252
+ * path does not name one entry, it names one per origin the path was cached
253
+ * under. Two forms resolve it:
254
+ *
255
+ * 1. The CURRENT request's origin, when one is ambient. A mutation runs inside
256
+ * the request of the user who triggered it, and that user is on the same
257
+ * origin as the pages being evicted, so a bare path is exact for the
258
+ * ordinary "server action writes, then evicts" flow, which is every
259
+ * in-repo example and everything the docs show.
260
+ * 2. An ABSOLUTE url (`https://app.example/blog`) names its origin outright.
261
+ * This is the form for a caller with NO ambient request: a cron job, a
262
+ * queue worker, an `instrumentation.js` timer. Under `webjs.basePath` pass
263
+ * the PUBLIC url and the mount prefix is stripped for you, so it keys the
264
+ * same entry the write stored.
265
+ *
266
+ * A bare path with no ambient request cannot be resolved and WARNS ONCE naming
267
+ * the path, rather than silently evicting nothing.
268
+ *
269
+ * There is deliberately no process-local registry of "origins seen so far"
270
+ * standing in for case 2. The origin comes from `X-Forwarded-Host`, which is
271
+ * client-supplied and forwarded rather than stripped (the whole premise of
272
+ * #1097), so such a registry is attacker-writable: a flood of hostile hosts
273
+ * against a cold process either fills a bounded registry before the real origin
274
+ * is ever seen, or grows an unbounded one. Either way eviction silently stops
275
+ * working, which is worse than the explicit contract above.
276
+ *
277
+ * @param {string} path e.g. '/blog', '/blog?page=2', or 'https://app.example/blog'
225
278
  * @returns {Promise<void>}
226
279
  */
227
280
  export async function revalidatePath(path) {
228
281
  if (typeof path !== 'string' || !path) return;
229
- // Build the same normalized key readHtmlCache / writeHtmlCache produce.
230
282
  let url;
231
- try {
232
- url = new URL(path, 'http://internal.invalid');
233
- } catch {
234
- return;
283
+ if (/^https?:\/\//i.test(path)) {
284
+ try { url = new URL(path); } catch { return; }
285
+ // An absolute url is the PUBLIC one, so under `webjs.basePath` it carries
286
+ // the mount prefix. Cache entries key on the app-root-relative path (the
287
+ // write strips it in dev.js for exactly this reason), so strip it here too
288
+ // or the caller who was told to use this form, the one with no ambient
289
+ // request, computes a key nothing was ever stored under. A path that is
290
+ // not under the base path is left alone, matching the write's behaviour.
291
+ const base = basePath();
292
+ if (base) {
293
+ const stripped = stripBasePath(url.pathname, base);
294
+ if (stripped !== null) url.pathname = stripped;
295
+ }
296
+ } else {
297
+ const origin = ambientOrigin();
298
+ if (!origin) {
299
+ warnUnresolvedOriginOnce(path);
300
+ return;
301
+ }
302
+ // Build the same normalized key readHtmlCache / writeHtmlCache produce.
303
+ try { url = new URL(path, origin); } catch { return; }
235
304
  }
236
305
  try {
237
306
  await getStore().delete(htmlCacheKey(url));
@@ -240,6 +309,41 @@ export async function revalidatePath(path) {
240
309
  }
241
310
  }
242
311
 
312
+ /**
313
+ * The current request's origin, or '' when there is no ambient request. Never
314
+ * throws: an eviction helper must not take down the mutation that called it.
315
+ *
316
+ * @returns {string}
317
+ */
318
+ function ambientOrigin() {
319
+ try {
320
+ const req = getRequest();
321
+ return req ? new URL(req.url).origin : '';
322
+ } catch {
323
+ return '';
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Paths already warned about an unresolvable bare-path eviction, so the warning
329
+ * fires once per offending path rather than once per invocation.
330
+ * @type {Set<string>}
331
+ */
332
+ const _warnedUnresolvedPaths = new Set();
333
+
334
+ /** @param {string} path */
335
+ function warnUnresolvedOriginOnce(path) {
336
+ if (_warnedUnresolvedPaths.has(path)) return;
337
+ _warnedUnresolvedPaths.add(path);
338
+ console.warn(
339
+ `[webjs] revalidatePath(${JSON.stringify(path)}) evicted nothing: HTML cache ` +
340
+ `keys carry the request origin, and this call has no ambient request to take ` +
341
+ `one from (a cron job, a queue worker, a boot-time timer). Pass an absolute ` +
342
+ `url instead, e.g. revalidatePath('https://your-host${path.startsWith('/') ? path : '/' + path}'), ` +
343
+ `or call it from a server action, where the triggering request supplies the origin.`
344
+ );
345
+ }
346
+
243
347
  /**
244
348
  * Response-funnel step (#241): if the response carries the HTML_CACHE_MARKER
245
349
  * (the SSR opted it into caching), re-check every guard against the FINAL
package/src/js-scan.js CHANGED
@@ -632,28 +632,46 @@ export function matchClosingParenthesis(s, start) {
632
632
  * `}` inside `'…'`, `"…"`, or backtick templates don't decrement depth.
633
633
  * Returns -1 if no balanced brace is found.
634
634
  *
635
+ * A template hole is a CODE context nested inside a template, not a brace in
636
+ * the enclosing block, so it gets its own frame on the stack: `${` pushes,
637
+ * and the `}` that returns that frame to depth zero pops back into the
638
+ * template rather than counting toward the block being matched. An earlier
639
+ * version incremented the outer depth at `${` and then never decremented it
640
+ * (the closing `}` arrived while still in template state), so depth could
641
+ * never return to zero and a class body holding `` html`…${x}…` `` was
642
+ * unmatchable. Every caller passes a masked source in which holes are already
643
+ * blanked, so the bug is invisible until one passes raw source.
644
+ *
635
645
  * @param {string} s
636
646
  * @param {number} start
637
647
  */
638
648
  export function matchClosingBrace(s, start) {
639
- let depth = 1;
649
+ // Innermost first. `tpl` frames are template literals (no brace counting);
650
+ // `!tpl` frames are code, each with its own depth.
651
+ /** @type {Array<{ tpl: boolean, depth: number }>} */
652
+ const stack = [{ tpl: false, depth: 1 }];
640
653
  let i = start;
641
- let str = ''; // '', "'", '"', or backtick
642
654
  while (i < s.length) {
655
+ const top = stack[stack.length - 1];
643
656
  const c = s[i];
644
- if (str) {
657
+ if (top.tpl) {
645
658
  if (c === '\\') { i += 2; continue; }
646
- if (c === str) str = '';
647
- else if (str === '`' && c === '$' && s[i + 1] === '{') {
648
- // template hole, count its closing `}` toward our brace depth.
649
- depth++;
650
- i += 2;
651
- continue;
652
- }
659
+ if (c === '`') { stack.pop(); i++; continue; }
660
+ if (c === '$' && s[i + 1] === '{') { stack.push({ tpl: false, depth: 1 }); i += 2; continue; }
653
661
  i++;
654
662
  continue;
655
663
  }
656
- if (c === "'" || c === '"' || c === '`') { str = c; i++; continue; }
664
+ if (c === "'" || c === '"') {
665
+ i++;
666
+ while (i < s.length) {
667
+ if (s[i] === '\\') { i += 2; continue; }
668
+ const d = s[i];
669
+ i++;
670
+ if (d === c || d === '\n') break; // closed, or unterminated at EOL
671
+ }
672
+ continue;
673
+ }
674
+ if (c === '`') { stack.push({ tpl: true, depth: 0 }); i++; continue; }
657
675
  if (c === '/' && s[i + 1] === '/') { // line comment
658
676
  while (i < s.length && s[i] !== '\n') i++;
659
677
  continue;
@@ -664,8 +682,18 @@ export function matchClosingBrace(s, start) {
664
682
  i += 2;
665
683
  continue;
666
684
  }
667
- if (c === '{') depth++;
668
- else if (c === '}') { depth--; if (depth === 0) return i; }
685
+ if (c === '{') { top.depth++; i++; continue; }
686
+ if (c === '}') {
687
+ top.depth--;
688
+ // Depth zero closes this frame: the outermost one is the answer, an inner
689
+ // one is a template hole ending and hands control back to its template.
690
+ if (top.depth === 0) {
691
+ if (stack.length === 1) return i;
692
+ stack.pop();
693
+ }
694
+ i++;
695
+ continue;
696
+ }
669
697
  i++;
670
698
  }
671
699
  return -1;
@@ -154,7 +154,7 @@ export function startBunListener(ctx) {
154
154
  const shutdown = makeShutdown({ closeServer, hub, logger });
155
155
  process.once('SIGINT', () => shutdown('SIGINT'));
156
156
  process.once('SIGTERM', () => shutdown('SIGTERM'));
157
- installProcessHandlers(logger, () => shutdown('uncaughtException'));
157
+ installProcessHandlers(logger, () => shutdown('uncaughtException', { fatal: true }));
158
158
 
159
159
  return {
160
160
  server,
@@ -383,7 +383,7 @@ export class SseHub {
383
383
  * Install once-only process error handlers. Idempotent across multiple
384
384
  * `startServer` calls in the same process. Runtime-neutral (plain `process.on`).
385
385
  * @param {import('./logger.js').Logger} logger
386
- * @param {() => void} onFatal
386
+ * @param {() => void} onFatal Starts the FATAL shutdown, which exits non-zero.
387
387
  */
388
388
  export function installProcessHandlers(logger, onFatal) {
389
389
  if (/** @type any */ (globalThis).__webjsProcHandlers) return;
@@ -405,12 +405,37 @@ export function installProcessHandlers(logger, onFatal) {
405
405
  * `closeServer()` thunk that resolves once the server has stopped accepting
406
406
  * connections (node `server.close`, Bun `server.stop(true)`). Closes the SSE hub,
407
407
  * then drains, then exits; hard-exits after 10s if the drain hangs.
408
+ *
409
+ * Exit code, in full. Three things can force a 1, and only their absence
410
+ * leaves a 0:
411
+ * - a drain that FAILS, so a rejected `closeServer()` and a drain still
412
+ * hanging at the 10s deadline both exit 1 whatever started them;
413
+ * - a FATAL start (the `uncaughtException` handler calling `onFatal`), which
414
+ * exits 1 even when the drain is perfectly clean;
415
+ * - a FATAL arriving MID-DRAIN, which upgrades a shutdown an operator signal
416
+ * had already begun (see the upgrade-only note on `code` below).
417
+ * So 0 does NOT mean "a signal started it", it means an operator asked for the
418
+ * stop AND it drained cleanly AND nothing crashed on the way out. A supervisor
419
+ * reads only this code (systemd `Restart=on-failure`, Docker, Railway, and a CI
420
+ * step running a `test/bun/*.mjs` proof script, whose failed top-level assertion
421
+ * arrives here as an uncaught exception, #1092), so every one of those has to
422
+ * come back non-zero.
408
423
  * @param {{ closeServer: () => Promise<unknown>, hub: SseHub, logger: import('./logger.js').Logger }} opts
409
- * @returns {(signal: string) => void}
424
+ * @returns {(signal: string, opts?: { fatal?: boolean }) => void}
410
425
  */
411
426
  export function makeShutdown({ closeServer, hub, logger }) {
412
427
  let shuttingDown = false;
413
- return (signal) => {
428
+ // Read at EXIT time, not at entry, so a fatal arriving mid-drain still counts.
429
+ // The drain window is up to 10s wide and in-flight requests are still running
430
+ // in it, so an uncaught exception landing there is an ordinary event, not an
431
+ // exotic race. Because the re-entrancy guard below drops that second call, a
432
+ // code captured at entry would stay 0 and the crashed process would report a
433
+ // successful stop, which is the exact failure this whole mechanism exists to
434
+ // prevent. Upgrade only (1 never falls back to 0), so a signal arriving during
435
+ // a fatal shutdown cannot launder the crash into a success either.
436
+ let code = 0;
437
+ return (signal, { fatal = false } = {}) => {
438
+ if (fatal) code = 1;
414
439
  if (shuttingDown) return;
415
440
  shuttingDown = true;
416
441
  logger.info(`received ${signal}, shutting down`);
@@ -420,11 +445,16 @@ export function makeShutdown({ closeServer, hub, logger }) {
420
445
  process.exit(1);
421
446
  }, 10_000);
422
447
  if (typeof hard.unref === 'function') hard.unref();
448
+ // Disarm before exiting. In production `process.exit` ends everything so this
449
+ // is redundant, but it keeps the deadline from outliving the decision it was
450
+ // guarding, which is what let a stubbed-exit test leave a live timer armed to
451
+ // kill an unrelated process 10s later.
452
+ const done = (c) => { clearTimeout(hard); process.exit(c); };
423
453
  Promise.resolve()
424
454
  .then(closeServer)
425
455
  .then(
426
- () => { logger.info('bye'); process.exit(0); },
427
- (err) => { logger.error('server close error', { err: String(err) }); process.exit(1); },
456
+ () => { logger.info('bye'); done(code); },
457
+ (err) => { logger.error('server close error', { err: String(err) }); done(1); },
428
458
  );
429
459
  };
430
460
  }
package/src/rate-limit.js CHANGED
@@ -23,6 +23,14 @@
23
23
  */
24
24
 
25
25
  import { getStore } from './cache.js';
26
+ // Aliased on purpose. `rateLimit()` binds a local `const trustProxy` from its
27
+ // own options, which would shadow a same-named import inside that function and
28
+ // leave the middleware path on the pre-override behaviour while the direct
29
+ // `clientIp` path changed. `forwarded.js` imports nothing, so this adds no cycle.
30
+ import { trustProxy as proxyIsTrusted } from './forwarded.js';
31
+
32
+ /** Module-scope latch so the override warns once per process, never per request. */
33
+ let warnedProxyOverride = false;
26
34
 
27
35
  /**
28
36
  * @param {{
@@ -126,8 +134,8 @@ function trustedRemoteIp(req) {
126
134
 
127
135
  /**
128
136
  * Carry the trusted remote IP from `src` to a freshly-rebuilt `dst` Request
129
- * (#756). When the framework re-wraps a request (the page-action body rebuild in
130
- * `page-action.js`), the out-of-band WeakMap key is a new object, so the trusted
137
+ * (#756). When the framework re-wraps a request (the form-submission body rebuild
138
+ * in `form-dispatch.js`), the out-of-band WeakMap key is a new object, so the trusted
131
139
  * IP would be lost and `clientIp(dst)` would fall back to the (spoofable) header
132
140
  * the rebuild copied over. Propagating the trusted value via the WeakMap keeps
133
141
  * `dst` authoritative on BOTH runtimes (it reads `trustedRemoteIp(src)`, which is
@@ -167,12 +175,32 @@ export function propagateTrustedRemoteIp(src, dst) {
167
175
  * trust-proxy reintroduces the spoofability this option exists to
168
176
  * defend against.
169
177
  *
178
+ * `WEBJS_NO_TRUST_PROXY=1` OVERRIDES `trustProxy: true` (#1254). The env
179
+ * switch is the operator's statement about the deployment topology and it can
180
+ * only ever SUBTRACT trust, so when it is set this call falls back to the
181
+ * default path above: the framework-stamped peer, or `_anon_` when there is
182
+ * none, and never a forwarded-IP header. The override logs once per process.
183
+ * An app that genuinely sits behind a trusted proxy must unset the flag; the
184
+ * two settings answer the same question about one socket and one topology, so
185
+ * leaving them in disagreement buckets every visitor behind that proxy onto
186
+ * one key.
187
+ *
170
188
  * @param {Request} req
171
189
  * @param {{ trustProxy?: boolean }} [opts]
172
190
  * @returns {string}
173
191
  */
174
192
  export function clientIp(req, opts = {}) {
175
- if (opts.trustProxy === true) {
193
+ if (opts.trustProxy === true && !proxyIsTrusted() && !warnedProxyOverride) {
194
+ warnedProxyOverride = true;
195
+ // No request data in this line, ever. It must not become a log of
196
+ // client-supplied header values.
197
+ console.warn(
198
+ '[webjs] WEBJS_NO_TRUST_PROXY=1 overrides trustProxy: true; ' +
199
+ 'forwarded-IP headers are ignored and the framework-stamped peer is used. ' +
200
+ 'Unset the env var if a trusted proxy really is in front of this process.',
201
+ );
202
+ }
203
+ if (opts.trustProxy === true && proxyIsTrusted()) {
176
204
  return (
177
205
  req.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
178
206
  req.headers.get('cf-connecting-ip') ||
@@ -11,7 +11,7 @@
11
11
  * Design choices:
12
12
  * - PAGES ONLY. A `route.{js,ts}` handler is an API endpoint, not a
13
13
  * navigable HTML page, so its path is excluded from the navigable `Route`
14
- * union. Pages (including page-action pages) are what a valid href points
14
+ * union. Pages (including ones hosting a bound form) are what a valid href points
15
15
  * at.
16
16
  * - The route KEY is the literal pattern (`/blog/[slug]`), derived from the
17
17
  * page's directory with route groups `(group)` stripped and `_private`