@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/index.d.ts +125 -12
- package/index.js +2 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +438 -70
- package/src/actions.js +10 -2
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/component-elision.js +75 -16
- package/src/component-scanner.js +82 -26
- 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 +151 -33
- package/src/elision-differential.js +101 -0
- package/src/elision-report.js +133 -30
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +666 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/js-scan.js +41 -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 +93 -17
- 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,666 @@
|
|
|
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
|
+
* Fingerprints already reported this process, keyed `CODE METHOD <route>`.
|
|
219
|
+
* Capped, and never cleared.
|
|
220
|
+
*
|
|
221
|
+
* Both signals below are reachable by anyone: an empty urlencoded POST to any
|
|
222
|
+
* page path, or `?__webjs_action=x` appended to any url. Reporting every hit
|
|
223
|
+
* would turn a public endpoint into a free amplifier into a paid APM sink, and
|
|
224
|
+
* an app needs to learn the SHAPE exists rather than count it (a real bug
|
|
225
|
+
* reproduces on the next boot).
|
|
226
|
+
*
|
|
227
|
+
* The key is the matched ROUTE PATTERN, not the request pathname, and that is
|
|
228
|
+
* load-bearing rather than cosmetic. Keyed on the pathname, a dynamic route
|
|
229
|
+
* yields unbounded distinct keys, so a few hundred crafted requests to
|
|
230
|
+
* `/blog/1`, `/blog/2`, … would fill the cap and permanently silence the
|
|
231
|
+
* diagnostics for the process, which is a worse failure than the amplification
|
|
232
|
+
* the cap exists to stop. Keyed on the pattern, all of those collapse to one
|
|
233
|
+
* entry and the key space is bounded by the app's own route count, which leaves
|
|
234
|
+
* the cap as a backstop no real app reaches. The code is part of the key too, so
|
|
235
|
+
* a flood of one signal can never silence the other.
|
|
236
|
+
*
|
|
237
|
+
* @type {Set<string>}
|
|
238
|
+
*/
|
|
239
|
+
const reportedFormFingerprints = new Set();
|
|
240
|
+
const FINGERPRINT_CAP = 256;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {string} key
|
|
244
|
+
* @returns {boolean} true the first time only, and false once the cap is hit
|
|
245
|
+
*/
|
|
246
|
+
function firstSighting(key) {
|
|
247
|
+
if (reportedFormFingerprints.has(key)) return false;
|
|
248
|
+
if (reportedFormFingerprints.size >= FINGERPRINT_CAP) return false;
|
|
249
|
+
reportedFormFingerprints.add(key);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The matched route's identity for the dedupe key: its page FILE, which is one
|
|
255
|
+
* per route and reads better in a report than a serialized RegExp (`pattern` is
|
|
256
|
+
* a RegExp, not a printable path). Falls back to the request pathname only when
|
|
257
|
+
* a caller has no route in hand.
|
|
258
|
+
*
|
|
259
|
+
* @param {{ file?: string } | null | undefined} route
|
|
260
|
+
* @param {URL} url
|
|
261
|
+
* @returns {string}
|
|
262
|
+
*/
|
|
263
|
+
function routeKeyOf(route, url) {
|
|
264
|
+
return (route && route.file) || url.pathname;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reset the per-process report dedupe. Test seam only: the cap and the
|
|
269
|
+
* never-cleared set are the point in production.
|
|
270
|
+
*/
|
|
271
|
+
export function resetFormReportDedupe() {
|
|
272
|
+
reportedFormFingerprints.clear();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A form posted to a page and carried no action identity, so nothing ran and
|
|
277
|
+
* the answer is a 405. Route it to the APM sink with a code an app can group
|
|
278
|
+
* on, instead of leaving an anonymous 405 in an access log.
|
|
279
|
+
*
|
|
280
|
+
* Carries the field NAMES, which are template constants and are what identify
|
|
281
|
+
* WHICH form posted nowhere. Never the values, which are user data. The form's
|
|
282
|
+
* own `action` attribute is not carried because a bound form has none (the
|
|
283
|
+
* renderer strips it so the form posts to its own url), so the request url
|
|
284
|
+
* already is that information.
|
|
285
|
+
*
|
|
286
|
+
* @param {URL} url
|
|
287
|
+
* @param {Request} req
|
|
288
|
+
* @param {FormData} formData
|
|
289
|
+
* @param {((error: unknown) => void) | undefined} onError
|
|
290
|
+
* @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
|
|
291
|
+
* @param {boolean} [dev]
|
|
292
|
+
* @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
|
|
293
|
+
*/
|
|
294
|
+
export function reportFormActionMissing(url, req, formData, onError, logger, dev, route) {
|
|
295
|
+
const willLog = !!(dev && logger && logger.warn);
|
|
296
|
+
const willReport = typeof onError === 'function';
|
|
297
|
+
// Nothing to say: do not spend a dedupe slot, which would let a run with no
|
|
298
|
+
// sink configured quietly consume the cap for one that does.
|
|
299
|
+
if (!willLog && !willReport) return;
|
|
300
|
+
if (!firstSighting(`WEBJS_FORM_ACTION_MISSING ${req.method} ${routeKeyOf(route, url)}`)) return;
|
|
301
|
+
const fields = [...new Set([...formData.keys()])];
|
|
302
|
+
if (willLog) {
|
|
303
|
+
logger.warn(
|
|
304
|
+
`[webjs] a form posted to ${url.pathname} carrying no action identity, so nothing ran (405). Bind the action: <form action=\${yourAction}>.`,
|
|
305
|
+
{ fields },
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (!willReport) return;
|
|
309
|
+
const err = new Error(
|
|
310
|
+
`A form submission to ${url.pathname} carried no \`${FORM_ACTION_FIELD}\` identity, so no server action ran and the request was answered with a 405.`,
|
|
311
|
+
);
|
|
312
|
+
/** @type {any} */ (err).code = 'WEBJS_FORM_ACTION_MISSING';
|
|
313
|
+
/** @type {any} */ (err).method = req.method;
|
|
314
|
+
/** @type {any} */ (err).pathname = url.pathname;
|
|
315
|
+
/** @type {any} */ (err).fields = fields;
|
|
316
|
+
onError(err);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A page GET carrying `__webjs_action` in the QUERY STRING (#1307). Nothing in
|
|
321
|
+
* this framework ever puts the reserved field in a url, so a submission
|
|
322
|
+
* carrying a bound action's identity went out as a GET: the identity rode the
|
|
323
|
+
* query string (a browser does this natively, and `performSubmission` promotes
|
|
324
|
+
* a safe-method body the same way), no action ran, and the page is about to
|
|
325
|
+
* render as if nothing was submitted.
|
|
326
|
+
*
|
|
327
|
+
* A bound submitter no longer produces this by accident, because it carries its
|
|
328
|
+
* own `formmethod="post"`. What reaches here now is an explicit override the
|
|
329
|
+
* author wrote and the renderer deliberately honours: a `formmethod="get"` on
|
|
330
|
+
* the pressed button, or on the form, wins by native precedence. This is the
|
|
331
|
+
* production counterpart to the dev-time client guard, which reports the same
|
|
332
|
+
* shape at submit time.
|
|
333
|
+
*
|
|
334
|
+
* DETECTS ONLY. The GET keeps rendering its 200 page, because answering
|
|
335
|
+
* differently on a query parameter would hand any visitor a way to turn any
|
|
336
|
+
* page into an error.
|
|
337
|
+
*
|
|
338
|
+
* @param {URL} url
|
|
339
|
+
* @param {Request} req
|
|
340
|
+
* @param {((error: unknown) => void) | undefined} onError
|
|
341
|
+
* @param {{ warn?: (msg: string, meta?: Record<string, unknown>) => void }} [logger]
|
|
342
|
+
* @param {boolean} [dev]
|
|
343
|
+
* @param {{ file?: string } | null} [route] the matched page route, for the dedupe key
|
|
344
|
+
*/
|
|
345
|
+
export function reportFormSubmittedAsGet(url, req, onError, logger, dev, route) {
|
|
346
|
+
// Cheapest possible guard first, and it runs on EVERY page GET: a substring
|
|
347
|
+
// test on the already-parsed `search` string, so the common case never builds
|
|
348
|
+
// a URLSearchParams at all.
|
|
349
|
+
if (!url.search || !url.search.includes(FORM_ACTION_FIELD)) return;
|
|
350
|
+
if (!url.searchParams.has(FORM_ACTION_FIELD)) return;
|
|
351
|
+
const willLog = !!(dev && logger && logger.warn);
|
|
352
|
+
const willReport = typeof onError === 'function';
|
|
353
|
+
if (!willLog && !willReport) return;
|
|
354
|
+
if (!firstSighting(`WEBJS_FORM_SUBMITTED_AS_GET ${req.method} ${routeKeyOf(route, url)}`)) return;
|
|
355
|
+
if (willLog) {
|
|
356
|
+
logger.warn(
|
|
357
|
+
`[webjs] ${url.pathname} was requested with \`${FORM_ACTION_FIELD}\` in the query string, which means a submission carrying a bound action's identity went out as a GET. A GET sends no body, so the identity rode the url, the action never ran, and this page is simply re-rendering. Look for a formmethod="get" on the button that was pressed, or a method="get" on its form: a submitter's own formmethod wins by native precedence and WebJs honours it rather than refusing it.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (!willReport) return;
|
|
361
|
+
const err = new Error(
|
|
362
|
+
`A form submission reached ${url.pathname} as a GET with the \`${FORM_ACTION_FIELD}\` identity in the query string, so no server action ran. The submitter's enclosing <form> binds no action.`,
|
|
363
|
+
);
|
|
364
|
+
/** @type {any} */ (err).code = 'WEBJS_FORM_SUBMITTED_AS_GET';
|
|
365
|
+
/** @type {any} */ (err).method = req.method;
|
|
366
|
+
/** @type {any} */ (err).pathname = url.pathname;
|
|
367
|
+
onError(err);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The submitted TEXT fields as a plain record, for repopulating a form the
|
|
372
|
+
* dispatcher could not run.
|
|
373
|
+
*
|
|
374
|
+
* Files are skipped: a `File` is not a string, and no browser lets a page
|
|
375
|
+
* refill a file input anyway, so carrying one here would only put an object
|
|
376
|
+
* where the page expects text.
|
|
377
|
+
*
|
|
378
|
+
* @param {FormData} formData
|
|
379
|
+
* @returns {Record<string, string>}
|
|
380
|
+
*/
|
|
381
|
+
function textValuesOf(formData) {
|
|
382
|
+
/** @type {Record<string, string>} */
|
|
383
|
+
const out = {};
|
|
384
|
+
for (const [k, v] of formData) if (typeof v === 'string') out[k] = v;
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A page path exists but only renders: the method is the problem, not the url.
|
|
390
|
+
* @returns {Response}
|
|
391
|
+
*/
|
|
392
|
+
function methodNotAllowed() {
|
|
393
|
+
return new Response('Method Not Allowed', {
|
|
394
|
+
status: 405,
|
|
395
|
+
headers: { allow: 'GET, HEAD' },
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Run a bound form action for a non-GET/HEAD request to a page's own url and
|
|
401
|
+
* produce the HTTP response. The caller has already matched the path to a page
|
|
402
|
+
* route and confirmed the request carries a form body; this runs inside the
|
|
403
|
+
* page's segment middleware (the caller wraps it).
|
|
404
|
+
*
|
|
405
|
+
* @param {import('./router.js').PageRoute} route
|
|
406
|
+
* @param {Record<string,string>} params
|
|
407
|
+
* @param {URL} url
|
|
408
|
+
* @param {Request} req
|
|
409
|
+
* @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
|
|
410
|
+
* @param {{
|
|
411
|
+
* actionIndex: import('./actions.js').ActionIndex,
|
|
412
|
+
* allowedOrigins?: string[],
|
|
413
|
+
* onError?: (error: unknown) => void,
|
|
414
|
+
* logger?: { warn?: (msg: string, meta?: Record<string, unknown>) => void },
|
|
415
|
+
* }} deps
|
|
416
|
+
* @returns {Promise<Response>}
|
|
417
|
+
*/
|
|
418
|
+
export async function runFormAction(route, params, url, req, ssrOpts, deps) {
|
|
419
|
+
const { actionIndex, allowedOrigins = [], onError, logger } = deps;
|
|
420
|
+
|
|
421
|
+
// Not a form body at all (a stray JSON POST, a probe): the page path exists
|
|
422
|
+
// and only renders. Answered before the body is touched.
|
|
423
|
+
if (!looksLikeFormSubmission(req)) return methodNotAllowed();
|
|
424
|
+
|
|
425
|
+
// CSRF, before the body is read and before anything runs. Same Origin /
|
|
426
|
+
// Sec-Fetch-Site check the RPC endpoint applies (`csrf.js`). The page `action`
|
|
427
|
+
// export this replaces had none, and was shielded only by SameSite=Lax cookies.
|
|
428
|
+
const origin = verifyOrigin(req, allowedOrigins);
|
|
429
|
+
if (!origin.ok) return new Response('CSRF validation failed', { status: 403 });
|
|
430
|
+
|
|
431
|
+
let formData = new FormData();
|
|
432
|
+
let actionReq = req;
|
|
433
|
+
try {
|
|
434
|
+
const parsed = await parseFormBody(req);
|
|
435
|
+
// Over the form/multipart limit (issue #237): 413 before anything runs.
|
|
436
|
+
if (parsed.tooLarge) return payloadTooLarge();
|
|
437
|
+
formData = parsed.formData;
|
|
438
|
+
actionReq = parsed.request;
|
|
439
|
+
} catch {
|
|
440
|
+
formData = new FormData();
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const actions = formData.getAll(FORM_ACTION_FIELD);
|
|
444
|
+
const id = actions.length ? actions[actions.length - 1] : null;
|
|
445
|
+
// A form body carrying no identity: a hand-written `<form method="post">`
|
|
446
|
+
// that binds no action. Nothing to run, and the page only renders. Reported
|
|
447
|
+
// before the 405 so the shape is not just an anonymous status in an access
|
|
448
|
+
// log.
|
|
449
|
+
//
|
|
450
|
+
// NOT the unparseable-enctype variant of #1307. An `enctype="text/plain"`
|
|
451
|
+
// submission arrives with that content type, so `looksLikeFormSubmission`
|
|
452
|
+
// answers it above, before the body is read and before this point. That is
|
|
453
|
+
// deliberate (buffering an attacker-chosen body to classify it is exactly what
|
|
454
|
+
// the early return avoids), and it means the text/plain variant is visible
|
|
455
|
+
// only as a bare 405. The dev-time client guard covers it instead.
|
|
456
|
+
if (typeof id !== 'string' || !id) {
|
|
457
|
+
reportFormActionMissing(url, req, formData, onError, logger, !!ssrOpts.dev, route);
|
|
458
|
+
return methodNotAllowed();
|
|
459
|
+
}
|
|
460
|
+
// The field is framework wire, not app data. Removing it keeps an action
|
|
461
|
+
// that iterates the FormData (building a record, echoing values back into a
|
|
462
|
+
// 422 re-render) from seeing a key it did not put there.
|
|
463
|
+
formData.delete(FORM_ACTION_FIELD);
|
|
464
|
+
|
|
465
|
+
const found = await lookupActionIdentity(actionIndex, id);
|
|
466
|
+
if (!found.ok) {
|
|
467
|
+
// The action's module threw at import (a bad env var read at module scope,
|
|
468
|
+
// a syntax error in dev). That is a server fault, not skew: telling the
|
|
469
|
+
// user to resubmit would loop them forever against a module that cannot
|
|
470
|
+
// load, and swallowing the error would leave nothing in the log or the APM
|
|
471
|
+
// sink. Surface it the way any other action throw is surfaced.
|
|
472
|
+
if (found.reason === 'load-failed') {
|
|
473
|
+
if (typeof onError === 'function') onError(found.error);
|
|
474
|
+
return await formActionErrorResponse(found.error, ssrOpts.dev);
|
|
475
|
+
}
|
|
476
|
+
if (found.reason === 'skew') {
|
|
477
|
+
return ssrPage(route, params, url, {
|
|
478
|
+
...ssrOpts, req,
|
|
479
|
+
// The submitted values ride along on the standard `actionData.values`
|
|
480
|
+
// key, so the page's ordinary repopulation idiom refills the form and
|
|
481
|
+
// "please submit again" is something a person can actually act on
|
|
482
|
+
// rather than an instruction to retype everything.
|
|
483
|
+
actionData: { success: false, error: ACTION_SKEW_MESSAGE, values: textValuesOf(formData) },
|
|
484
|
+
status: 422,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
return new Response('Unknown action', { status: 404 });
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const { fnName, module: mod } = found;
|
|
491
|
+
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
492
|
+
// A reserved config export is never a callable action even though some are
|
|
493
|
+
// functions (#488), so a forged `__webjs_action` naming one is a 404 rather
|
|
494
|
+
// than a way to invoke `validate` directly.
|
|
495
|
+
if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
|
|
496
|
+
return new Response('Unknown action', { status: 404 });
|
|
497
|
+
}
|
|
498
|
+
// A GET-declared action cannot be a form target: it is CSRF-exempt and rides
|
|
499
|
+
// its args in the url, so binding one to a POST form is a contradiction. A
|
|
500
|
+
// `webjs check` rule catches it at edit time; this is the runtime backstop.
|
|
501
|
+
if (actionMethod(mod) === 'GET') {
|
|
502
|
+
return new Response(
|
|
503
|
+
`The action ${fnName} declares method = 'GET', so it cannot be bound to a form.`,
|
|
504
|
+
{ status: 405, headers: { allow: 'GET' } },
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// A form-bound action always receives the `FormData` (#1155). `validate` is
|
|
509
|
+
// the typing seam: it takes the FormData and its transform-return becomes the
|
|
510
|
+
// action's typed input, the same `runValidate` contract the RPC path uses.
|
|
511
|
+
let args = [formData];
|
|
512
|
+
const validate = actionConfigFn(mod, 'validate');
|
|
513
|
+
if (typeof validate === 'function') {
|
|
514
|
+
const v = runValidate(validate, args[0]);
|
|
515
|
+
if (!v.ok) {
|
|
516
|
+
if (v.thrown !== undefined) {
|
|
517
|
+
// A THROWN validator is answered here rather than rethrown. Letting it
|
|
518
|
+
// escape reaches the handler's last-resort catch, which reports the
|
|
519
|
+
// SAME error to `onError` a second time and answers a bare 500 with no
|
|
520
|
+
// digest, so the one thing that makes a prod error diagnosable is lost.
|
|
521
|
+
// The RPC path does exactly this (`actionErrorResponse` in actions.js).
|
|
522
|
+
if (typeof onError === 'function') onError(v.thrown);
|
|
523
|
+
return await formActionErrorResponse(v.thrown, ssrOpts.dev);
|
|
524
|
+
}
|
|
525
|
+
// A structured failure envelope is a NORMAL result the page renders, so
|
|
526
|
+
// it takes the 422 re-render path exactly as a failing action would.
|
|
527
|
+
return ssrPage(route, params, url, { ...ssrOpts, req, actionData: v.result, status: 422 });
|
|
528
|
+
}
|
|
529
|
+
args = [v.value];
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
533
|
+
/** @type {ActionResult | undefined} */
|
|
534
|
+
let result;
|
|
535
|
+
let ranAction = false;
|
|
536
|
+
try {
|
|
537
|
+
const middleware = actionMiddleware(mod);
|
|
538
|
+
result = await runWithActionSignal(actionReq.signal, () =>
|
|
539
|
+
runActionChain(
|
|
540
|
+
middleware,
|
|
541
|
+
{
|
|
542
|
+
request: actionReq,
|
|
543
|
+
args,
|
|
544
|
+
signal: actionReq.signal,
|
|
545
|
+
// Route context a form middleware may want, absent on the RPC path
|
|
546
|
+
// because an RPC call has no page. Params are awaitable AND
|
|
547
|
+
// sync-readable, matching a page render (#848).
|
|
548
|
+
params: makeThenable(params),
|
|
549
|
+
searchParams: makeThenable(searchParams),
|
|
550
|
+
url,
|
|
551
|
+
},
|
|
552
|
+
() => { ranAction = true; return fn(...args); },
|
|
553
|
+
));
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (isRedirect(err)) {
|
|
556
|
+
const e = /** @type any */ (err);
|
|
557
|
+
// A thrown redirect from an action (a POST) defaults to 307 Temporary
|
|
558
|
+
// Redirect, which is method-preserving so the action's intent survives
|
|
559
|
+
// the bounce; an explicit `redirect(url, status)` overrides it. This is
|
|
560
|
+
// deliberately NOT the GET gate's 302 default (see ssr.js). PRG (303) is
|
|
561
|
+
// the SUCCESS-result path below.
|
|
562
|
+
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
|
|
563
|
+
}
|
|
564
|
+
if (isNotFound(err)) {
|
|
565
|
+
return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
|
|
566
|
+
}
|
|
567
|
+
// forbidden()/unauthorized() from a form action render the same 403/401
|
|
568
|
+
// boundary as the page-render path (#848), not a generic 500.
|
|
569
|
+
if (isForbidden(err)) return ssrForbidden(route, { ...ssrOpts, req, url });
|
|
570
|
+
if (isUnauthorized(err)) return ssrUnauthorized(route, { ...ssrOpts, req, url });
|
|
571
|
+
if (typeof onError === 'function') onError(err);
|
|
572
|
+
return await formActionErrorResponse(err, ssrOpts.dev);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A streamed return (#489) has no consumer here. The RPC stub decodes frames;
|
|
576
|
+
// a form submission is answered with a redirect or a page, and with JS off
|
|
577
|
+
// there is nothing on the other end at all. Refusing loudly beats emitting a
|
|
578
|
+
// frame stream a browser renders as garbage.
|
|
579
|
+
if (ranAction && isStreamable(result)) {
|
|
580
|
+
const err = new Error(
|
|
581
|
+
`The action ${fnName} returned a stream, which a form submission cannot consume. `
|
|
582
|
+
+ 'Return an ActionResult from a form-bound action; stream from a programmatic call instead.',
|
|
583
|
+
);
|
|
584
|
+
if (typeof onError === 'function') onError(err);
|
|
585
|
+
return await formActionErrorResponse(err, ssrOpts.dev);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Only a COMPLETED action evicts its `invalidates` tags; a middleware
|
|
589
|
+
// short-circuit (the action never ran) does not.
|
|
590
|
+
//
|
|
591
|
+
// The tags are also REPORTED, the way `invokeAction` reports them, so the
|
|
592
|
+
// browser-side tag coordinator can bypass a stale cached GET. One reach
|
|
593
|
+
// limit, stated because it looks like an oversight: `fetch` follows the 303
|
|
594
|
+
// transparently, so JS cannot read the headers of a redirect response. The
|
|
595
|
+
// header is on the wire and the 422 re-render path carries it to the router;
|
|
596
|
+
// after a successful PRG the client learns nothing from it. The redirect's
|
|
597
|
+
// own re-render is server-side and seeds fresh data, so what remains exposed
|
|
598
|
+
// is only a later client refetch of a separately cached GET key.
|
|
599
|
+
let invalidated = [];
|
|
600
|
+
if (ranAction) {
|
|
601
|
+
invalidated = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
602
|
+
if (invalidated.length) await revalidateTags(invalidated);
|
|
603
|
+
}
|
|
604
|
+
const invalidateHeader = invalidated.length
|
|
605
|
+
? { 'x-webjs-invalidate': invalidated.join(',') }
|
|
606
|
+
: null;
|
|
607
|
+
|
|
608
|
+
// An action MAY return a `Response` directly (e.g. a content-negotiated
|
|
609
|
+
// `streamResponse`, #248). Honor it verbatim, so the action owns the status +
|
|
610
|
+
// content type and the router applies it (a stream body surgically). With JS
|
|
611
|
+
// off the same action returns a normal ActionResult instead, so the PRG /
|
|
612
|
+
// re-render paths below still drive the no-JS form.
|
|
613
|
+
if (result instanceof Response) return result;
|
|
614
|
+
|
|
615
|
+
if (!isFailureResult(result)) {
|
|
616
|
+
// SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
|
|
617
|
+
// honored when it is a same-site local path; otherwise fall back to the
|
|
618
|
+
// page's own path so a poisoned value cannot become an open redirect.
|
|
619
|
+
const ownPath = (url.pathname + url.search) || '/';
|
|
620
|
+
const safe = result ? sameSiteRedirect(result.redirect) : null;
|
|
621
|
+
return new Response(null, {
|
|
622
|
+
status: 303,
|
|
623
|
+
headers: { location: safe || ownPath, ...invalidateHeader },
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// FAILURE: re-render the SAME page with the action result available on
|
|
628
|
+
// ctx.actionData, status 422. Repopulation is the page author's job (native
|
|
629
|
+
// `value=${actionData.values?.field}`).
|
|
630
|
+
const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
|
|
631
|
+
const page = await ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status });
|
|
632
|
+
// A failure result can still have mutated (a partial write, then a rejected
|
|
633
|
+
// second step), so the eviction above applies here too and this response, not
|
|
634
|
+
// being a redirect, is one the router can actually read the header off.
|
|
635
|
+
if (invalidateHeader && page instanceof Response) {
|
|
636
|
+
for (const [k, v] of Object.entries(invalidateHeader)) page.headers.set(k, v);
|
|
637
|
+
}
|
|
638
|
+
return page;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* A thrown form action becomes a 500 whose body is sanitized the same way the
|
|
643
|
+
* RPC path's is (#749): dev shows the real message, prod shows a generic one
|
|
644
|
+
* plus a correlation digest and logs the full error. A raw driver message or an
|
|
645
|
+
* fs path is not author-controlled, so it does not reach the browser, and here
|
|
646
|
+
* the browser is showing it to a person rather than parsing it.
|
|
647
|
+
*
|
|
648
|
+
* @param {unknown} err
|
|
649
|
+
* @param {boolean} [dev]
|
|
650
|
+
* @returns {Promise<Response>}
|
|
651
|
+
*/
|
|
652
|
+
async function formActionErrorResponse(err, dev) {
|
|
653
|
+
if (dev) {
|
|
654
|
+
console.error('[webjs] form action threw:', err);
|
|
655
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
656
|
+
return new Response(msg, { status: 500 });
|
|
657
|
+
}
|
|
658
|
+
if (isControlFlowThrow(err)) {
|
|
659
|
+
console.error('[webjs] form action threw:', err);
|
|
660
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
661
|
+
return new Response(msg, { status: 500 });
|
|
662
|
+
}
|
|
663
|
+
const digest = await errorDigest(err);
|
|
664
|
+
console.error(`[webjs] form action threw [digest=${digest}]:`, err);
|
|
665
|
+
return new Response(`${GENERIC_ERROR_MESSAGE} (digest ${digest})`, { status: 500 });
|
|
666
|
+
}
|