@webjsdev/server 0.8.33 → 0.8.35

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.33",
3
+ "version": "0.8.35",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
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 { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
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
- lines.push(`function __csrf() { const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }`);
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, csrf, sig) {`);
387
- lines.push(` const headers = { 'content-type': __CT }; if (csrf) headers[${J(CSRF_HEADER)}] = __csrf();`);
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', ${SAFE ? 'false' : 'true'}, sig);`);
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, headers: { ${J(CSRF_HEADER)}: __csrf() }, credentials: 'same-origin', signal: sig });`);
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, true, sig);`);
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: required for every verb except a safe GET (a read with no state
463
- // change, which is also browser-cacheable so it cannot carry a fresh token).
464
- if (!SAFE_VERBS.has(method) && !verifyCsrf(req)) {
465
- return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
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/check.js CHANGED
@@ -77,12 +77,12 @@ export const RULES = [
77
77
  {
78
78
  name: 'no-static-properties',
79
79
  description:
80
- 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults via the `default` option or in the constructor.',
80
+ 'Reactive properties must be declared via the `extends WebComponent({ … })` factory, never a hand-written `static properties = { … }` field in the class body. The factory types each field for you (no `declare` needed) and the runtime throws on a direct `static properties`. Migrate `class X extends WebComponent { static properties = { count: { type: Number } } }` to `class X extends WebComponent({ count: Number })`; use the `prop()` helper for options (`prop(Number, { reflect: true })`) and set defaults in the constructor after super().',
81
81
  },
82
82
  {
83
83
  name: 'reactive-props-no-class-field',
84
84
  description:
85
- 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default via the `default` property option (`prop(Number, { default: 0 })`) or by assigning in the constructor after super().',
85
+ 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
86
86
  },
87
87
  {
88
88
  name: 'array-prop-uses-array-type',
@@ -451,7 +451,7 @@ export async function checkConventions(appDir) {
451
451
  file: rel,
452
452
  message:
453
453
  '`static properties = { … }` is no longer supported; declare reactive properties via the `extends WebComponent({ … })` factory instead.',
454
- fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults via the `default` option or the constructor. Delete the `static properties` block and any `declare` fields for those props.',
454
+ fix: 'Move the properties into the factory call: `class X extends WebComponent({ count: Number })`. Use `prop(Number, { reflect: true })` for options and set defaults in the constructor after super(). Delete the `static properties` block and any `declare` fields for those props.',
455
455
  });
456
456
  }
457
457
  }
@@ -471,7 +471,7 @@ export async function checkConventions(appDir) {
471
471
  rule: 'reactive-props-no-class-field',
472
472
  file: rel,
473
473
  message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
474
- fix: `Remove the class-field initializer and set the default via the \`default\` option (\`prop(<Type>, { default: <value> })\`) or by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
474
+ fix: `Remove the class-field initializer and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
475
475
  });
476
476
  }
477
477
  }
@@ -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)) continue; // the module itself ships whole
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
- * Double-submit cookie CSRF protection for `/__webjs/action/*` RPC endpoints.
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
- * Flow:
10
- * 1. Every SSR response that lacks the cookie issues a fresh `webjs_csrf`
11
- * cookie with `SameSite=Lax; Path=/` (and `Secure` when over HTTPS).
12
- * The cookie is readable by JS (not HttpOnly) so the auto-generated
13
- * action stub can echo it.
14
- * 2. The action-stub `fetch` sends the token back in `x-webjs-csrf`.
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
- * This protects against classic CSRF (a malicious site triggering a POST
19
- * from a victim browser): cross-origin requests cannot read the cookie, so
20
- * they cannot set the header to the matching value.
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
- * Notes on scope:
23
- * - Applies to internal RPC only. A `route.ts` REST endpoint (hand-written
24
- * or via the `route()` adapter) is *not* CSRF-protected because it is
25
- * intended for external consumers; such an endpoint should carry its own
26
- * auth (bearer token, signed request, API key) the app provides via
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
- /** @param {Request} req */
64
- export function readToken(req) {
65
- return parseCookies(req)[CSRF_COOKIE] || null;
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
- * Serialise a Set-Cookie value for the CSRF token.
70
- * @param {string} token
71
- * @param {{ secure?: boolean }} [opts]
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 cookieHeader(token, opts = {}) {
74
- const parts = [
75
- `${CSRF_COOKIE}=${encodeURIComponent(token)}`,
76
- 'Path=/',
77
- 'SameSite=Lax',
78
- `Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
79
- ];
80
- if (opts.secure) parts.push('Secure');
81
- return parts.join('; ');
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
- * Constant-time verification of the double-submit.
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 verify(req) {
89
- const cookie = readToken(req);
90
- const header = req.headers.get(CSRF_HEADER);
91
- if (!cookie || !header) return false;
92
- if (cookie.length !== header.length) return false;
93
- let diff = 0;
94
- for (let i = 0; i < cookie.length; i++) diff |= cookie.charCodeAt(i) ^ header.charCodeAt(i);
95
- return diff === 0;
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 non-framework Set-Cookie. A page that sets a session / per-user
194
- * cookie is per-user output and must not be shared. The framework's own
195
- * CSRF cookie (`webjs_csrf`) is allowed and re-minted per response on a
196
- * cache hit, so its presence does not block caching.
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 (hasNonFrameworkSetCookie(res)) return false;
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
@@ -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 and, if missing, attach the CSRF cookie.
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 CSRF cookie is freshly issued
354
- * when the request lacks one (it is a Set-Cookie header, never part of the
355
- * cached body), and the published build id is re-read so a post-deploy
356
- * client sees the current id. The BUFFERED marker opts the cached body into
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, getCsrf }
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
- csrf?: CsrfPair | null;
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, getCsrf, invokeActionForTest } from '@webjsdev/server/testing';
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
- let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
329
- if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
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
- * (e.g. a CSRF-missing request returns 403). Send `opts.csrf = null` /
362
- * `opts.omitCsrf = true` to deliberately omit the CSRF header + cookie.
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
- * csrf?: { cookie: string, token: string } | null,
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 (!opts.omitCsrf && opts.csrf !== null) {
390
- const csrf = opts.csrf || (await getCsrf(handle));
391
- headers[CSRF_HEADER] = csrf.token;
392
- let cookie = csrf.cookie || `${CSRF_COOKIE}=${encodeURIComponent(csrf.token)}`;
393
- if (opts.extraCookies) cookie = `${cookie}; ${opts.extraCookies}`;
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/src/vendor.js CHANGED
@@ -412,7 +412,7 @@ async function jspmCall(installs, provider) {
412
412
  // A 5xx/429 is a transient jspm problem worth retrying. A 401/4xx
413
413
  // means at least one install is genuinely unresolvable (jspm uses
414
414
  // 401 for that): a private / workspace / server-only package (e.g.
415
- // @webjsdev/server, better-sqlite3) the browser never fetches
415
+ // @webjsdev/server, pg) the browser never fetches
416
416
  // anyway. Permanent failures must NOT block readiness.
417
417
  const transient = response.status >= 500 || response.status === 429;
418
418
  return { ok: false, imports: {}, transient };
@@ -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": [