@webjsdev/server 0.8.8 → 0.8.10
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.js +3 -0
- package/package.json +1 -1
- package/src/actions.js +37 -5
- package/src/api.js +16 -1
- package/src/auth.js +18 -3
- package/src/body-limit.js +291 -0
- package/src/check.js +41 -350
- package/src/context.js +66 -16
- package/src/cors.js +245 -0
- package/src/csp.js +218 -0
- package/src/dev.js +215 -10
- package/src/env-schema.js +250 -0
- package/src/headers.js +213 -0
- package/src/json.js +11 -2
- package/src/node-version.js +102 -0
- package/src/page-action.js +225 -0
- package/src/ssr.js +41 -23
package/src/headers.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure-by-default response headers plus a small declarative per-path
|
|
3
|
+
* header config (issue #232).
|
|
4
|
+
*
|
|
5
|
+
* webjs sets a baseline of standard security headers on every document
|
|
6
|
+
* and asset response, so a scaffolded app is not clickjackable or
|
|
7
|
+
* MIME-sniffable out of the box (no reverse proxy required for the
|
|
8
|
+
* baseline). The defaults are LITERAL HTTP headers, no abstraction.
|
|
9
|
+
*
|
|
10
|
+
* An app can ADD, OVERRIDE, or DISABLE any header per path via a
|
|
11
|
+
* declarative config (`package.json` -> `webjs.headers`), shaped like
|
|
12
|
+
* Next's: an array of `{ source, headers: [{ key, value }] }` where
|
|
13
|
+
* `source` is a path pattern matched with the native URLPattern API.
|
|
14
|
+
*
|
|
15
|
+
* Precedence, lowest to highest:
|
|
16
|
+
* 1. secure defaults
|
|
17
|
+
* 2. path config (webjs.headers) overrides/adds/removes a default
|
|
18
|
+
* 3. app middleware (already on the Response when we merge)
|
|
19
|
+
*
|
|
20
|
+
* In other words middleware wins over the path config, which wins over
|
|
21
|
+
* the defaults. We only ever ADD a header the response does not already
|
|
22
|
+
* carry, so a header an app set (in middleware, a route handler, or via
|
|
23
|
+
* `expose`) is never clobbered. A path-config entry with a null/empty
|
|
24
|
+
* value REMOVES a header (e.g. drop a default on a public embed route).
|
|
25
|
+
*
|
|
26
|
+
* This is the connective tissue #233 (CSP) and #234 (CORS) plug into
|
|
27
|
+
* later: the merge seam (applySecurityHeaders) is the single place a
|
|
28
|
+
* future per-path policy layers onto a Response, so neither needs to
|
|
29
|
+
* touch the response pipeline again.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Baseline security headers, as literal name -> value pairs. Set only
|
|
34
|
+
* when absent, so an app override always wins. HSTS is NOT here: it is
|
|
35
|
+
* conditional (production + HTTPS) and added separately.
|
|
36
|
+
*
|
|
37
|
+
* @type {ReadonlyArray<[string, string]>}
|
|
38
|
+
*/
|
|
39
|
+
const SECURE_DEFAULTS = [
|
|
40
|
+
['X-Content-Type-Options', 'nosniff'],
|
|
41
|
+
['X-Frame-Options', 'SAMEORIGIN'],
|
|
42
|
+
['Referrer-Policy', 'strict-origin-when-cross-origin'],
|
|
43
|
+
['Permissions-Policy', 'camera=(), microphone=(), geolocation=()'],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The standard HSTS posture: two years, include subdomains. */
|
|
47
|
+
const HSTS_VALUE = 'max-age=63072000; includeSubDomains';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the per-path header config from the app's package.json
|
|
51
|
+
* (`webjs.headers`). Returns a normalized array of compiled rules, each
|
|
52
|
+
* pairing a URLPattern against the configured header directives. A
|
|
53
|
+
* malformed or absent config yields an empty array (no per-path rules),
|
|
54
|
+
* never a throw: a broken config must not take the app down.
|
|
55
|
+
*
|
|
56
|
+
* Shape consumed:
|
|
57
|
+
* "webjs": { "headers": [ { "source": "/embed/:path*",
|
|
58
|
+
* "headers": [ { "key": "X-Frame-Options", "value": null } ] } ] }
|
|
59
|
+
*
|
|
60
|
+
* A `value` of null, undefined, or false REMOVES the header on a match
|
|
61
|
+
* (the disable-a-default escape hatch). Any other value is stringified
|
|
62
|
+
* and SET (override or add).
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} pkg parsed package.json (or any object)
|
|
65
|
+
* @returns {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>}
|
|
66
|
+
*/
|
|
67
|
+
export function compileHeaderRules(pkg) {
|
|
68
|
+
const raw =
|
|
69
|
+
pkg &&
|
|
70
|
+
typeof pkg === 'object' &&
|
|
71
|
+
/** @type {any} */ (pkg).webjs &&
|
|
72
|
+
/** @type {any} */ (pkg).webjs.headers;
|
|
73
|
+
if (!Array.isArray(raw)) return [];
|
|
74
|
+
/** @type {Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>} */
|
|
75
|
+
const rules = [];
|
|
76
|
+
for (const entry of raw) {
|
|
77
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
78
|
+
const source = /** @type {any} */ (entry).source;
|
|
79
|
+
const list = /** @type {any} */ (entry).headers;
|
|
80
|
+
if (typeof source !== 'string' || !Array.isArray(list)) continue;
|
|
81
|
+
let pattern;
|
|
82
|
+
try {
|
|
83
|
+
// Match on the pathname only. A bare path string is the common
|
|
84
|
+
// Next-style usage; URLPattern treats it as the pathname component.
|
|
85
|
+
pattern = new URLPattern({ pathname: source });
|
|
86
|
+
} catch {
|
|
87
|
+
continue; // skip an invalid pattern rather than crash the request
|
|
88
|
+
}
|
|
89
|
+
/** @type {Array<{ key: string, value: string | null }>} */
|
|
90
|
+
const directives = [];
|
|
91
|
+
for (const d of list) {
|
|
92
|
+
if (!d || typeof d !== 'object') continue;
|
|
93
|
+
const key = /** @type {any} */ (d).key;
|
|
94
|
+
if (typeof key !== 'string' || !key) continue;
|
|
95
|
+
const v = /** @type {any} */ (d).value;
|
|
96
|
+
// null / undefined / false means REMOVE the header on a match.
|
|
97
|
+
const value = v === null || v === undefined || v === false ? null : String(v);
|
|
98
|
+
// Validate the key/value against the real Headers parser at COMPILE
|
|
99
|
+
// time (consistent with dropping a bad `source`). A name or value
|
|
100
|
+
// that Headers rejects (an invalid header name, or a value carrying
|
|
101
|
+
// CR/LF) would otherwise make `applySecurityHeaders` THROW on every
|
|
102
|
+
// matching request, a self-inflicted 500, which breaks this file's
|
|
103
|
+
// "a broken config must not take the app down" guarantee. Probe on a
|
|
104
|
+
// throwaway Headers and DROP the directive if it throws. For a delete
|
|
105
|
+
// (value null) only the key needs to be a valid header name, so probe
|
|
106
|
+
// it with a placeholder value.
|
|
107
|
+
try {
|
|
108
|
+
new Headers().set(key, value === null ? 'x' : value);
|
|
109
|
+
} catch {
|
|
110
|
+
// eslint-disable-next-line no-console
|
|
111
|
+
console.warn(`[webjs] dropping invalid webjs.headers directive for key "${key}"`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
directives.push({ key, value });
|
|
115
|
+
}
|
|
116
|
+
if (directives.length) rules.push({ pattern, directives });
|
|
117
|
+
}
|
|
118
|
+
return rules;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Apply the security defaults and the per-path config to a Response,
|
|
123
|
+
* returning a Response carrying the merged headers. The input Response
|
|
124
|
+
* is not mutated; a new Headers is derived from it so the body/status
|
|
125
|
+
* are preserved by reference (no body copy).
|
|
126
|
+
*
|
|
127
|
+
* Headers already on the response (set by app middleware, a route
|
|
128
|
+
* handler, or `expose`) are treated as authoritative and never
|
|
129
|
+
* overwritten by a default. The per-path config runs AFTER the defaults
|
|
130
|
+
* but BEFORE that "already present" rule is consulted for middleware:
|
|
131
|
+
* the config can override a default freely (it is the app's own
|
|
132
|
+
* declarative intent), while middleware still wins because its headers
|
|
133
|
+
* are on the response before we are even called.
|
|
134
|
+
*
|
|
135
|
+
* @param {Response} res the response produced by the app pipeline
|
|
136
|
+
* @param {{
|
|
137
|
+
* pathname: string,
|
|
138
|
+
* https: boolean,
|
|
139
|
+
* prod: boolean,
|
|
140
|
+
* rules?: Array<{ pattern: URLPattern, directives: Array<{ key: string, value: string | null }> }>,
|
|
141
|
+
* }} ctx
|
|
142
|
+
* @returns {Response}
|
|
143
|
+
*/
|
|
144
|
+
export function applySecurityHeaders(res, ctx) {
|
|
145
|
+
const headers = new Headers(res.headers);
|
|
146
|
+
// Snapshot the keys the app already set, so a default never clobbers
|
|
147
|
+
// them. Captured BEFORE we add anything. Lowercased for compare;
|
|
148
|
+
// Headers itself is case-insensitive.
|
|
149
|
+
const appSet = new Set();
|
|
150
|
+
headers.forEach((_v, k) => appSet.add(k.toLowerCase()));
|
|
151
|
+
|
|
152
|
+
// 1. Secure defaults: set only if the app did not already set them.
|
|
153
|
+
for (const [k, v] of SECURE_DEFAULTS) {
|
|
154
|
+
if (!appSet.has(k.toLowerCase())) headers.set(k, v);
|
|
155
|
+
}
|
|
156
|
+
// HSTS only in production AND only over HTTPS (never on a plain-HTTP
|
|
157
|
+
// hop). Same "do not clobber" rule.
|
|
158
|
+
if (ctx.prod && ctx.https && !appSet.has('strict-transport-security')) {
|
|
159
|
+
headers.set('Strict-Transport-Security', HSTS_VALUE);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 2. Per-path config: override / add / remove. Runs over the merged
|
|
163
|
+
// set so it can replace a default this same call just added. It does
|
|
164
|
+
// NOT override a header the app set in middleware (appSet), preserving
|
|
165
|
+
// the middleware-wins precedence.
|
|
166
|
+
const rules = ctx.rules || [];
|
|
167
|
+
for (const rule of rules) {
|
|
168
|
+
if (!rule.pattern.test({ pathname: ctx.pathname })) continue;
|
|
169
|
+
for (const { key, value } of rule.directives) {
|
|
170
|
+
if (appSet.has(key.toLowerCase())) continue; // middleware wins
|
|
171
|
+
// Belt and suspenders: compileHeaderRules already validated the
|
|
172
|
+
// key/value, so this never throws in practice, but a surprise from a
|
|
173
|
+
// directive constructed elsewhere must never throw the response
|
|
174
|
+
// pipeline. Skip the bad directive instead.
|
|
175
|
+
try {
|
|
176
|
+
if (value === null) headers.delete(key);
|
|
177
|
+
else headers.set(key, value);
|
|
178
|
+
} catch {
|
|
179
|
+
/* skip a directive the Headers parser rejects */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return new Response(res.body, {
|
|
185
|
+
status: res.status,
|
|
186
|
+
statusText: res.statusText,
|
|
187
|
+
headers,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 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.
|
|
198
|
+
*
|
|
199
|
+
* @param {Request} req
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
export function webRequestIsHttps(req) {
|
|
203
|
+
const trust = process.env.WEBJS_NO_TRUST_PROXY !== '1';
|
|
204
|
+
if (trust) {
|
|
205
|
+
const proto = req.headers.get('x-forwarded-proto');
|
|
206
|
+
if (proto) return proto.split(',')[0].trim().toLowerCase() === 'https';
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return new URL(req.url).protocol === 'https:';
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/json.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { stringify as wjStringify, parse as wjParse } from '@webjsdev/core';
|
|
2
|
-
import { getRequest } from './context.js';
|
|
2
|
+
import { getRequest, getBodyLimits } from './context.js';
|
|
3
3
|
import { RPC_CONTENT_TYPE } from './actions.js';
|
|
4
|
+
import { readTextBounded, BodyLimitError, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Content-negotiated JSON helper for API routes (`route.js` handlers).
|
|
@@ -51,11 +52,19 @@ export async function json(data, init = {}) {
|
|
|
51
52
|
* that accept rich bodies from the `richFetch` helper but plain JSON
|
|
52
53
|
* from everyone else.
|
|
53
54
|
*
|
|
55
|
+
* Enforces the request body-size limit (issue #237): an over-limit body throws
|
|
56
|
+
* a `BodyLimitError`, which the API dispatcher (`handleApi`) maps to a 413, so a
|
|
57
|
+
* `route.{js,ts}` handler doing `await readBody(req)` is protected with no extra
|
|
58
|
+
* code. The over-limit body is never buffered whole (see `readTextBounded`).
|
|
59
|
+
*
|
|
54
60
|
* @param {Request} req
|
|
55
61
|
*/
|
|
56
62
|
export async function readBody(req) {
|
|
57
63
|
const ct = req.headers.get('content-type') || '';
|
|
58
|
-
const
|
|
64
|
+
const limits = getBodyLimits();
|
|
65
|
+
const limit = limits ? limits.json : DEFAULT_MAX_BODY_BYTES;
|
|
66
|
+
const { tooLarge, text } = await readTextBounded(req, limit);
|
|
67
|
+
if (tooLarge) throw new BodyLimitError();
|
|
59
68
|
if (!text) return null;
|
|
60
69
|
if (ct.includes(RPC_CONTENT_TYPE)) return wjParse(text);
|
|
61
70
|
return JSON.parse(text);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-version preflight guard (issue #238).
|
|
3
|
+
*
|
|
4
|
+
* webjs depends on Node 24+ built-ins: `module.stripTypeScriptTypes` (the
|
|
5
|
+
* no-build TypeScript strip) and recursive `fs.watch` (dev live-reload). On an
|
|
6
|
+
* older Node the failure surfaces late and cryptically (a strip error or a
|
|
7
|
+
* missing API deep inside a request), not as a clear "you need Node 24+". This
|
|
8
|
+
* module is the single early preflight that fails fast with an actionable
|
|
9
|
+
* message naming the exact version found and the version required.
|
|
10
|
+
*
|
|
11
|
+
* The check is a PURE function (`checkNodeVersion`) so it unit-tests with
|
|
12
|
+
* injected version strings, no spawning an old Node. `assertNodeVersion` is the
|
|
13
|
+
* thin side-effecting wrapper the CLI and the server entry both call: it prints
|
|
14
|
+
* to stderr + exits non-zero (CLI), or throws a clear Error (embedded server),
|
|
15
|
+
* depending on the `onFail` mode.
|
|
16
|
+
*
|
|
17
|
+
* The minimum is sourced from ONE place, this package's own `engines.node`
|
|
18
|
+
* field, so it never drifts from what npm enforces on install.
|
|
19
|
+
*/
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the leading major-version integer out of a Node version string.
|
|
24
|
+
* Handles `'24.1.0'`, `'v24.1.0'`, and prerelease tags like
|
|
25
|
+
* `'24.0.0-nightly20240101'`. Returns `NaN` when no leading integer is found.
|
|
26
|
+
* @param {string} version
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function parseMajor(version) {
|
|
30
|
+
const m = String(version).trim().match(/^v?(\d+)/);
|
|
31
|
+
return m ? Number(m[1]) : NaN;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse the minimum major version out of an `engines.node` range like
|
|
36
|
+
* `'>=24.0.0'`, `'>= 24'`, or `'24.x'`. Returns `NaN` when no integer is found.
|
|
37
|
+
* @param {string} engines
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
export function parseRequiredMajor(engines) {
|
|
41
|
+
const m = String(engines).match(/(\d+)/);
|
|
42
|
+
return m ? Number(m[1]) : NaN;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Pure version check. Compares the running Node major against the required
|
|
47
|
+
* minimum and returns a structured result (no side effects).
|
|
48
|
+
* @param {string} current the running Node version (e.g. `process.versions.node`)
|
|
49
|
+
* @param {number} requiredMajor the minimum acceptable major version
|
|
50
|
+
* @returns {{ ok: boolean, current: string, currentMajor: number, requiredMajor: number, message: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function checkNodeVersion(current, requiredMajor) {
|
|
53
|
+
const currentMajor = parseMajor(current);
|
|
54
|
+
// If we cannot parse the running version, fail open: do not block a runtime
|
|
55
|
+
// that reports an unusual version string. The deep APIs guard themselves.
|
|
56
|
+
const ok = Number.isNaN(currentMajor) || currentMajor >= requiredMajor;
|
|
57
|
+
const message = ok
|
|
58
|
+
? ''
|
|
59
|
+
: `webjs requires Node ${requiredMajor}+ but found Node ${current}. ` +
|
|
60
|
+
`webjs is buildless and relies on Node ${requiredMajor}'s built-in ` +
|
|
61
|
+
`TypeScript strip (module.stripTypeScriptTypes) and recursive fs.watch, ` +
|
|
62
|
+
`neither of which exists on Node ${currentMajor}. ` +
|
|
63
|
+
`Upgrade to Node ${requiredMajor} or newer (see https://nodejs.org).`;
|
|
64
|
+
return { ok, current, currentMajor, requiredMajor, message };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the minimum required Node major from this package's own
|
|
69
|
+
* `engines.node` field, so the minimum lives in exactly one place. Falls back
|
|
70
|
+
* to 24 if the field is missing or unparseable (defensive only).
|
|
71
|
+
* @returns {number}
|
|
72
|
+
*/
|
|
73
|
+
export function requiredNodeMajor() {
|
|
74
|
+
try {
|
|
75
|
+
const require = createRequire(import.meta.url);
|
|
76
|
+
const pkg = require('../package.json');
|
|
77
|
+
const major = parseRequiredMajor(pkg?.engines?.node || '');
|
|
78
|
+
if (!Number.isNaN(major)) return major;
|
|
79
|
+
} catch {}
|
|
80
|
+
return 24;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Side-effecting preflight: assert the running Node satisfies the minimum.
|
|
85
|
+
* On an unsupported Node, either exits the process non-zero (CLI, `onFail:
|
|
86
|
+
* 'exit'`) or throws a clear Error (embedded server, `onFail: 'throw'`).
|
|
87
|
+
* A no-op on a supported Node.
|
|
88
|
+
* @param {{ current?: string, requiredMajor?: number, onFail?: 'exit'|'throw' }} [opts]
|
|
89
|
+
* @returns {void}
|
|
90
|
+
*/
|
|
91
|
+
export function assertNodeVersion(opts = {}) {
|
|
92
|
+
const current = opts.current ?? process.versions.node;
|
|
93
|
+
const requiredMajor = opts.requiredMajor ?? requiredNodeMajor();
|
|
94
|
+
const onFail = opts.onFail ?? 'throw';
|
|
95
|
+
const result = checkNodeVersion(current, requiredMajor);
|
|
96
|
+
if (result.ok) return;
|
|
97
|
+
if (onFail === 'exit') {
|
|
98
|
+
console.error(result.message);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
throw new Error(result.message);
|
|
102
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { isNotFound, isRedirect } from '@webjsdev/core';
|
|
2
|
+
import { ssrPage, ssrNotFound, loadModule } from './ssr.js';
|
|
3
|
+
import { readBytesBounded, payloadTooLarge, DEFAULT_MAX_MULTIPART_BYTES } from './body-limit.js';
|
|
4
|
+
import { getBodyLimits } from './context.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Page server actions: a `page.{js,ts}` may export an `action` function that
|
|
8
|
+
* handles a non-GET/HEAD submission to the page's own URL. This is webjs's
|
|
9
|
+
* Remix-style page-action path, adapted to the no-build progressive-enhancement
|
|
10
|
+
* model: a `<form method="POST">` submits with JS disabled, the action runs on
|
|
11
|
+
* the server, and a validation failure re-renders the SAME page with field
|
|
12
|
+
* errors and the user's typed values preserved.
|
|
13
|
+
*
|
|
14
|
+
* Behavior (see #244):
|
|
15
|
+
* - Action throws `redirect(url)` or `notFound()` => honored exactly as a page
|
|
16
|
+
* render does (3xx / 404). A thrown `redirect()` may target an external URL
|
|
17
|
+
* (it is the explicit nav sentinel, author-controlled).
|
|
18
|
+
* - Action returns a SUCCESS result => 303 See Other to `result.redirect` if
|
|
19
|
+
* present, else to the page's own path (Post/Redirect/Get, so a reload does
|
|
20
|
+
* not resubmit). `result.redirect` MUST be a same-site local path (see
|
|
21
|
+
* `sameSiteRedirect`), a non-local value is ignored to avoid an
|
|
22
|
+
* open-redirect through a user-controlled action result.
|
|
23
|
+
* - Action returns a FAILURE result => re-SSR the SAME page (status 422) with
|
|
24
|
+
* the result on `ctx.actionData`, so the page template can read
|
|
25
|
+
* `actionData.fieldErrors` / `actionData.values` and repopulate inputs.
|
|
26
|
+
*
|
|
27
|
+
* Failure detection is robust (it does not require a literal `success: false`):
|
|
28
|
+
* see `isFailureResult`.
|
|
29
|
+
*
|
|
30
|
+
* @typedef {{
|
|
31
|
+
* success?: boolean,
|
|
32
|
+
* data?: unknown,
|
|
33
|
+
* error?: string,
|
|
34
|
+
* fieldErrors?: Record<string,string>,
|
|
35
|
+
* values?: Record<string,string>,
|
|
36
|
+
* status?: number,
|
|
37
|
+
* redirect?: string,
|
|
38
|
+
* }} ActionResult
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether an action result is a FAILURE (re-render the page) rather than a
|
|
43
|
+
* success (PRG redirect). A result is a failure when ANY of these hold:
|
|
44
|
+
* - `result.success === false` (explicit), OR
|
|
45
|
+
* - `result.fieldErrors` is present (per-field validation messages), OR
|
|
46
|
+
* - `result.error` is present AND `result.success !== true`.
|
|
47
|
+
*
|
|
48
|
+
* Success is the explicit `success: true`, or a bare value (or
|
|
49
|
+
* undefined/null) carrying no error markers. This means an action that returns
|
|
50
|
+
* `{ error, status }` or `{ fieldErrors }` WITHOUT a literal `success: false`
|
|
51
|
+
* is still treated as a failure and its error is surfaced, not swallowed.
|
|
52
|
+
*
|
|
53
|
+
* @param {ActionResult | null | undefined} result
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isFailureResult(result) {
|
|
57
|
+
if (!result || typeof result !== 'object') return false;
|
|
58
|
+
if (result.success === false) return true;
|
|
59
|
+
if (result.fieldErrors != null) return true;
|
|
60
|
+
if (result.error != null && result.success !== true) return true;
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Restrict a page action's `result.redirect` to a SAME-SITE local target.
|
|
66
|
+
* Allowed: a path beginning with a single `/` (e.g. `/login`, `/a?b=1#c`).
|
|
67
|
+
* Rejected: a protocol-relative `//host/...` and any absolute `scheme://...`
|
|
68
|
+
* URL. A user-controlled redirect target is an open-redirect vector, so a
|
|
69
|
+
* non-local value is dropped and the caller falls back to the page's own path.
|
|
70
|
+
*
|
|
71
|
+
* A thrown `redirect(absoluteUrl)` (the nav sentinel) is intentionally NOT
|
|
72
|
+
* routed through here: that is the author-controlled escape hatch for a
|
|
73
|
+
* legitimate external redirect.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} target
|
|
76
|
+
* @returns {string | null} the safe local path, or null when not same-site
|
|
77
|
+
*/
|
|
78
|
+
function sameSiteRedirect(target) {
|
|
79
|
+
if (typeof target !== 'string') return null;
|
|
80
|
+
// Must start with a single slash (a leading `//` is protocol-relative and
|
|
81
|
+
// would navigate cross-origin).
|
|
82
|
+
if (!target.startsWith('/') || target.startsWith('//')) return null;
|
|
83
|
+
// A backslash after the leading slash (`/\evil.com`) is normalized by some
|
|
84
|
+
// browsers into a protocol-relative URL, so reject it too.
|
|
85
|
+
if (target.startsWith('/\\')) return null;
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Load a page module and return its `action` export, or null if it has none.
|
|
91
|
+
* Uses ssr.js's shared `loadModule`, so page-action loading is consistent with
|
|
92
|
+
* the SSR re-render. In prod the URL is stable and Node's module cache serves
|
|
93
|
+
* one evaluation; in dev a cache-bust forces a fresh evaluation.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} file absolute path to the page module
|
|
96
|
+
* @param {boolean} dev
|
|
97
|
+
* @returns {Promise<{ action: Function, module: Record<string, unknown> } | null>}
|
|
98
|
+
*/
|
|
99
|
+
export async function loadPageAction(file, dev) {
|
|
100
|
+
try {
|
|
101
|
+
const mod = await loadModule(file, dev);
|
|
102
|
+
return typeof mod.action === 'function'
|
|
103
|
+
? { action: /** @type {Function} */ (mod.action), module: mod }
|
|
104
|
+
: null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Read the submitted body ONCE, bounded by the form/multipart limit (issue
|
|
112
|
+
* #237), and return both a `FormData` (handed to the action as `formData`) and a
|
|
113
|
+
* rebuilt `Request` carrying the already-read bytes (handed to the action as
|
|
114
|
+
* `request`, so it can still call `request.json()` / `request.formData()`). The
|
|
115
|
+
* body is consumed off the ORIGINAL request directly, NOT via `req.clone()`: a
|
|
116
|
+
* tee'd clone whose reader is cancelled mid-stream (the over-limit case)
|
|
117
|
+
* deadlocks the untaken branch, hanging the response.
|
|
118
|
+
*
|
|
119
|
+
* An over-limit body is reported as `tooLarge` (the caller returns 413) and is
|
|
120
|
+
* never buffered whole. A form posts more than a JSON RPC call (textarea, small
|
|
121
|
+
* upload), so it uses the higher `multipart` cap. A non-form content type yields
|
|
122
|
+
* an empty FormData so the action signature stays stable; the rebuilt request
|
|
123
|
+
* still carries the raw bytes for the action to parse however it likes.
|
|
124
|
+
*
|
|
125
|
+
* @param {Request} req
|
|
126
|
+
* @returns {Promise<{ tooLarge: boolean, formData: FormData, request: Request }>}
|
|
127
|
+
*/
|
|
128
|
+
async function parseFormBody(req) {
|
|
129
|
+
const ct = req.headers.get('content-type') || '';
|
|
130
|
+
const limits = getBodyLimits();
|
|
131
|
+
const limit = limits ? limits.multipart : DEFAULT_MAX_MULTIPART_BYTES;
|
|
132
|
+
const { tooLarge, bytes } = await readBytesBounded(req, limit);
|
|
133
|
+
if (tooLarge) return { tooLarge: true, formData: new FormData(), request: req };
|
|
134
|
+
|
|
135
|
+
// Rebuild a fresh Request from the bytes so the action can re-read the body.
|
|
136
|
+
const headers = new Headers(req.headers);
|
|
137
|
+
const rebuilt = new Request(req.url, {
|
|
138
|
+
method: req.method,
|
|
139
|
+
headers,
|
|
140
|
+
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const isForm = /multipart\/form-data|application\/x-www-form-urlencoded/i.test(ct);
|
|
144
|
+
let formData = new FormData();
|
|
145
|
+
if (isForm) {
|
|
146
|
+
// Parse a SECOND fresh Request (the rebuilt one is reserved for the action).
|
|
147
|
+
const forParse = new Request(req.url, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: ct ? { 'content-type': ct } : undefined,
|
|
150
|
+
body: bytes && bytes.byteLength ? bytes : undefined,
|
|
151
|
+
});
|
|
152
|
+
formData = await forParse.formData();
|
|
153
|
+
}
|
|
154
|
+
return { tooLarge: false, formData, request: rebuilt };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run a page `action` for a non-GET/HEAD request and produce the HTTP response.
|
|
159
|
+
* The caller has already confirmed the path matches a page route AND that the
|
|
160
|
+
* page module exports an `action` (via `loadPageAction`). The action runs inside
|
|
161
|
+
* the same segment middleware as the page (the caller wraps this).
|
|
162
|
+
*
|
|
163
|
+
* The failure re-render reuses the SAME page module instance whose `action` just
|
|
164
|
+
* ran (passed through as `pageModule`), so the page module is loaded once per
|
|
165
|
+
* POST rather than evaluated a second time.
|
|
166
|
+
*
|
|
167
|
+
* @param {import('./router.js').PageRoute} route
|
|
168
|
+
* @param {Record<string,string>} params
|
|
169
|
+
* @param {URL} url
|
|
170
|
+
* @param {{ action: Function, module: Record<string, unknown> }} loaded the page module's `action` plus the loaded module
|
|
171
|
+
* @param {Request} req
|
|
172
|
+
* @param {object} ssrOpts the same opts object `ssrPage` receives in dev.js
|
|
173
|
+
* @returns {Promise<Response>}
|
|
174
|
+
*/
|
|
175
|
+
export async function runPageAction(route, params, url, loaded, req, ssrOpts) {
|
|
176
|
+
const { action, module: pageModule } = loaded;
|
|
177
|
+
const searchParams = Object.fromEntries(url.searchParams.entries());
|
|
178
|
+
let formData = new FormData();
|
|
179
|
+
// The body is read ONCE here (bounded). `actionReq` is a rebuilt request the
|
|
180
|
+
// action can re-read; on a parse failure it falls back to the original `req`.
|
|
181
|
+
let actionReq = req;
|
|
182
|
+
try {
|
|
183
|
+
const parsed = await parseFormBody(req);
|
|
184
|
+
// Over the form/multipart limit (issue #237): 413 before the action runs.
|
|
185
|
+
if (parsed.tooLarge) return payloadTooLarge();
|
|
186
|
+
formData = parsed.formData;
|
|
187
|
+
actionReq = parsed.request;
|
|
188
|
+
} catch {
|
|
189
|
+
formData = new FormData();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @type {ActionResult | undefined} */
|
|
193
|
+
let result;
|
|
194
|
+
try {
|
|
195
|
+
result = await action({ request: actionReq, params, searchParams, url, formData });
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (isRedirect(err)) {
|
|
198
|
+
const e = /** @type any */ (err);
|
|
199
|
+
// A thrown redirect from an action is honored as the page render does.
|
|
200
|
+
// Use the action's chosen status (307/308) so an explicit redirect()
|
|
201
|
+
// keeps its semantics; PRG (303) is the SUCCESS-result path below.
|
|
202
|
+
return new Response(null, { status: e.status || 307, headers: { location: e.url } });
|
|
203
|
+
}
|
|
204
|
+
if (isNotFound(err)) {
|
|
205
|
+
return ssrNotFound(ssrOpts.notFoundFile ?? null, { ...ssrOpts, req, url });
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!isFailureResult(result)) {
|
|
211
|
+
// SUCCESS: Post/Redirect/Get. A user-controlled `result.redirect` is only
|
|
212
|
+
// honored when it is a same-site local path; otherwise fall back to the
|
|
213
|
+
// page's own path so a poisoned value cannot become an open redirect.
|
|
214
|
+
const ownPath = (url.pathname + url.search) || '/';
|
|
215
|
+
const safe = result ? sameSiteRedirect(result.redirect) : null;
|
|
216
|
+
return new Response(null, { status: 303, headers: { location: safe || ownPath } });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// FAILURE: re-render the SAME page with the action result available on
|
|
220
|
+
// ctx.actionData, status 422. Repopulation is the page author's job (native
|
|
221
|
+
// `value=${actionData.values?.field}`). Pass the already-loaded page module so
|
|
222
|
+
// the re-render shares this POST's single evaluation.
|
|
223
|
+
const status = typeof result.status === 'number' && result.status >= 400 ? result.status : 422;
|
|
224
|
+
return ssrPage(route, params, url, { ...ssrOpts, req, actionData: result, status, pageModule });
|
|
225
|
+
}
|