@webjsdev/server 0.8.57 → 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.
- package/index.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
|
@@ -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:
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
* `
|
|
169
|
-
* comma-chain
|
|
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 (
|
|
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).
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
|
|
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
|
}
|