@webjsdev/server 0.8.58 → 0.8.59

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.
@@ -0,0 +1,500 @@
1
+ import { isNotFound, isRedirect, isForbidden, isUnauthorized, FORM_ACTION_FIELD } from '@webjsdev/core';
2
+ import { ssrPage, ssrNotFound, ssrForbidden, ssrUnauthorized } from './ssr.js';
3
+ import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
4
+ import { getBodyLimits } from './context.js';
5
+ import { propagateTrustedRemoteIp } from './rate-limit.js';
6
+ import { makeThenable } from './thenable-params.js';
7
+ import { lookupActionIdentity } from './form-action-identity.js';
8
+ import { verifyOrigin } from './csrf.js';
9
+ import { actionMethod, actionConfigFn, actionMiddleware, resolveTags, RESERVED_CONFIG } from './action-config.js';
10
+ import { runValidate } from './actions.js';
11
+ import { runActionChain } from './action-middleware.js';
12
+ import { runWithActionSignal } from './action-signal.js';
13
+ import { isStreamable } from './action-stream.js';
14
+ import { revalidateTags } from './cache-tags.js';
15
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
16
+
17
+ /**
18
+ * The form-submission dispatcher (#1155): a non-GET request to a PAGE's own url
19
+ * carrying the `__webjs_action` hidden field runs the server action that field
20
+ * names, and the response drives the page.
21
+ *
22
+ * This is the ONE write path. A `<form action=${importedAction}>` renders to a
23
+ * plain HTML form that posts here with no JS, and the client router posts the
24
+ * SAME body to the SAME url when JS is on, so the two paths are identical by
25
+ * construction rather than by two implementations agreeing. It replaces the
26
+ * page `action` export (#244), which forced every module action through a
27
+ * hand-written per-page adapter.
28
+ *
29
+ * Behaviour:
30
+ * - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
31
+ * render does (3xx / 404). A thrown `redirect()` may target an external URL
32
+ * (it is the explicit nav sentinel, author-controlled).
33
+ * - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
34
+ * present, else to the page's own path (Post/Redirect/Get, so a reload does
35
+ * not resubmit). `result.redirect` MUST be a same-site local path (see
36
+ * `sameSiteRedirect`), a non-local value is ignored to avoid an
37
+ * open-redirect through a user-controlled action result.
38
+ * - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
39
+ * the result on `ctx.actionData`, so the page template can read
40
+ * `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
41
+ *
42
+ * The action's declared config exports run here too (`validate`, `middleware`,
43
+ * `invalidates`), on the same seam `invokeAction` uses. Skipping them would
44
+ * mean an action was protected over RPC and unprotected over a form, which is
45
+ * a privilege gap and not a missing feature.
46
+ *
47
+ * @typedef {{
48
+ * success?: boolean,
49
+ * data?: unknown,
50
+ * error?: string,
51
+ * fieldErrors?: Record<string,string>,
52
+ * values?: Record<string,string>,
53
+ * status?: number,
54
+ * redirect?: string,
55
+ * }} ActionResult
56
+ */
57
+
58
+ /**
59
+ * What a form submission whose identity no longer resolves is told.
60
+ *
61
+ * A form held open across a deploy submits a hash the new build has never
62
+ * seen. The response re-renders the page at 422 with this on `actionData`,
63
+ * because the alternatives are worse: a 404 loses everything the user typed,
64
+ * and treating it as a no-op shows a success page for a write that did not
65
+ * happen. Next surfaces the same case as an "older or newer deployment" error.
66
+ */
67
+ export const ACTION_SKEW_MESSAGE =
68
+ 'This page was updated while the form was open. Please submit again.';
69
+
70
+ /**
71
+ * Whether an action result is a FAILURE (re-render the page) rather than a
72
+ * success (PRG redirect). A result is a failure when ANY of these hold:
73
+ * - `result.success === false` (explicit), OR
74
+ * - `result.fieldErrors` is present (per-field validation messages), OR
75
+ * - `result.error` is present AND `result.success !== true`.
76
+ *
77
+ * Success is the explicit `success: true`, or a bare value (or
78
+ * undefined/null) carrying no error markers. This means an action that returns
79
+ * `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
80
+ * is still treated as a failure and its error is surfaced, not swallowed.
81
+ *
82
+ * @param {ActionResult | null | undefined} result
83
+ * @returns {boolean}
84
+ */
85
+ function isFailureResult(result) {
86
+ if (!result || typeof result !== 'object') return false;
87
+ if (result.success === false) return true;
88
+ if (result.fieldErrors != null) return true;
89
+ if (result.error != null && result.success !== true) return true;
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Restrict a form action's `result.redirect` to a SAME-SITE local target.
95
+ * Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
96
+ * Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
97
+ * URL. A user-controlled redirect target is an open-redirect vector, so a
98
+ * non-local value is dropped and the caller falls back to the page's own path.
99
+ *
100
+ * The check runs on the string the BROWSER will parse, not the one the action
101
+ * returned, and those differ. The WHATWG URL parser REMOVES every ASCII tab,
102
+ * LF, and CR from a URL before parsing, so `/<TAB>/evil.com` reaches the parser
103
+ * as `//evil.com` and resolves cross-origin. `Headers` rejects a LF or CR in a
104
+ * field value, but a tab is a legal HTTP field-value character and rides
105
+ * through to the wire intact (measured: `new URL('/\t/evil.com', origin).href`
106
+ * is `https://evil.com/`, and Chromium follows it to the attacker host). So
107
+ * strip those three FIRST and validate what is left, which also makes the
108
+ * returned value exactly what the browser sees. A raw tab in a Location is
109
+ * malformed regardless; a legitimate path percent-encodes it.
110
+ *
111
+ * A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
112
+ * routed through here: that is the author-controlled escape hatch for a
113
+ * legitimate external redirect.
114
+ *
115
+ * @param {unknown} target
116
+ * @returns {string | null} the safe local path, or null when not same-site
117
+ */
118
+ function sameSiteRedirect(target) {
119
+ if (typeof target !== 'string') return null;
120
+ // What the URL parser will actually see (tab / LF / CR are removed anywhere
121
+ // in the string, not only at the front).
122
+ const url = target.replace(/[\t\n\r]/g, '');
123
+ // Must start with a single slash (a leading `//` is protocol-relative and
124
+ // would navigate cross-origin).
125
+ if (!url.startsWith('/') || url.startsWith('//')) return null;
126
+ // A backslash after the leading slash (`/\evil.com`) is normalized by some
127
+ // browsers into a protocol-relative URL, so reject it too.
128
+ if (url.startsWith('/\\')) return null;
129
+ return url;
130
+ }
131
+
132
+ /**
133
+ * Read the submitted body ONCE, bounded by the form/multipart limit (issue
134
+ * #237), and return both a `FormData` (handed to the action) and a rebuilt
135
+ * `Request` carrying the already-read bytes (so a middleware or the action can
136
+ * still call `request.json()` / `request.formData()`). The body is consumed off
137
+ * the ORIGINAL request directly, NOT via `req.clone()`: a tee'd clone whose
138
+ * reader is cancelled mid-stream (the over-limit case) deadlocks the untaken
139
+ * branch, hanging the response.
140
+ *
141
+ * An over-limit body is reported as `tooLarge` (the caller returns 413) and is
142
+ * never buffered whole. A form posts more than a JSON RPC call (textarea, small
143
+ * upload), so it uses the higher `multipart` cap. A non-form content type yields
144
+ * an empty FormData so the action signature stays stable; the rebuilt request
145
+ * still carries the raw bytes for the action to parse however it likes.
146
+ *
147
+ * @param {Request} req
148
+ * @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
149
+ */
150
+ async function parseFormBody(req) {
151
+ const ct = req.headers.get('content-type') || '';
152
+ const limits = getBodyLimits();
153
+ const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
154
+ const { tooLarge, bytes } = await readBytesBounded(req, limit);
155
+ if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
156
+
157
+ // Rebuild a fresh Request from the bytes so the action can re-read the body.
158
+ // SECURITY (#756): strip any inbound `x-webjs-remote-ip` the copy carried so a
159
+ // client cannot spoof it through the rebuild, and carry the FRAMEWORK-trusted
160
+ // remote IP forward out of band (the rebuild is a new object, so the listener's
161
+ // WeakMap stamp on `req` does not follow it). Without this, `clientIp` inside a
162
+ // form action (the no-JS write path, e.g. login throttling) would read the
163
+ // spoofable header on Bun.
164
+ const headers = new Headers(req.headers);
165
+ headers.delete('x-webjs-remote-ip');
166
+ const rebuilt = new Request(req.url, {
167
+ method: req.method,
168
+ headers,
169
+ body: bytes && bytes.byteLength ? bytes : undefined,
170
+ // Carry the ORIGINAL abort signal. Without it the rebuild gets a fresh,
171
+ // never-aborted one, and since this request is what drives
172
+ // `runWithActionSignal`, `actionSignal()` (#492) inside a form-bound action
173
+ // could never fire, no matter what the transport underneath did. The RPC
174
+ // path gets this for free: it consumes the original request rather than
175
+ // rebuilding it (`readTextBounded` in actions.js).
176
+ //
177
+ // How far it actually reaches is a property of the listener, not of this
178
+ // line. On Bun, `Bun.serve` hands over a request whose signal fires on
179
+ // disconnect (`listener-bun.js` forwards it), so this makes cancellation
180
+ // work. On Node, `toWebRequest` (dev.js) builds its Request with no signal
181
+ // at all, so nothing aborts on EITHER transport there; that is a listener
182
+ // gap ahead of this one, and closing it here would be a fiction.
183
+ signal: req.signal,
184
+ });
185
+ propagateTrustedRemoteIp(req, rebuilt);
186
+
187
+ const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
188
+ let formData = new FormData();
189
+ if (isForm) {
190
+ // Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
191
+ const forParse = new Request(req.url, {
192
+ method: 'POST',
193
+ headers: ct ? { 'content-type': ct } : undefined,
194
+ body: bytes && bytes.byteLength ? bytes : undefined,
195
+ });
196
+ formData = await forParse.formData();
197
+ }
198
+ return { tooLarge: false, formData, request: rebuilt };
199
+ }
200
+
201
+ /**
202
+ * Does this request look like a form submission at all?
203
+ *
204
+ * Content-type only, and deliberately cheap: the authoritative test is the
205
+ * hidden field, which needs the body read, and buffering the body of every
206
+ * stray POST to a page path just to decide it is a 405 would be a free way to
207
+ * make the server do work an attacker chose for it.
208
+ *
209
+ * @param {Request} req
210
+ * @returns {boolean}
211
+ */
212
+ function looksLikeFormSubmission(req) {
213
+ const ct = req.headers.get('content-type') || '';
214
+ return /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
215
+ }
216
+
217
+ /**
218
+ * The submitted TEXT fields as a plain record, for repopulating a form the
219
+ * dispatcher could not run.
220
+ *
221
+ * Files are skipped: a `File` is not a string, and no browser lets a page
222
+ * refill a file input anyway, so carrying one here would only put an object
223
+ * where the page expects text.
224
+ *
225
+ * @param {FormData} formData
226
+ * @returns {Record<string, string>}
227
+ */
228
+ function textValuesOf(formData) {
229
+ /** @type {Record<string, string>} */
230
+ const out = {};
231
+ for (const [k, v] of formData) if (typeof v === 'string') out[k] = v;
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * A page path exists but only renders: the method is the problem, not the url.
237
+ * @returns {Response}
238
+ */
239
+ function methodNotAllowed() {
240
+ return new Response('Method Not Allowed', {
241
+ status: 405,
242
+ headers: { allow: 'GET, HEAD' },
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Run a bound form action for a non-GET/HEAD request to a page's own url and
248
+ * produce the HTTP response. The caller has already matched the path to a page
249
+ * route and confirmed the request carries a form body; this runs inside the
250
+ * page's segment middleware (the caller wraps it).
251
+ *
252
+ * @param {import('./router.js').PageRoute} route
253
+ * @param {Record<string,string>} params
254
+ * @param {URL} url
255
+ * @param {Request} req
256
+ * @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
257
+ * @param {{
258
+ * actionIndex: import('./actions.js').ActionIndex,
259
+ * allowedOrigins?: string[],
260
+ * onError?: (error: unknown) => void,
261
+ * }} deps
262
+ * @returns {Promise<Response>}
263
+ */
264
+ export async function runFormAction(route, params, url, req, ssrOpts, deps) {
265
+ const { actionIndex, allowedOrigins = [], onError } = deps;
266
+
267
+ // Not a form body at all (a stray JSON POST, a probe): the page path exists
268
+ // and only renders. Answered before the body is touched.
269
+ if (!looksLikeFormSubmission(req)) return methodNotAllowed();
270
+
271
+ // CSRF, before the body is read and before anything runs. Same Origin /
272
+ // Sec-Fetch-Site check the RPC endpoint applies (`csrf.js`). The page `action`
273
+ // export this replaces had none, and was shielded only by SameSite=Lax cookies.
274
+ const origin = verifyOrigin(req, allowedOrigins);
275
+ if (!origin.ok) return new Response('CSRF validation failed', { status: 403 });
276
+
277
+ let formData = new FormData();
278
+ let actionReq = req;
279
+ try {
280
+ const parsed = await parseFormBody(req);
281
+ // Over the form/multipart limit (issue #237): 413 before anything runs.
282
+ if (parsed.tooLarge) return payloadTooLarge();
283
+ formData = parsed.formData;
284
+ actionReq = parsed.request;
285
+ } catch {
286
+ formData = new FormData();
287
+ }
288
+
289
+ const actions = formData.getAll(FORM_ACTION_FIELD);
290
+ const id = actions.length ? actions[actions.length - 1] : null;
291
+ // A form body carrying no identity: a hand-written `<form method="post">`
292
+ // that binds no action. Nothing to run, and the page only renders.
293
+ if (typeof id !== 'string' || !id) return methodNotAllowed();
294
+ // The field is framework wire, not app data. Removing it keeps an action
295
+ // that iterates the FormData (building a record, echoing values back into a
296
+ // 422 re-render) from seeing a key it did not put there.
297
+ formData.delete(FORM_ACTION_FIELD);
298
+
299
+ const found = await lookupActionIdentity(actionIndex, id);
300
+ if (!found.ok) {
301
+ // The action's module threw at import (a bad env var read at module scope,
302
+ // a syntax error in dev). That is a server fault, not skew: telling the
303
+ // user to resubmit would loop them forever against a module that cannot
304
+ // load, and swallowing the error would leave nothing in the log or the APM
305
+ // sink. Surface it the way any other action throw is surfaced.
306
+ if (found.reason === 'load-failed') {
307
+ if (typeof onError === 'function') onError(found.error);
308
+ return await formActionErrorResponse(found.error, ssrOpts.dev);
309
+ }
310
+ if (found.reason === 'skew') {
311
+ return ssrPage(route, params, url, {
312
+ ...ssrOpts, req,
313
+ // The submitted values ride along on the standard `actionData.values`
314
+ // key, so the page's ordinary repopulation idiom refills the form and
315
+ // "please submit again" is something a person can actually act on
316
+ // rather than an instruction to retype everything.
317
+ actionData: { success: false, error: ACTION_SKEW_MESSAGE, values: textValuesOf(formData) },
318
+ status: 422,
319
+ });
320
+ }
321
+ return new Response('Unknown action', { status: 404 });
322
+ }
323
+
324
+ const { fnName, module: mod } = found;
325
+ const fn = fnName === 'default' ? mod.default : mod[fnName];
326
+ // A reserved config export is never a callable action even though some are
327
+ // functions (#488), so a forged `__webjs_action` naming one is a 404 rather
328
+ // than a way to invoke `validate` directly.
329
+ if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
330
+ return new Response('Unknown action', { status: 404 });
331
+ }
332
+ // A GET-declared action cannot be a form target: it is CSRF-exempt and rides
333
+ // its args in the url, so binding one to a POST form is a contradiction. A
334
+ // `webjs check` rule catches it at edit time; this is the runtime backstop.
335
+ if (actionMethod(mod) === 'GET') {
336
+ return new Response(
337
+ `The action ${fnName} declares method = 'GET', so it cannot be bound to a form.`,
338
+ { status: 405, headers: { allow: 'GET' } },
339
+ );
340
+ }
341
+
342
+ // A form-bound action always receives the `FormData` (#1155). `validate` is
343
+ // the typing seam: it takes the FormData and its transform-return becomes the
344
+ // action's typed input, the same `runValidate` contract the RPC path uses.
345
+ let args = [formData];
346
+ const validate = actionConfigFn(mod, 'validate');
347
+ if (typeof validate === 'function') {
348
+ const v = runValidate(validate, args[0]);
349
+ if (!v.ok) {
350
+ if (v.thrown !== undefined) {
351
+ // A THROWN validator is answered here rather than rethrown. Letting it
352
+ // escape reaches the handler's last-resort catch, which reports the
353
+ // SAME error to `onError` a second time and answers a bare 500 with no
354
+ // digest, so the one thing that makes a prod error diagnosable is lost.
355
+ // The RPC path does exactly this (`actionErrorResponse` in actions.js).
356
+ if (typeof onError === 'function') onError(v.thrown);
357
+ return await formActionErrorResponse(v.thrown, ssrOpts.dev);
358
+ }
359
+ // A structured failure envelope is a NORMAL result the page renders, so
360
+ // it takes the 422 re-render path exactly as a failing action would.
361
+ return ssrPage(route, params, url, { ...ssrOpts, req, actionData: v.result, status: 422 });
362
+ }
363
+ args = [v.value];
364
+ }
365
+
366
+ const searchParams = Object.fromEntries(url.searchParams.entries());
367
+ /** @type {ActionResult | undefined} */
368
+ let result;
369
+ let ranAction = false;
370
+ try {
371
+ const middleware = actionMiddleware(mod);
372
+ result = await runWithActionSignal(actionReq.signal, () =>
373
+ runActionChain(
374
+ middleware,
375
+ {
376
+ request: actionReq,
377
+ args,
378
+ signal: actionReq.signal,
379
+ // Route context a form middleware may want, absent on the RPC path
380
+ // because an RPC call has no page. Params are awaitable AND
381
+ // sync-readable, matching a page render (#848).
382
+ params: makeThenable(params),
383
+ searchParams: makeThenable(searchParams),
384
+ url,
385
+ },
386
+ () => { ranAction = true; return fn(...args); },
387
+ ));
388
+ } catch (err) {
389
+ if (isRedirect(err)) {
390
+ const e = /** @type any */ (err);
391
+ // A thrown redirect from an action (a POST) defaults to 307 Temporary
392
+ // Redirect, which is method-preserving so the action's intent survives
393
+ // the bounce; an explicit `redirect(url, status)` overrides it. This is
394
+ // deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
395
+ // the SUCCESS-result path below.
396
+ return new Response(null, { status: e.status || 307, headers: { location: e.url } });
397
+ }
398
+ if (isNotFound(err)) {
399
+ return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
400
+ }
401
+ // forbidden()/unauthorized() from a form action render the same 403/401
402
+ // boundary as the page-render path (#848), not a generic 500.
403
+ if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
404
+ if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
405
+ if (typeof onError === 'function') onError(err);
406
+ return await formActionErrorResponse(err, ssrOpts.dev);
407
+ }
408
+
409
+ // A streamed return (#489) has no consumer here. The RPC stub decodes frames;
410
+ // a form submission is answered with a redirect or a page, and with JS off
411
+ // there is nothing on the other end at all. Refusing loudly beats emitting a
412
+ // frame stream a browser renders as garbage.
413
+ if (ranAction && isStreamable(result)) {
414
+ const err = new Error(
415
+ `The action ${fnName} returned a stream, which a form submission cannot consume. `
416
+ + 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
417
+ );
418
+ if (typeof onError === 'function') onError(err);
419
+ return await formActionErrorResponse(err, ssrOpts.dev);
420
+ }
421
+
422
+ // Only a COMPLETED action evicts its `invalidates` tags; a middleware
423
+ // short-circuit (the action never ran) does not.
424
+ //
425
+ // The tags are also REPORTED, the way `invokeAction` reports them, so the
426
+ // browser-side tag coordinator can bypass a stale cached GET. One reach
427
+ // limit, stated because it looks like an oversight: `fetch` follows the 303
428
+ // transparently, so JS cannot read the headers of a redirect response. The
429
+ // header is on the wire and the 422 re-render path carries it to the router;
430
+ // after a successful PRG the client learns nothing from it. The redirect's
431
+ // own re-render is server-side and seeds fresh data, so what remains exposed
432
+ // is only a later client refetch of a separately cached GET key.
433
+ let invalidated = [];
434
+ if (ranAction) {
435
+ invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), args);
436
+ if (invalidated.length) await revalidateTags(invalidated);
437
+ }
438
+ const invalidateHeader = invalidated.length
439
+ ? { 'x-webjs-invalidate': invalidated.join(',') }
440
+ : null;
441
+
442
+ // An action MAY return a `Response` directly (e.g. a content-negotiated
443
+ // `streamResponse`, #248). Honor it verbatim, so the action owns the status +
444
+ // content type and the router applies it (a stream body surgically). With JS
445
+ // off the same action returns a normal ActionResult instead, so the PRG /
446
+ // re-render paths below still drive the no-JS form.
447
+ if (result instanceof Response) return result;
448
+
449
+ if (!isFailureResult(result)) {
450
+ // SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
451
+ // honored when it is a same-site local path; otherwise fall back to the
452
+ // page's own path so a poisoned value cannot become an open redirect.
453
+ const ownPath = (url.pathname + url.search) || '/';
454
+ const safe = result ? sameSiteRedirect(result.redirect) : null;
455
+ return new Response(null, {
456
+ status: 303,
457
+ headers: { location: safe || ownPath, ...invalidateHeader },
458
+ });
459
+ }
460
+
461
+ // FAILURE: re-render the SAME page with the action result available on
462
+ // ctx.actionData, status 422. Repopulation is the page author's job (native
463
+ // `value=${actionData.values?.field}`).
464
+ const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
465
+ const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status });
466
+ // A failure result can still have mutated (a partial write, then a rejected
467
+ // second step), so the eviction above applies here too and this response, not
468
+ // being a redirect, is one the router can actually read the header off.
469
+ if (invalidateHeader && page instanceof Response) {
470
+ for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
471
+ }
472
+ return page;
473
+ }
474
+
475
+ /**
476
+ * A thrown form action becomes a 500 whose body is sanitized the same way the
477
+ * RPC path's is (#749): dev shows the real message, prod shows a generic one
478
+ * plus a correlation digest and logs the full error. A raw driver message or an
479
+ * fs path is not author-controlled, so it does not reach the browser, and here
480
+ * the browser is showing it to a person rather than parsing it.
481
+ *
482
+ * @param {unknown} err
483
+ * @param {boolean} [dev]
484
+ * @returns {Promise<Response>}
485
+ */
486
+ async function formActionErrorResponse(err, dev) {
487
+ if (dev) {
488
+ console.error('[webjs] form action threw:', err);
489
+ const msg = err instanceof Error ? err.message : String(err);
490
+ return new Response(msg, { status: 500 });
491
+ }
492
+ if (isControlFlowThrow(err)) {
493
+ console.error('[webjs] form action threw:', err);
494
+ const msg = err instanceof Error ? err.message : String(err);
495
+ return new Response(msg, { status: 500 });
496
+ }
497
+ const digest = await errorDigest(err);
498
+ console.error(`[webjs] form action threw [digest=${digest}]:`, err);
499
+ return new Response(`${GENERIC_ERROR_MESSAGE} (digest ${digest})`, { status: 500 });
500
+ }
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
  }