@webjsdev/server 0.8.33 → 0.8.34
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 +15 -0
- package/index.js +1 -2
- package/package.json +1 -1
- package/src/actions.js +19 -13
- package/src/component-elision.js +28 -4
- package/src/csrf.js +101 -61
- package/src/dev.js +25 -3
- package/src/elision-report.js +89 -0
- package/src/html-cache.js +5 -31
- package/src/listener-bun.js +1 -1
- package/src/ssr.js +6 -18
- package/src/testing.d.ts +8 -26
- package/src/testing.js +18 -72
- package/webjs-config.schema.json +6 -0
package/index.d.ts
CHANGED
|
@@ -457,6 +457,21 @@ export declare function findOrphanComponents(
|
|
|
457
457
|
appDir: string,
|
|
458
458
|
): Promise<Array<{ className: string; file: string }>>;
|
|
459
459
|
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// elision-report.js (#646)
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* App-level elision report: the page/layout route modules that SHIP WHOLE to
|
|
466
|
+
* the browser instead of being elided as carriers, each with the first
|
|
467
|
+
* client-effecting blocker that pins it (or `null` when the module's own code
|
|
468
|
+
* is the cause) plus a human reason. A reporting layer over `analyzeElision`,
|
|
469
|
+
* consumed by the `webjs doctor` carrier-hygiene advisory (#646).
|
|
470
|
+
*/
|
|
471
|
+
export declare function analyzeAppElision(
|
|
472
|
+
appDir: string,
|
|
473
|
+
): Promise<{ analysed: boolean; shipped: Array<{ file: string; blocker: string | null; reason: string }> }>;
|
|
474
|
+
|
|
460
475
|
// ---------------------------------------------------------------------------
|
|
461
476
|
// context.js (per-request context helpers)
|
|
462
477
|
// ---------------------------------------------------------------------------
|
package/index.js
CHANGED
|
@@ -42,6 +42,7 @@ export {
|
|
|
42
42
|
} from './src/vendor.js';
|
|
43
43
|
export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
|
|
44
44
|
export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
|
|
45
|
+
export { analyzeAppElision } from './src/elision-report.js';
|
|
45
46
|
export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
|
|
46
47
|
export { defaultLogger } from './src/logger.js';
|
|
47
48
|
export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
|
|
@@ -68,8 +69,6 @@ export { createAuth, Credentials, Google, GitHub } from './src/auth.js';
|
|
|
68
69
|
export {
|
|
69
70
|
testRequest,
|
|
70
71
|
toRequest,
|
|
71
|
-
getCsrf,
|
|
72
|
-
readCsrfCookie,
|
|
73
72
|
getSetCookies,
|
|
74
73
|
cookiesToHeader,
|
|
75
74
|
withCookies,
|
package/package.json
CHANGED
package/src/actions.js
CHANGED
|
@@ -3,7 +3,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join, sep } from 'node:path';
|
|
5
5
|
import { walk } from './fs-walk.js';
|
|
6
|
-
import {
|
|
6
|
+
import { verifyOrigin } from './csrf.js';
|
|
7
7
|
import { getSerializer } from './serializer.js';
|
|
8
8
|
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
9
9
|
import {
|
|
@@ -311,7 +311,6 @@ export async function serveActionStub(idx, absFile) {
|
|
|
311
311
|
*/
|
|
312
312
|
function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
313
313
|
const URL_ARG = URL_ARG_VERBS.has(method);
|
|
314
|
-
const SAFE = SAFE_VERBS.has(method);
|
|
315
314
|
const J = JSON.stringify;
|
|
316
315
|
const lines = [];
|
|
317
316
|
lines.push(`// webjs: generated server-action stub (${method})`);
|
|
@@ -334,7 +333,8 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
334
333
|
lines.push(`const __METHOD = ${J(method)};`);
|
|
335
334
|
lines.push(`const __MAX = ${MAX_URL_ARGS};`);
|
|
336
335
|
lines.push(`const __CT = ${J(RPC_CONTENT_TYPE)};`);
|
|
337
|
-
|
|
336
|
+
// CSRF is enforced server-side by an Origin-header check, so the stub sends
|
|
337
|
+
// nothing extra: the browser attaches `Origin` to a same-origin POST itself.
|
|
338
338
|
// Shared: parse a response, surface invalidation, register a GET's tags
|
|
339
339
|
// (stamped with the clock SAMPLED BEFORE the fetch, so a mutation in flight is
|
|
340
340
|
// caught on the next read).
|
|
@@ -383,8 +383,8 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
383
383
|
// captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
|
|
384
384
|
// be read before the stub's first await (stringify), or the render's
|
|
385
385
|
// synchronous window has already closed and cleared it.
|
|
386
|
-
lines.push(`async function __body(fn, body, m,
|
|
387
|
-
lines.push(` const headers = { 'content-type': __CT }
|
|
386
|
+
lines.push(`async function __body(fn, body, m, sig) {`);
|
|
387
|
+
lines.push(` const headers = { 'content-type': __CT };`);
|
|
388
388
|
lines.push(` const res = await fetch(__URL + fn, { method: m, headers, credentials: 'same-origin', body, signal: sig });`);
|
|
389
389
|
lines.push(` return __handle(res, fn, null);`);
|
|
390
390
|
lines.push(`}`);
|
|
@@ -394,14 +394,14 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
394
394
|
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
395
395
|
lines.push(` const key = await __s(args);`);
|
|
396
396
|
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
397
|
-
lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST',
|
|
397
|
+
lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', sig);`);
|
|
398
398
|
if (method === 'GET') {
|
|
399
399
|
lines.push(` const bypass = __stale(key);`);
|
|
400
400
|
lines.push(` const since = __mark();`);
|
|
401
401
|
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: 'GET', credentials: 'same-origin', cache: bypass ? 'no-cache' : 'default', signal: sig });`);
|
|
402
402
|
lines.push(` return __handle(res, fn, key, since);`);
|
|
403
403
|
} else {
|
|
404
|
-
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD,
|
|
404
|
+
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, credentials: 'same-origin', signal: sig });`);
|
|
405
405
|
lines.push(` return __handle(res, fn, null);`);
|
|
406
406
|
}
|
|
407
407
|
lines.push(`}`);
|
|
@@ -413,7 +413,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
413
413
|
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
414
414
|
lines.push(` const key = await __s(args);`);
|
|
415
415
|
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
416
|
-
lines.push(` return __body(fn, key, __METHOD,
|
|
416
|
+
lines.push(` return __body(fn, key, __METHOD, sig);`);
|
|
417
417
|
lines.push(`}`);
|
|
418
418
|
}
|
|
419
419
|
for (const name of fnNames) {
|
|
@@ -436,8 +436,10 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
436
436
|
* invoked when the action throws unexpectedly, BEFORE the sanitized 500 is
|
|
437
437
|
* returned, so an APM integration sees the original error. The caller wraps
|
|
438
438
|
* it so a throwing sink can never affect the response.
|
|
439
|
+
* @param {string[]} [allowedOrigins] cross-site origins the CSRF check allows
|
|
440
|
+
* (the app's `webjs.allowedOrigins`), for reverse-proxy / multi-domain setups.
|
|
439
441
|
*/
|
|
440
|
-
export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
442
|
+
export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigins = []) {
|
|
441
443
|
const file = idx.hashToFile.get(hash);
|
|
442
444
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
443
445
|
const mod = await loadModule(file, idx.dev);
|
|
@@ -459,10 +461,14 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
459
461
|
{ status: 405, headers: { allow: [...allowed].join(', ') } },
|
|
460
462
|
);
|
|
461
463
|
}
|
|
462
|
-
// CSRF:
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
464
|
+
// CSRF: an Origin-header check on every state-changing verb (a safe GET is a
|
|
465
|
+
// read with no state change). A browser sets `Origin` on a cross-site POST to
|
|
466
|
+
// the ATTACKER's origin, so a forged request fails the host match. No token
|
|
467
|
+
// or cookie is involved, which keeps SSR HTML free of `Set-Cookie` and so
|
|
468
|
+
// CDN-cacheable. See csrf.js.
|
|
469
|
+
if (!SAFE_VERBS.has(method)) {
|
|
470
|
+
const v = verifyOrigin(req, allowedOrigins);
|
|
471
|
+
if (!v.ok) return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
|
|
466
472
|
}
|
|
467
473
|
|
|
468
474
|
// Args ride the URL for a URL-arg request (GET / DELETE), else the body.
|
package/src/component-elision.js
CHANGED
|
@@ -843,7 +843,7 @@ export async function computeElidableComponents(components, moduleGraph, readFil
|
|
|
843
843
|
* @param {import('./module-graph.js').ModuleGraph} moduleGraph
|
|
844
844
|
* @param {(file: string) => Promise<string>} readFileFn
|
|
845
845
|
* @param {string} [appDir]
|
|
846
|
-
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]> }>}
|
|
846
|
+
* @returns {Promise<{ elidableComponents: Set<string>, inertRouteModules: Set<string>, importOnlyRouteModules: Map<string, string[]>, shippedRouteModules: Map<string, { blocker: string|null, reason: string }> }>}
|
|
847
847
|
*/
|
|
848
848
|
export async function analyzeElision(components, routeModules, moduleGraph, readFileFn, appDir) {
|
|
849
849
|
/** @type {Set<string>} */
|
|
@@ -1069,6 +1069,17 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1069
1069
|
clientRouterFiles.has(file) ||
|
|
1070
1070
|
clientGlobalOrBareFiles.has(file);
|
|
1071
1071
|
|
|
1072
|
+
// A human reason for WHY a file does client work, for the advisory that
|
|
1073
|
+
// names why a page/layout ships (#646). Mirrors the isClientEffecting
|
|
1074
|
+
// branches, most specific first.
|
|
1075
|
+
const clientEffectReason = (file) => {
|
|
1076
|
+
if (componentFiles.has(file) && mustShip.has(file)) return 'is an interactive component';
|
|
1077
|
+
if (clientRouterFiles.has(file)) return 'imports the client router';
|
|
1078
|
+
if (reactiveFiles.has(file)) return 'imports a reactive primitive (signal / computed / watch)';
|
|
1079
|
+
if (clientGlobalOrBareFiles.has(file)) return 'references a browser global at module scope, runs code at module scope, or has a bare side-effect import';
|
|
1080
|
+
return 'does client work';
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1072
1083
|
// Route modules fall into three classes by their effective client closure
|
|
1073
1084
|
// (skipping elided components and server stubs, which never load on the
|
|
1074
1085
|
// client):
|
|
@@ -1091,9 +1102,18 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1091
1102
|
const inertRouteModules = new Set();
|
|
1092
1103
|
/** @type {Map<string, string[]>} route file -> component files to emit in its place */
|
|
1093
1104
|
const importOnlyRouteModules = new Map();
|
|
1105
|
+
// Advisory (#646): for each route module that SHIPS WHOLE (neither inert nor
|
|
1106
|
+
// import-only), record the first client-effecting blocker so a tool can name
|
|
1107
|
+
// why it ships. blocker is null when the module's OWN code is the cause.
|
|
1108
|
+
/** @type {Map<string, { blocker: string|null, reason: string }>} */
|
|
1109
|
+
const shippedRouteModules = new Map();
|
|
1094
1110
|
for (const file of routeModules) {
|
|
1095
1111
|
if (!fileTags.has(file)) continue; // unreadable / not analysed: ship (omit from both sets)
|
|
1096
|
-
if (isClientEffecting(file))
|
|
1112
|
+
if (isClientEffecting(file)) {
|
|
1113
|
+
// The module itself ships whole; its own signal is the blocker.
|
|
1114
|
+
shippedRouteModules.set(file, { blocker: null, reason: clientEffectReason(file) });
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1097
1117
|
const closure = appDir ? transitiveDeps(moduleGraph, [file], appDir, skip) : [];
|
|
1098
1118
|
const effecting = closure.filter(isClientEffecting);
|
|
1099
1119
|
if (effecting.length === 0) { inertRouteModules.add(file); continue; }
|
|
@@ -1111,11 +1131,15 @@ export async function analyzeElision(components, routeModules, moduleGraph, read
|
|
|
1111
1131
|
// closure and still loads via the IntersectionObserver `observeLazy` path.
|
|
1112
1132
|
if (effecting.every((f) => componentFiles.has(f))) {
|
|
1113
1133
|
importOnlyRouteModules.set(file, effecting);
|
|
1134
|
+
} else {
|
|
1135
|
+
// A client-effecting non-component is reachable; ship the whole module.
|
|
1136
|
+
// Name the FIRST such non-component as the blocker for the advisory.
|
|
1137
|
+
const blocker = effecting.find((f) => !componentFiles.has(f));
|
|
1138
|
+
shippedRouteModules.set(file, { blocker, reason: clientEffectReason(blocker) });
|
|
1114
1139
|
}
|
|
1115
|
-
// else: a client-effecting non-component is reachable; ship the whole module.
|
|
1116
1140
|
}
|
|
1117
1141
|
|
|
1118
|
-
return { elidableComponents, inertRouteModules, importOnlyRouteModules };
|
|
1142
|
+
return { elidableComponents, inertRouteModules, importOnlyRouteModules, shippedRouteModules };
|
|
1119
1143
|
}
|
|
1120
1144
|
|
|
1121
1145
|
/** Match a whole-line side-effect import: `import './x.js';` (no bindings). */
|
package/src/csrf.js
CHANGED
|
@@ -1,47 +1,36 @@
|
|
|
1
|
-
// Use Web Crypto (globalThis.crypto) for random + hash: works on Node >=20,
|
|
2
|
-
// Deno, Bun, Cloudflare Workers. Avoids the node:crypto import and keeps
|
|
3
|
-
// CSRF portable across runtimes.
|
|
4
|
-
const webCrypto = /** @type {Crypto} */ (globalThis.crypto);
|
|
5
|
-
|
|
6
1
|
/**
|
|
7
|
-
*
|
|
2
|
+
* Cross-origin (CSRF) protection for `/__webjs/action/*` RPC endpoints, via
|
|
3
|
+
* Fetch-Metadata + Origin verification. This is the model Remix 3's
|
|
4
|
+
* `cop-middleware` and Go 1.25's `http.CrossOriginProtection` use, and the
|
|
5
|
+
* spiritual sibling of Next.js / Astro's Origin-vs-Host check.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* 3. `invokeAction` compares the header to the cookie with constant-time
|
|
16
|
-
* equality; mismatch → 403.
|
|
7
|
+
* Why not a token cookie: webjs previously issued a per-request `webjs_csrf`
|
|
8
|
+
* double-submit cookie on every SSR response. That made SSR HTML
|
|
9
|
+
* un-cacheable at a CDN (a CDN skips a response with `Set-Cookie`, and a
|
|
10
|
+
* cached one would share / poison the token across visitors). A header check
|
|
11
|
+
* needs nothing on the page, so SSR HTML carries no `Set-Cookie` and a page
|
|
12
|
+
* that opts into a public `Cache-Control` is edge-cacheable.
|
|
17
13
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* The check, on every state-changing verb (a safe GET is exempt):
|
|
15
|
+
* 1. `Sec-Fetch-Site` is the primary signal. The browser sets it on every
|
|
16
|
+
* request and page JS cannot forge it. `same-origin` / `none` (a direct
|
|
17
|
+
* navigation with no initiator) pass; `same-site` / `cross-site` are
|
|
18
|
+
* rejected unless the source origin is in `webjs.allowedOrigins`.
|
|
19
|
+
* 2. When `Sec-Fetch-Site` is absent (an older browser), fall back to
|
|
20
|
+
* comparing the `Origin` host to the request host; an absent `Origin`
|
|
21
|
+
* can't be checked so it passes (a handcrafted / non-browser request
|
|
22
|
+
* can't carry a victim's SameSite cookies cross-site anyway).
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* middleware.
|
|
24
|
+
* Scope:
|
|
25
|
+
* - Internal RPC only. A `route.ts` REST endpoint (hand-written or via the
|
|
26
|
+
* `route()` adapter) is intentionally NOT covered here; it is for external
|
|
27
|
+
* consumers and must carry its own auth.
|
|
28
|
+
* - Session / auth cookies stay `SameSite=Lax` as defense-in-depth.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
|
-
export const CSRF_COOKIE = 'webjs_csrf';
|
|
31
|
-
export const CSRF_HEADER = 'x-webjs-csrf';
|
|
32
|
-
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; // 7 days
|
|
33
|
-
|
|
34
|
-
/** @returns {string} a 128-bit hex token */
|
|
35
|
-
export function newToken() {
|
|
36
|
-
const bytes = new Uint8Array(16);
|
|
37
|
-
webCrypto.getRandomValues(bytes);
|
|
38
|
-
let s = '';
|
|
39
|
-
for (const b of bytes) s += b.toString(16).padStart(2, '0');
|
|
40
|
-
return s;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
31
|
/**
|
|
44
|
-
* Parse cookies off a standard Request.
|
|
32
|
+
* Parse cookies off a standard Request. Retained as a general cookie reader
|
|
33
|
+
* (used by `context.js` for `cookies()`), independent of CSRF.
|
|
45
34
|
* @param {Request} req
|
|
46
35
|
* @returns {Record<string,string>}
|
|
47
36
|
*/
|
|
@@ -60,37 +49,88 @@ export function parseCookies(req) {
|
|
|
60
49
|
return out;
|
|
61
50
|
}
|
|
62
51
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
52
|
+
/** Lower-cased host from a URL-or-origin string, or '' if unparseable. */
|
|
53
|
+
function hostOf(value) {
|
|
54
|
+
if (!value) return '';
|
|
55
|
+
try { return new URL(value).host.toLowerCase(); } catch { return ''; }
|
|
66
56
|
}
|
|
67
57
|
|
|
68
58
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
59
|
+
* The host the request was addressed to. Honors `x-forwarded-host` first
|
|
60
|
+
* (set by a reverse proxy / CDN like the Cloudflare-in-front-of-Railway
|
|
61
|
+
* setup), then the `Host` header, then the request URL.
|
|
62
|
+
*
|
|
63
|
+
* Trust note: `x-forwarded-host` is only meaningful behind a proxy that
|
|
64
|
+
* overwrites it. This is NOT a CSRF weakness, because a CSRF attack is
|
|
65
|
+
* browser-driven and a browser cannot set `x-forwarded-host` (or `Origin`);
|
|
66
|
+
* only a direct non-browser client can, and such a client carries no victim
|
|
67
|
+
* SameSite cookies to abuse. The primary `Sec-Fetch-Site` path does not use
|
|
68
|
+
* this at all.
|
|
69
|
+
* @param {Request} req
|
|
72
70
|
*/
|
|
73
|
-
export function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
71
|
+
export function requestHost(req) {
|
|
72
|
+
const xfh = req.headers.get('x-forwarded-host');
|
|
73
|
+
if (xfh) return xfh.split(',')[0].trim().toLowerCase();
|
|
74
|
+
const host = req.headers.get('host');
|
|
75
|
+
if (host) return host.toLowerCase();
|
|
76
|
+
return hostOf(req.url);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Is the request's `Origin` host in the configured allowlist? */
|
|
80
|
+
function originAllowed(req, allowedOrigins) {
|
|
81
|
+
const origin = req.headers.get('origin');
|
|
82
|
+
if (!origin || origin === 'null') return false;
|
|
83
|
+
const h = hostOf(origin);
|
|
84
|
+
if (!h) return false;
|
|
85
|
+
const allow = new Set(
|
|
86
|
+
// A full-origin entry (`https://x.example`) is host-parsed; a bare host
|
|
87
|
+
// (`x.example` or a copy-pasted `x.example/`) is lower-cased and stripped
|
|
88
|
+
// of a stray trailing slash so it still matches.
|
|
89
|
+
allowedOrigins.map((o) => (o.includes('://') ? hostOf(o) : o.toLowerCase().replace(/\/+$/, ''))),
|
|
90
|
+
);
|
|
91
|
+
return allow.has(h);
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/**
|
|
85
|
-
*
|
|
95
|
+
* Cross-origin (CSRF) verification for a state-changing action request.
|
|
86
96
|
* @param {Request} req
|
|
97
|
+
* @param {string[]} [allowedOrigins] hosts or full origins allowed cross-site
|
|
98
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
99
|
+
*/
|
|
100
|
+
export function verifyOrigin(req, allowedOrigins = []) {
|
|
101
|
+
// Primary: the Sec-Fetch-Site fetch-metadata header (browser-set, not
|
|
102
|
+
// forgeable by page JS, sent on every request).
|
|
103
|
+
const secFetchSite = (req.headers.get('sec-fetch-site') || '').toLowerCase();
|
|
104
|
+
if (secFetchSite === 'same-origin' || secFetchSite === 'none') return { ok: true };
|
|
105
|
+
if (secFetchSite) {
|
|
106
|
+
// 'same-site' or 'cross-site': reject unless the source origin is trusted.
|
|
107
|
+
return originAllowed(req, allowedOrigins)
|
|
108
|
+
? { ok: true }
|
|
109
|
+
: { ok: false, reason: `cross-origin request (Sec-Fetch-Site: ${secFetchSite})` };
|
|
110
|
+
}
|
|
111
|
+
// Fallback (no Sec-Fetch-Site, older browser): compare Origin host to host.
|
|
112
|
+
const origin = req.headers.get('origin');
|
|
113
|
+
if (!origin) return { ok: true, reason: 'no-origin' };
|
|
114
|
+
const sourceHost = origin === 'null' ? 'null' : hostOf(origin);
|
|
115
|
+
const host = requestHost(req);
|
|
116
|
+
if (sourceHost && host && sourceHost === host) return { ok: true };
|
|
117
|
+
return originAllowed(req, allowedOrigins)
|
|
118
|
+
? { ok: true }
|
|
119
|
+
: { ok: false, reason: `origin ${sourceHost || '(none)'} does not match host ${host || '(none)'}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read `webjs.allowedOrigins` (string[]) from a parsed package.json. Pure;
|
|
124
|
+
* the caller supplies the package.json read (mirrors `readBasePath`).
|
|
125
|
+
* @param {unknown} pkg
|
|
126
|
+
* @returns {string[]}
|
|
87
127
|
*/
|
|
88
|
-
export function
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
128
|
+
export function readAllowedOrigins(pkg) {
|
|
129
|
+
const raw =
|
|
130
|
+
pkg &&
|
|
131
|
+
typeof pkg === 'object' &&
|
|
132
|
+
/** @type {any} */ (pkg).webjs &&
|
|
133
|
+
/** @type {any} */ (pkg).webjs.allowedOrigins;
|
|
134
|
+
if (!Array.isArray(raw)) return [];
|
|
135
|
+
return raw.filter((x) => typeof x === 'string' && x.length > 0);
|
|
96
136
|
}
|
package/src/dev.js
CHANGED
|
@@ -87,6 +87,7 @@ function shouldAccessLog(pathname) {
|
|
|
87
87
|
}
|
|
88
88
|
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
|
|
89
89
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
90
|
+
import { readAllowedOrigins } from './csrf.js';
|
|
90
91
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
|
|
91
92
|
import { urlFromRequest } from './forwarded.js';
|
|
92
93
|
import { compileHeaderRules, applySecurityHeaders, webRequestIsHttps } from './headers.js';
|
|
@@ -362,6 +363,24 @@ export async function readBasePathFromApp(appDir) {
|
|
|
362
363
|
}
|
|
363
364
|
}
|
|
364
365
|
|
|
366
|
+
/**
|
|
367
|
+
* Read the cross-origin allowlist (`webjs.allowedOrigins`) from the app's
|
|
368
|
+
* package.json. These hosts / origins are accepted by the action CSRF check
|
|
369
|
+
* even when cross-site (reverse-proxy / multi-domain setups). A missing or
|
|
370
|
+
* unreadable config yields `[]`.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} appDir
|
|
373
|
+
* @returns {Promise<string[]>}
|
|
374
|
+
*/
|
|
375
|
+
export async function readAllowedOriginsFromApp(appDir) {
|
|
376
|
+
try {
|
|
377
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
378
|
+
return readAllowedOrigins(pkg);
|
|
379
|
+
} catch {
|
|
380
|
+
return [];
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
365
384
|
/**
|
|
366
385
|
* Read the CSP config (`webjs.csp`) from the app's package.json and
|
|
367
386
|
* normalize it (issue #233). A missing, malformed, or unreadable config
|
|
@@ -495,6 +514,9 @@ export async function createRequestHandler(opts) {
|
|
|
495
514
|
const basePathValue = await readBasePathFromApp(appDir);
|
|
496
515
|
await setBasePath(basePathValue);
|
|
497
516
|
|
|
517
|
+
// Cross-origin allowlist for the action CSRF check (#659), read once.
|
|
518
|
+
const allowedOriginsValue = await readAllowedOriginsFromApp(appDir);
|
|
519
|
+
|
|
498
520
|
// Client-router opt-out (#629): bind it eagerly at handler construction (the
|
|
499
521
|
// same timing as setBasePath above), so a handler's module-global is set
|
|
500
522
|
// before any render even if a request arrives before the first warm. The
|
|
@@ -1306,7 +1328,7 @@ export async function createRequestHandler(opts) {
|
|
|
1306
1328
|
// Build all whole-app analysis on the first request (memoized), before
|
|
1307
1329
|
// any SSR, module serve, gate check, action dispatch, or middleware runs.
|
|
1308
1330
|
await ensureReady();
|
|
1309
|
-
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled });
|
|
1331
|
+
const next = () => handleCore(req, { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled: cspConfig.enabled, allowedOrigins: allowedOriginsValue });
|
|
1310
1332
|
if (state.middleware) {
|
|
1311
1333
|
try {
|
|
1312
1334
|
return await state.middleware(req, next);
|
|
@@ -1740,7 +1762,7 @@ async function tryServeFrameworkStatic(path, method, ctx) {
|
|
|
1740
1762
|
}
|
|
1741
1763
|
|
|
1742
1764
|
async function handleCore(req, ctx) {
|
|
1743
|
-
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled } = ctx;
|
|
1765
|
+
const { state, appDir, coreDir, dev, reportError, reportDevError, cspEnabled, allowedOrigins } = ctx;
|
|
1744
1766
|
const url = new URL(req.url);
|
|
1745
1767
|
// Decode percent-encoded characters so filesystem lookups match real
|
|
1746
1768
|
// filenames. Dynamic route segments like `[slug]` and route groups like
|
|
@@ -1778,7 +1800,7 @@ async function handleCore(req, ctx) {
|
|
|
1778
1800
|
// Pass the onError sink (issue #239): a server action that throws
|
|
1779
1801
|
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1780
1802
|
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1781
|
-
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1803
|
+
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError, allowedOrigins);
|
|
1782
1804
|
}
|
|
1783
1805
|
|
|
1784
1806
|
// Static: /public/*
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-level elision report (#646).
|
|
3
|
+
*
|
|
4
|
+
* A reporting layer over the `analyzeElision` verdict, NOT a second analysis
|
|
5
|
+
* and NOT a build (webjs is no-build; elision is the server's analysis pass,
|
|
6
|
+
* run at dev-server start and re-derived after each fs.watch rebuild). It
|
|
7
|
+
* builds the module graph, runs `analyzeElision`, and returns the page / layout
|
|
8
|
+
* route modules that SHIP WHOLE to the browser, each with the first
|
|
9
|
+
* client-effecting blocker that pins it (or its own signal when the module
|
|
10
|
+
* itself is the cause).
|
|
11
|
+
*
|
|
12
|
+
* Consumed by `webjs doctor` to ADVISE why a page/layout is not elided (an
|
|
13
|
+
* import-only #605 / inert #179 carrier stays out of the browser; a module that
|
|
14
|
+
* ships whole does not). Advisory only: a page legitimately MAY ship, and the
|
|
15
|
+
* analyser is biased toward shipping by design (server AGENTS invariant 7), so
|
|
16
|
+
* this never fails anything.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { buildModuleGraph } from './module-graph.js';
|
|
22
|
+
import { scanComponents } from './component-scanner.js';
|
|
23
|
+
import { buildRouteTable } from './router.js';
|
|
24
|
+
import { analyzeElision } from './component-elision.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string} appDir
|
|
28
|
+
* @returns {Promise<{ analysed: boolean, shipped: Array<{ file: string, blocker: string|null, reason: string }> }>}
|
|
29
|
+
*/
|
|
30
|
+
export async function analyzeAppElision(appDir) {
|
|
31
|
+
// No `app/` means this is not a routable app (a bare component library, a
|
|
32
|
+
// lib-only fixture); nothing ships, so there is nothing to advise on.
|
|
33
|
+
if (!(await pathExists(join(appDir, 'app')))) return { analysed: false, shipped: [] };
|
|
34
|
+
// Elision off (opt-out) ships everything by design, so the advisory is moot.
|
|
35
|
+
if (!(await readElideEnabled(appDir))) return { analysed: false, shipped: [] };
|
|
36
|
+
|
|
37
|
+
let moduleGraph, components, routeTable;
|
|
38
|
+
try {
|
|
39
|
+
moduleGraph = await buildModuleGraph(appDir);
|
|
40
|
+
components = await scanComponents(appDir);
|
|
41
|
+
routeTable = await buildRouteTable(appDir);
|
|
42
|
+
} catch {
|
|
43
|
+
// A malformed app the analysis cannot process degrades to no advice (the
|
|
44
|
+
// dev server and `webjs check` surface the real problem).
|
|
45
|
+
return { analysed: false, shipped: [] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Exactly the page + layout set the dev server feeds to analyzeElision, so
|
|
49
|
+
// the verdict matches (error / loading / not-found modules always ship and
|
|
50
|
+
// are never elision candidates, so they are not in scope here).
|
|
51
|
+
const routeModuleSet = new Set();
|
|
52
|
+
for (const page of routeTable.pages || []) {
|
|
53
|
+
if (page.file) routeModuleSet.add(page.file);
|
|
54
|
+
for (const f of page.layouts || []) routeModuleSet.add(f);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { shippedRouteModules } = await analyzeElision(
|
|
58
|
+
components, [...routeModuleSet], moduleGraph, (f) => readFile(f, 'utf8'), appDir,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const shipped = [...shippedRouteModules.entries()].map(([file, v]) => ({
|
|
62
|
+
file,
|
|
63
|
+
blocker: v.blocker,
|
|
64
|
+
reason: v.reason,
|
|
65
|
+
}));
|
|
66
|
+
return { analysed: true, shipped };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Mirror of the dev server's elide flag: WEBJS_ELIDE override, then webjs.elide. */
|
|
70
|
+
async function readElideEnabled(appDir) {
|
|
71
|
+
const raw = process.env.WEBJS_ELIDE;
|
|
72
|
+
if (raw != null) {
|
|
73
|
+
const v = raw.trim().toLowerCase();
|
|
74
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
75
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
79
|
+
if (pkg && pkg.webjs && pkg.webjs.elide === false) return false;
|
|
80
|
+
} catch {
|
|
81
|
+
// No package.json, malformed JSON, or unreadable: keep the default (on).
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} p @returns {Promise<boolean>} */
|
|
87
|
+
async function pathExists(p) {
|
|
88
|
+
try { await stat(p); return true; } catch { return false; }
|
|
89
|
+
}
|
package/src/html-cache.js
CHANGED
|
@@ -190,10 +190,10 @@ export async function writeHtmlCache(url, rec, revalidateSeconds) {
|
|
|
190
190
|
* - status 200 (an error / redirect / 404 is request-specific)
|
|
191
191
|
* - NOT a streamed Suspense body (it cannot be buffered cheaply, and an
|
|
192
192
|
* unflushed stream has no stable bytes to cache)
|
|
193
|
-
* - NO
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
193
|
+
* - NO Set-Cookie at all. A page that sets a session / per-user cookie is
|
|
194
|
+
* per-user output and must not be shared. SSR responses no longer carry a
|
|
195
|
+
* framework cookie (action CSRF is an Origin / Sec-Fetch-Site check, not a
|
|
196
|
+
* token cookie), so ANY Set-Cookie means per-user, do not cache.
|
|
197
197
|
* - CSP is OFF. With CSP enabled the inline boot script carries a fresh
|
|
198
198
|
* per-request nonce, so the body varies per request and a cached body
|
|
199
199
|
* would replay a stale nonce that the response's CSP header rejects.
|
|
@@ -206,36 +206,10 @@ export function isCacheableResponse(res, guards = {}) {
|
|
|
206
206
|
if (res.status !== 200) return false;
|
|
207
207
|
if (res.headers.has(STREAM_MARKER)) return false;
|
|
208
208
|
if (guards.cspEnabled) return false;
|
|
209
|
-
if (
|
|
209
|
+
if (res.headers.has('set-cookie')) return false;
|
|
210
210
|
return true;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
-
/**
|
|
214
|
-
* True when the response carries a Set-Cookie OTHER than the framework's
|
|
215
|
-
* own CSRF cookie. Reads each cookie individually via `getSetCookie()`, the
|
|
216
|
-
* only correct way to enumerate multiple Set-Cookie values (a combined
|
|
217
|
-
* `get('set-cookie')` cannot be split safely, since a cookie value or an
|
|
218
|
-
* Expires date can contain a comma). When `getSetCookie` is unavailable
|
|
219
|
-
* (a runtime older than Node 24) this FAILS SAFE: it reports a
|
|
220
|
-
* non-framework cookie (do not cache) rather than parsing only the first of
|
|
221
|
-
* a combined header and wrongly judging it framework-only.
|
|
222
|
-
*
|
|
223
|
-
* @param {Response} res
|
|
224
|
-
* @returns {boolean}
|
|
225
|
-
*/
|
|
226
|
-
function hasNonFrameworkSetCookie(res) {
|
|
227
|
-
const h = res.headers;
|
|
228
|
-
if (typeof h.getSetCookie !== 'function') {
|
|
229
|
-
// No reliable per-cookie enumeration: fail safe (treat as per-user).
|
|
230
|
-
return h.has('set-cookie');
|
|
231
|
-
}
|
|
232
|
-
for (const c of h.getSetCookie()) {
|
|
233
|
-
const name = c.split('=', 1)[0].trim().toLowerCase();
|
|
234
|
-
if (name !== 'webjs_csrf') return true;
|
|
235
|
-
}
|
|
236
|
-
return false;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
213
|
/**
|
|
240
214
|
* Evict the cached HTML for one path (server-side, on-demand
|
|
241
215
|
* revalidation: the no-build ISR revalidation hook). A server action that
|
package/src/listener-bun.js
CHANGED
|
@@ -349,7 +349,7 @@ function maybeCompress(resp, req) {
|
|
|
349
349
|
* on Bun too.
|
|
350
350
|
* @param {{ requestTimeout?: number } | undefined} timeouts
|
|
351
351
|
*/
|
|
352
|
-
function bunIdleTimeout(timeouts) {
|
|
352
|
+
export function bunIdleTimeout(timeouts) {
|
|
353
353
|
const reqMs = timeouts && timeouts.requestTimeout;
|
|
354
354
|
if (reqMs === 0) return 0;
|
|
355
355
|
let secs = Math.ceil((reqMs || 30_000) / 1000);
|
package/src/ssr.js
CHANGED
|
@@ -5,7 +5,6 @@ import { importMapTag, vendorIntegrityFor, publishedBuildId, basePath, vendorPre
|
|
|
5
5
|
import { withBasePath } from './base-path.js';
|
|
6
6
|
import { withAssetHash } from './asset-hash.js';
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
|
-
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
9
8
|
import { transitiveDeps } from './module-graph.js';
|
|
10
9
|
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
11
10
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
@@ -316,7 +315,8 @@ export async function ssrNotFound(notFoundFile, opts) {
|
|
|
316
315
|
}
|
|
317
316
|
|
|
318
317
|
/**
|
|
319
|
-
* Build an HTML Response
|
|
318
|
+
* Build an HTML Response. Sets no cookie: action CSRF is an Origin /
|
|
319
|
+
* Sec-Fetch-Site check, so the page response is cookieless (CDN-cacheable).
|
|
320
320
|
* @param {string} html
|
|
321
321
|
* @param {number} status
|
|
322
322
|
* @param {Request | undefined} req
|
|
@@ -335,10 +335,6 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
335
335
|
// final, so a warming response is reload-safe. See router-client.js
|
|
336
336
|
// applySwap and publishedBuildId() in importmap.js.
|
|
337
337
|
headers.set('x-webjs-build', publishedBuildId());
|
|
338
|
-
if (req && !readToken(req)) {
|
|
339
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
340
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
341
|
-
}
|
|
342
338
|
// Buffered (string) body: opt into the conditional-GET funnel so a
|
|
343
339
|
// PUBLIC-cacheable page (metadata.cacheControl) gets a weak ETag + 304.
|
|
344
340
|
// The funnel still excludes the no-store default, so a private page is
|
|
@@ -350,10 +346,10 @@ function htmlResponse(html, status, req, url, metadata) {
|
|
|
350
346
|
/**
|
|
351
347
|
* Rebuild a Response from a cached HTML record (#241). The stored body is
|
|
352
348
|
* the stable per-page HTML; the per-response varying bits are re-minted
|
|
353
|
-
* here so a new visitor still gets them: the
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
349
|
+
* here so a new visitor still gets them: the published build id is re-read so
|
|
350
|
+
* a post-deploy client sees the current id. No cookie is set (action CSRF is
|
|
351
|
+
* an Origin / Sec-Fetch-Site check), which is what keeps a cached page
|
|
352
|
+
* cookieless and shareable. The BUFFERED marker opts the cached body into
|
|
357
353
|
* the conditional-GET funnel exactly as a fresh render does, so a cached
|
|
358
354
|
* PUBLIC-cacheable page still 304s. Output is observably identical to the
|
|
359
355
|
* fresh render of the same route within the window.
|
|
@@ -366,10 +362,6 @@ function cachedHtmlResponse(rec, req, url) {
|
|
|
366
362
|
const headers = new Headers({ 'content-type': rec.contentType });
|
|
367
363
|
headers.set('cache-control', rec.cacheControl);
|
|
368
364
|
headers.set('x-webjs-build', publishedBuildId());
|
|
369
|
-
if (req && !readToken(req)) {
|
|
370
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
371
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
372
|
-
}
|
|
373
365
|
headers.set(BUFFERED_MARKER, '1');
|
|
374
366
|
return new Response(rec.body, { status: rec.status, headers });
|
|
375
367
|
}
|
|
@@ -1525,10 +1517,6 @@ function streamingHtmlResponse(prefix, bodyHtml, closer, ctx, status, req, url,
|
|
|
1525
1517
|
// See htmlResponse: published build id on every response for the
|
|
1526
1518
|
// client router's importmap-mismatch detection on partial swaps.
|
|
1527
1519
|
headers.set('x-webjs-build', publishedBuildId());
|
|
1528
|
-
if (req && !readToken(req)) {
|
|
1529
|
-
const secure = url ? url.protocol === 'https:' : false;
|
|
1530
|
-
headers.append('set-cookie', cookieHeader(newToken(), { secure }));
|
|
1531
|
-
}
|
|
1532
1520
|
|
|
1533
1521
|
if (!ctx.pending.length) {
|
|
1534
1522
|
// No pending boundaries: this degrades to a single buffered (string)
|
package/src/testing.d.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Type overlay for `@webjsdev/server/testing` (the handle() test harness, #267).
|
|
3
3
|
*
|
|
4
4
|
* The runtime is packages/server/src/testing.js (JSDoc-annotated JavaScript);
|
|
5
|
-
* this overlay exists so a TypeScript app's `import { testRequest,
|
|
6
|
-
* from '@webjsdev/server/testing'` resolves precise types instead of emitting
|
|
5
|
+
* this overlay exists so a TypeScript app's `import { testRequest,
|
|
6
|
+
* invokeActionForTest } from '@webjsdev/server/testing'` resolves precise types instead of emitting
|
|
7
7
|
* TS7016. The same declarations are re-exported from the package's main
|
|
8
8
|
* `index.d.ts` (the helpers ship from both entry points). Zero runtime cost.
|
|
9
9
|
*/
|
|
@@ -14,16 +14,6 @@ export type Handle = (req: Request) => Promise<Response> | Response;
|
|
|
14
14
|
/** A `createRequestHandler` return value, or a bare `handle` function. */
|
|
15
15
|
export type AppOrHandle = { handle: Handle; appDir: string } | Handle;
|
|
16
16
|
|
|
17
|
-
/** A CSRF token + cookie + header-name triple minted off the first SSR response. */
|
|
18
|
-
export interface CsrfPair {
|
|
19
|
-
/** The bare token value for the `x-webjs-csrf` request header. */
|
|
20
|
-
token: string;
|
|
21
|
-
/** The header NAME (`x-webjs-csrf`). */
|
|
22
|
-
header: string;
|
|
23
|
-
/** The `name=value` cookie string for the `Cookie` request header. */
|
|
24
|
-
cookie: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
17
|
/**
|
|
28
18
|
* Coerce a bare path, full URL string, or pre-built `Request` into a `Request`.
|
|
29
19
|
* A bare path is prefixed with a dummy origin; `init` is merged when provided.
|
|
@@ -40,21 +30,12 @@ export declare function testRequest(
|
|
|
40
30
|
init?: RequestInit,
|
|
41
31
|
): Promise<Response>;
|
|
42
32
|
|
|
43
|
-
/** Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s). */
|
|
44
|
-
export declare function readCsrfCookie(res: Response): string | null;
|
|
45
|
-
|
|
46
33
|
/** Collect all `Set-Cookie` header values off a response. */
|
|
47
34
|
export declare function getSetCookies(res: Response): string[];
|
|
48
35
|
|
|
49
36
|
/** Reduce raw `Set-Cookie` strings to a single `name=value; name2=value2` `Cookie` header value. */
|
|
50
37
|
export declare function cookiesToHeader(setCookies: string[]): string;
|
|
51
38
|
|
|
52
|
-
/**
|
|
53
|
-
* Mint a `{ token, header, cookie }` CSRF triple off the first SSR response.
|
|
54
|
-
* `path` is the GET path used to issue the cookie (default `/`).
|
|
55
|
-
*/
|
|
56
|
-
export declare function getCsrf(handle: Handle, path?: string): Promise<CsrfPair>;
|
|
57
|
-
|
|
58
39
|
/** Merge an extra cookie string into a `RequestInit`'s `Cookie` header. */
|
|
59
40
|
export declare function withCookies(init: RequestInit | undefined, cookieValue: string): RequestInit;
|
|
60
41
|
|
|
@@ -89,7 +70,8 @@ export declare function actionEndpoint(
|
|
|
89
70
|
|
|
90
71
|
/**
|
|
91
72
|
* Round-trip an action through its REAL RPC endpoint (serializer + CSRF + prod
|
|
92
|
-
* error sanitization) and return the parsed return value.
|
|
73
|
+
* error sanitization) and return the parsed return value. Models a same-origin
|
|
74
|
+
* browser POST, so it passes the Origin / Sec-Fetch-Site CSRF check.
|
|
93
75
|
*/
|
|
94
76
|
export declare function invokeActionForTest<T = unknown>(
|
|
95
77
|
app: AppOrHandle,
|
|
@@ -97,7 +79,6 @@ export declare function invokeActionForTest<T = unknown>(
|
|
|
97
79
|
fnName: string,
|
|
98
80
|
args?: unknown[],
|
|
99
81
|
opts?: {
|
|
100
|
-
csrf?: CsrfPair;
|
|
101
82
|
appDir?: string;
|
|
102
83
|
extraCookies?: string;
|
|
103
84
|
throwOnError?: boolean;
|
|
@@ -106,7 +87,9 @@ export declare function invokeActionForTest<T = unknown>(
|
|
|
106
87
|
|
|
107
88
|
/**
|
|
108
89
|
* Lower-level variant of `invokeActionForTest` that returns the raw `Response`
|
|
109
|
-
* (never throws on a non-2xx), so a test can assert the status directly.
|
|
90
|
+
* (never throws on a non-2xx), so a test can assert the status directly. Set
|
|
91
|
+
* `crossOrigin` (true or a specific origin) to model a cross-site request and
|
|
92
|
+
* assert the 403.
|
|
110
93
|
*/
|
|
111
94
|
export declare function rawActionRequest(
|
|
112
95
|
app: AppOrHandle,
|
|
@@ -114,8 +97,7 @@ export declare function rawActionRequest(
|
|
|
114
97
|
fnName: string,
|
|
115
98
|
args?: unknown[],
|
|
116
99
|
opts?: {
|
|
117
|
-
|
|
118
|
-
omitCsrf?: boolean;
|
|
100
|
+
crossOrigin?: boolean | string;
|
|
119
101
|
appDir?: string;
|
|
120
102
|
extraCookies?: string;
|
|
121
103
|
contentType?: string;
|
package/src/testing.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* ```js
|
|
20
20
|
* import { createRequestHandler } from '@webjsdev/server';
|
|
21
|
-
* import { testRequest,
|
|
21
|
+
* import { testRequest, invokeActionForTest } from '@webjsdev/server/testing';
|
|
22
22
|
*
|
|
23
23
|
* const app = await createRequestHandler({ appDir: process.cwd(), dev: true });
|
|
24
24
|
*
|
|
@@ -33,7 +33,6 @@
|
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
35
|
import { hashFile, RPC_CONTENT_TYPE } from './actions.js';
|
|
36
|
-
import { CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
37
36
|
import { getSerializer } from './serializer.js';
|
|
38
37
|
import { join, sep } from 'node:path';
|
|
39
38
|
|
|
@@ -83,24 +82,6 @@ export function testRequest(handle, input, init) {
|
|
|
83
82
|
return handle(toRequest(input, init));
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
/**
|
|
87
|
-
* Read the `webjs_csrf` cookie value off a response's `Set-Cookie` header(s).
|
|
88
|
-
* Returns null when the response set no CSRF cookie.
|
|
89
|
-
*
|
|
90
|
-
* @param {Response} res
|
|
91
|
-
* @returns {string | null}
|
|
92
|
-
*/
|
|
93
|
-
export function readCsrfCookie(res) {
|
|
94
|
-
const cookies = getSetCookies(res);
|
|
95
|
-
for (const c of cookies) {
|
|
96
|
-
const m = new RegExp(`(?:^|\\s)${CSRF_COOKIE}=([^;]+)`).exec(c);
|
|
97
|
-
if (m) {
|
|
98
|
-
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
85
|
/**
|
|
105
86
|
* Collect every `Set-Cookie` value off a `Response`, preferring the
|
|
106
87
|
* structured `getSetCookie()` (which keeps multiple cookies separate) and
|
|
@@ -134,32 +115,6 @@ export function cookiesToHeader(setCookies) {
|
|
|
134
115
|
.join('; ');
|
|
135
116
|
}
|
|
136
117
|
|
|
137
|
-
/**
|
|
138
|
-
* Do an initial GET to mint a CSRF token, returning the `{ cookie, token }`
|
|
139
|
-
* pair so a test can send a CSRF-valid action request. `cookie` is a ready-made
|
|
140
|
-
* `Cookie` request-header value; `token` is the bare value for the
|
|
141
|
-
* `x-webjs-csrf` header. Reuses the REAL cookie / header names from `csrf.js`.
|
|
142
|
-
*
|
|
143
|
-
* @param {Handle} handle
|
|
144
|
-
* @param {string} [path] the GET path used to issue the cookie (default `/`)
|
|
145
|
-
* @returns {Promise<{ cookie: string, token: string, header: string }>}
|
|
146
|
-
*/
|
|
147
|
-
export async function getCsrf(handle, path = '/') {
|
|
148
|
-
const res = await testRequest(handle, path);
|
|
149
|
-
const token = readCsrfCookie(res);
|
|
150
|
-
if (!token) {
|
|
151
|
-
throw new Error(
|
|
152
|
-
`getCsrf: GET ${path} issued no ${CSRF_COOKIE} cookie. The first SSR ` +
|
|
153
|
-
`response issues it; hit a real page route (or pass a path that renders).`,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
token,
|
|
158
|
-
header: CSRF_HEADER,
|
|
159
|
-
cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}`,
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
118
|
/**
|
|
164
119
|
* Merge an extra cookie string into a `RequestInit`'s `Cookie` header,
|
|
165
120
|
* preserving any cookie already on `init.headers`.
|
|
@@ -301,7 +256,6 @@ function isAbsolutePath(p) {
|
|
|
301
256
|
* @param {string} fnName the exported action function name
|
|
302
257
|
* @param {unknown[]} [args] positional args (serialized as the stub sends an arg array)
|
|
303
258
|
* @param {{
|
|
304
|
-
* csrf?: { cookie: string, token: string },
|
|
305
259
|
* appDir?: string,
|
|
306
260
|
* extraCookies?: string,
|
|
307
261
|
* throwOnError?: boolean,
|
|
@@ -320,23 +274,17 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
|
|
|
320
274
|
const throwOnError = opts.throwOnError !== false;
|
|
321
275
|
|
|
322
276
|
const endpoint = await actionEndpoint(appDir, serverFilePath, fnName);
|
|
323
|
-
const csrf = opts.csrf || (await getCsrf(handle));
|
|
324
277
|
|
|
325
278
|
const serializer = getSerializer();
|
|
326
279
|
const body = await serializer.serialize(args);
|
|
327
280
|
|
|
328
|
-
|
|
329
|
-
|
|
281
|
+
// Model a same-origin browser POST (the action CSRF check reads
|
|
282
|
+
// `Sec-Fetch-Site`, no token / cookie needed).
|
|
283
|
+
/** @type {Record<string,string>} */
|
|
284
|
+
const headers = { 'content-type': RPC_CONTENT_TYPE, 'sec-fetch-site': 'same-origin' };
|
|
285
|
+
if (opts.extraCookies) headers.cookie = opts.extraCookies;
|
|
330
286
|
|
|
331
|
-
const res = await testRequest(handle, endpoint, {
|
|
332
|
-
method: 'POST',
|
|
333
|
-
headers: {
|
|
334
|
-
'content-type': RPC_CONTENT_TYPE,
|
|
335
|
-
[CSRF_HEADER]: csrf.token,
|
|
336
|
-
cookie,
|
|
337
|
-
},
|
|
338
|
-
body,
|
|
339
|
-
});
|
|
287
|
+
const res = await testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
340
288
|
|
|
341
289
|
const text = await res.text();
|
|
342
290
|
const ct = res.headers.get('content-type') || '';
|
|
@@ -357,17 +305,17 @@ export async function invokeActionForTest(app, serverFilePath, fnName, args = []
|
|
|
357
305
|
|
|
358
306
|
/**
|
|
359
307
|
* Lower-level variant of `invokeActionForTest` that returns the raw `Response`
|
|
360
|
-
* (never throws on a non-2xx), so a test can assert on the status directly
|
|
361
|
-
*
|
|
362
|
-
* `opts.
|
|
308
|
+
* (never throws on a non-2xx), so a test can assert on the status directly.
|
|
309
|
+
* By default it models a same-origin browser POST (passes the CSRF check); set
|
|
310
|
+
* `opts.crossOrigin` (true, or a specific origin string) to model a cross-site
|
|
311
|
+
* request and assert the 403.
|
|
363
312
|
*
|
|
364
313
|
* @param {{ handle: Handle, appDir: string } | Handle} app
|
|
365
314
|
* @param {string} serverFilePath
|
|
366
315
|
* @param {string} fnName
|
|
367
316
|
* @param {unknown[]} [args]
|
|
368
317
|
* @param {{
|
|
369
|
-
*
|
|
370
|
-
* omitCsrf?: boolean,
|
|
318
|
+
* crossOrigin?: boolean | string,
|
|
371
319
|
* appDir?: string,
|
|
372
320
|
* extraCookies?: string,
|
|
373
321
|
* contentType?: string,
|
|
@@ -386,15 +334,13 @@ export async function rawActionRequest(app, serverFilePath, fnName, args = [], o
|
|
|
386
334
|
|
|
387
335
|
/** @type {Record<string,string>} */
|
|
388
336
|
const headers = { 'content-type': opts.contentType || RPC_CONTENT_TYPE };
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
headers
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
headers.cookie = cookie;
|
|
395
|
-
} else if (opts.extraCookies) {
|
|
396
|
-
headers.cookie = opts.extraCookies;
|
|
337
|
+
if (opts.crossOrigin) {
|
|
338
|
+
headers['sec-fetch-site'] = 'cross-site';
|
|
339
|
+
headers.origin = typeof opts.crossOrigin === 'string' ? opts.crossOrigin : 'https://evil.example';
|
|
340
|
+
} else {
|
|
341
|
+
headers['sec-fetch-site'] = 'same-origin';
|
|
397
342
|
}
|
|
343
|
+
if (opts.extraCookies) headers.cookie = opts.extraCookies;
|
|
398
344
|
|
|
399
345
|
return testRequest(handle, endpoint, { method: 'POST', headers, body });
|
|
400
346
|
}
|
package/webjs-config.schema.json
CHANGED
|
@@ -96,6 +96,12 @@
|
|
|
96
96
|
"type": "string",
|
|
97
97
|
"default": ""
|
|
98
98
|
},
|
|
99
|
+
"allowedOrigins": {
|
|
100
|
+
"description": "Cross-origin hosts (or full origins) the action CSRF check accepts even when the request is cross-site. The action endpoint defends with a Sec-Fetch-Site check plus an Origin-vs-host fallback; list additional origins a reverse-proxy / multi-domain setup must allow here. A bare host or a full origin are both accepted. Read by readAllowedOrigins in csrf.js. Default [] (same-origin only).",
|
|
101
|
+
"type": "array",
|
|
102
|
+
"items": { "type": "string" },
|
|
103
|
+
"default": []
|
|
104
|
+
},
|
|
99
105
|
"csp": {
|
|
100
106
|
"description": "Content-Security-Policy config. Off by default. true enables a strict nonce-based default policy. An object customizes directives and report-only mode. Read by readCspConfig in csp.js.",
|
|
101
107
|
"oneOf": [
|