@webjsdev/server 0.8.57 → 0.8.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +26 -2
- package/index.js +1 -0
- package/package.json +3 -3
- package/src/action-seed-bun.js +2 -2
- package/src/action-seed.js +364 -64
- package/src/actions.js +14 -4
- package/src/asset-hash.js +66 -4
- package/src/base-path.js +4 -1
- package/src/body-limit.js +4 -4
- package/src/check.js +123 -2
- package/src/conditional-get.js +32 -9
- package/src/context.js +1 -1
- package/src/csp.js +22 -6
- package/src/csrf.js +33 -12
- package/src/dev-error.js +13 -2
- package/src/dev-overlay.js +218 -6
- package/src/dev.js +111 -29
- package/src/form-action-identity.js +290 -0
- package/src/form-dispatch.js +500 -0
- package/src/forwarded.js +68 -10
- package/src/headers.js +9 -7
- package/src/html-cache.js +117 -13
- package/src/listener-bun.js +1 -1
- package/src/listener-core.js +35 -5
- package/src/rate-limit.js +31 -3
- package/src/route-types.js +1 -1
- package/src/ssr.js +172 -33
- package/src/stream.js +1 -1
- package/src/testing.d.ts +19 -0
- package/src/testing.js +113 -0
- package/src/vendor.js +85 -22
- package/webjs-config.schema.json +19 -3
- package/src/page-action.js +0 -259
package/src/actions.js
CHANGED
|
@@ -20,6 +20,7 @@ import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
|
20
20
|
import { getBodyLimits } from './context.js';
|
|
21
21
|
import { basePath } from './importmap.js';
|
|
22
22
|
import { withBasePath } from './base-path.js';
|
|
23
|
+
import { FORM_ACTION_ID_KEY } from '@webjsdev/core';
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* The JSON / RPC body cap in effect for the current request: the per-request
|
|
@@ -417,11 +418,18 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
|
417
418
|
lines.push(` return __body(fn, key, __METHOD, sig);`);
|
|
418
419
|
lines.push(`}`);
|
|
419
420
|
}
|
|
421
|
+
// Each stub carries its own `<hash>/<fn>` identity (#1155), so a client
|
|
422
|
+
// re-render of `<form action=${action}>` can bind the live form
|
|
423
|
+
// synchronously. The server resolves the REAL function's identity through the
|
|
424
|
+
// load-hook registry instead; both produce the same string, which is what
|
|
425
|
+
// makes the SSR'd form and the re-rendered one submit the same value.
|
|
426
|
+
// Non-enumerable so `import * as actions` and a spread stay unchanged.
|
|
427
|
+
lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return fn; };`);
|
|
420
428
|
for (const name of fnNames) {
|
|
421
429
|
lines.push(
|
|
422
430
|
name === 'default'
|
|
423
|
-
? `export default (...args) => __call('default', args);`
|
|
424
|
-
: `export const ${name} = (...args) => __call(${J(name)}, args);`,
|
|
431
|
+
? `export default __id((...args) => __call('default', args), 'default');`
|
|
432
|
+
: `export const ${name} = __id((...args) => __call(${J(name)}, args), ${J(name)});`,
|
|
425
433
|
);
|
|
426
434
|
}
|
|
427
435
|
return lines.join('\n') + '\n';
|
|
@@ -563,8 +571,10 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
|
|
|
563
571
|
* Build a GET action's response (#488): the serialized body plus a weak ETag
|
|
564
572
|
* (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
|
|
565
573
|
* and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
|
|
566
|
-
*
|
|
567
|
-
*
|
|
574
|
+
* deferring to the conditional-GET funnel: this branch already hashed the
|
|
575
|
+
* serialized body, so it can answer without the funnel re-reading it. (The
|
|
576
|
+
* funnel would now accept these too, since #1140 stopped excluding `private`,
|
|
577
|
+
* but the short-circuit here still runs first and is cheaper.)
|
|
568
578
|
* @param {unknown} result
|
|
569
579
|
* @param {Record<string, unknown>} mod
|
|
570
580
|
* @param {unknown[]} args
|
package/src/asset-hash.js
CHANGED
|
@@ -159,10 +159,32 @@ export function assetHashFor(absPath) {
|
|
|
159
159
|
const bytes = readFileSync(absPath);
|
|
160
160
|
const h = createHash('sha256').update(bytes);
|
|
161
161
|
// An app module's served body is elision-transformed, so fold the verdict
|
|
162
|
-
// fingerprint in (see `_elisionFp`). Core
|
|
163
|
-
// transformed, so they hash over their bytes alone
|
|
164
|
-
//
|
|
165
|
-
|
|
162
|
+
// fingerprint in (see `_elisionFp`). Core and `public/` files are never
|
|
163
|
+
// transformed, so they hash over their bytes alone, which the two
|
|
164
|
+
// exclusions below enforce rather than merely assume. Empty fp leaves an
|
|
165
|
+
// app module's hash at exactly `sha256(bytes)`.
|
|
166
|
+
//
|
|
167
|
+
// Both exclusions are EXPLICIT because the containment test alone admits
|
|
168
|
+
// them, and the intent above is otherwise silently false:
|
|
169
|
+
//
|
|
170
|
+
// - `public/` sits under `_appDir`. Inert while only module specifiers
|
|
171
|
+
// reached here, but `asset()` (#1194) routes public paths through this
|
|
172
|
+
// function.
|
|
173
|
+
// - `_coreDir` sits under `_appDir` too in a REAL install, where it
|
|
174
|
+
// resolves to `<appDir>/node_modules/@webjsdev/core`. It escapes only
|
|
175
|
+
// in this monorepo, where the workspace symlink lands outside, which
|
|
176
|
+
// is exactly why no in-repo test ever caught it.
|
|
177
|
+
//
|
|
178
|
+
// Neither file set is elision-transformed (core is served verbatim by
|
|
179
|
+
// `fileResponse`), so folding the verdict in buys nothing and costs
|
|
180
|
+
// correctness: any deploy that merely flips an UNRELATED component between
|
|
181
|
+
// elidable and interactive would move their urls, re-downloading the whole
|
|
182
|
+
// core runtime plus every marked stylesheet, image, and script even though
|
|
183
|
+
// not one of those bytes changed. That is the precise cost a content hash
|
|
184
|
+
// exists to avoid.
|
|
185
|
+
if (_elisionFp && _appDir && absPath.startsWith(_appDir + sep)
|
|
186
|
+
&& !absPath.startsWith(join(_appDir, 'public') + sep)
|
|
187
|
+
&& !(_coreDir && absPath.startsWith(_coreDir + sep))) {
|
|
166
188
|
h.update('\0');
|
|
167
189
|
h.update(_elisionFp);
|
|
168
190
|
}
|
|
@@ -267,6 +289,46 @@ export function withAssetHash(url, basePath = '') {
|
|
|
267
289
|
return `${url}${sepChar}v=${hash}`;
|
|
268
290
|
}
|
|
269
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Resolve one author-marked asset path to its fingerprinted url. This is the
|
|
294
|
+
* body behind the isomorphic `asset()` helper (#1194); `dev.js` installs it
|
|
295
|
+
* via `setAssetUrlProvider` at boot.
|
|
296
|
+
*
|
|
297
|
+
* The `public/` gate is defensive, not decorative. `resolveUrlToFile` maps any
|
|
298
|
+
* same-origin path under the PROJECT ROOT, so an app that passed request- or
|
|
299
|
+
* user-derived data in (`asset(user.avatarPath)`) would otherwise read and
|
|
300
|
+
* publish a content hash for `/.env`, `/db/app.db`, or a `.server.ts`, leaking
|
|
301
|
+
* both that the file exists and a fingerprint of its exact bytes for files the
|
|
302
|
+
* serve path deliberately 404s. Mirror the static-asset route instead: under
|
|
303
|
+
* `public/`, no traversal. Anything else returns unchanged WITHOUT touching
|
|
304
|
+
* the disk, so a rejected path costs nothing and reveals nothing.
|
|
305
|
+
*
|
|
306
|
+
* A fragment is split before hashing. `withAssetHash` appends the query, so
|
|
307
|
+
* passing the whole thing would emit `/x.svg#icon?v=H`: the browser then
|
|
308
|
+
* requests `/x.svg` with no query (losing the immutable caching this exists
|
|
309
|
+
* for) and the fragment becomes `icon?v=H`, matching no element id.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} p the author-supplied path
|
|
312
|
+
* @param {string} [bp] the active base path
|
|
313
|
+
* @returns {string} the fingerprinted url, or `p` unchanged
|
|
314
|
+
*/
|
|
315
|
+
export function resolveAssetUrl(p, bp = '') {
|
|
316
|
+
if (typeof p !== 'string' || p[0] !== '/' || p[1] === '/') return p;
|
|
317
|
+
let probe = p;
|
|
318
|
+
if (bp && probe.startsWith(bp + '/')) probe = probe.slice(bp.length);
|
|
319
|
+
const cuts = [probe.indexOf('?'), probe.indexOf('#')].filter((i) => i !== -1);
|
|
320
|
+
let decoded = probe.slice(0, cuts.length ? Math.min(...cuts) : probe.length);
|
|
321
|
+
try { decoded = decodeURIComponent(decoded); } catch { /* keep raw */ }
|
|
322
|
+
if (decoded.includes('..') || !decoded.startsWith('/public/')) return p;
|
|
323
|
+
const h = p.indexOf('#');
|
|
324
|
+
const path = h === -1 ? p : p.slice(0, h);
|
|
325
|
+
const frag = h === -1 ? '' : p.slice(h);
|
|
326
|
+
// An author-supplied query is left alone: it may carry meaning we do not
|
|
327
|
+
// own, and appending would merge with `&`.
|
|
328
|
+
if (path.includes('?')) return p;
|
|
329
|
+
return withAssetHash(path, bp) + frag;
|
|
330
|
+
}
|
|
331
|
+
|
|
270
332
|
/**
|
|
271
333
|
* Static `import` specifier with positional indices. Mirrors module-graph.js's
|
|
272
334
|
* `IMPORT_RE` (side-effect / default / namespace / named imports), but captures
|
package/src/base-path.js
CHANGED
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
* hydrates. `createRequestHandler` explicitly targets embedding, where a
|
|
12
12
|
* sub-path mount is the norm.
|
|
13
13
|
*
|
|
14
|
-
* The model is strip-at-ingress + prefix-on-emit
|
|
14
|
+
* The model is strip-at-ingress + prefix-on-emit. Two seams carry it, plus two
|
|
15
|
+
* further `stripBasePath` consumers on the CACHE path (`dev.js`'s HTML-cache
|
|
16
|
+
* write and `html-cache.js`'s `revalidatePath`), which key on the
|
|
17
|
+
* app-root-relative path so a mounted app hits its own cache:
|
|
15
18
|
*
|
|
16
19
|
* 1. STRIP AT INGRESS. At the very start of request handling, when the
|
|
17
20
|
* request pathname starts with the basePath, strip it so all
|
package/src/body-limit.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* webjs's prod server reads request bodies on three paths: the server-action
|
|
5
5
|
* RPC endpoint (actions.js), `route.{js,ts}` handlers via `readBody` (json.js),
|
|
6
|
-
* and the no-JS
|
|
6
|
+
* and the no-JS form-submission path (form-dispatch.js). Without a cap, an
|
|
7
7
|
* uncapped body is a memory-exhaustion vector. This module is the SINGLE place
|
|
8
8
|
* that decides the limit and performs a bounded read, so every body-read site
|
|
9
9
|
* enforces it uniformly.
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
export const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Default form / multipart body cap: 10 MiB. A form submission (the
|
|
34
|
+
* Default form / multipart body cap: 10 MiB. A form submission (the form-dispatch
|
|
35
35
|
* path) may legitimately carry more than a JSON RPC call (a textarea, a small
|
|
36
36
|
* upload), so it gets a separate, higher, still-bounded limit. Large file
|
|
37
37
|
* uploads are a distinct concern (#247) with their own streaming story.
|
|
@@ -40,7 +40,7 @@ export const DEFAULT_MAX_MULTIPART_BYTES = 10 * 1024 * 1024;
|
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* Thrown by `readBody` (json.js) when a route handler's body exceeds the limit.
|
|
43
|
-
* The RPC and
|
|
43
|
+
* The RPC and form-dispatch paths return a 413 Response inline, but `readBody`
|
|
44
44
|
* runs INSIDE a user route handler and returns parsed data, so it signals the
|
|
45
45
|
* over-limit case by throwing. The API dispatcher (`handleApi`) catches this and
|
|
46
46
|
* maps it to a 413, so a handler that just does `await readBody(req)` gets the
|
|
@@ -266,7 +266,7 @@ export async function readTextBounded(req, limit) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
/**
|
|
269
|
-
* Read a request body as `FormData`, bounded by `limit`. Used by the
|
|
269
|
+
* Read a request body as `FormData`, bounded by `limit`. Used by the form-dispatch
|
|
270
270
|
* form path. Reconstructs a bounded Request from the already-read bytes and
|
|
271
271
|
* defers to the platform `formData()` parser, so multipart and
|
|
272
272
|
* urlencoded bodies are decoded exactly as before, just size-checked first.
|
package/src/check.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, relative, sep, basename, dirname } from 'node:path';
|
|
|
3
3
|
import { walk } from './fs-walk.js';
|
|
4
4
|
import {
|
|
5
5
|
redactStringsAndTemplates,
|
|
6
|
+
redactToPlaceholders,
|
|
6
7
|
extractWebComponentClassBodies,
|
|
7
8
|
matchClosingBrace,
|
|
8
9
|
parsePropEntries,
|
|
@@ -134,6 +135,11 @@ export const RULES = [
|
|
|
134
135
|
description:
|
|
135
136
|
'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
|
|
136
137
|
},
|
|
138
|
+
{
|
|
139
|
+
name: 'form-action-not-a-get-action',
|
|
140
|
+
description:
|
|
141
|
+
'Flags `<form action=${someAction}>` bound to a `\'use server\'` action whose file declares `export const method = \'GET\'` (#488). A GET action is a READ: it is CSRF-exempt and rides its arguments in the url, while a form submission is a CSRF-checked POST carrying a body, so the two contracts contradict each other and the submission is answered with a 405 at runtime. The rule reads the imported action\'s own file, so it only fires when the binding really does resolve to a GET-declared action. Fix by dropping the `method` export (an action with no `method` is a POST, which is what a form wants) or by binding a different action; if the form really is a read, use a plain `<form method="get" action="/search">` with no bound action.',
|
|
142
|
+
},
|
|
137
143
|
{
|
|
138
144
|
name: 'no-redirect-in-api-route',
|
|
139
145
|
description:
|
|
@@ -475,6 +481,36 @@ function enumerableExports(scan) {
|
|
|
475
481
|
* @param {string} clause the text between `import` and `from`
|
|
476
482
|
* @returns {string[]|null}
|
|
477
483
|
*/
|
|
484
|
+
/**
|
|
485
|
+
* The `{ local, imported }` pairs a named import clause binds, for a rule that
|
|
486
|
+
* has to match the LOCAL name against code (`action=${reader}`) while reporting
|
|
487
|
+
* the IMPORTED one (`readIt`, which is what the target module calls it).
|
|
488
|
+
*
|
|
489
|
+
* `importedValueNames` cannot serve here: it returns only the imported side,
|
|
490
|
+
* which is the wrong name to match against a template hole under `as`.
|
|
491
|
+
*
|
|
492
|
+
* @param {string} clause the text between `import` and `from`
|
|
493
|
+
* @returns {{ local: string, imported: string }[]}
|
|
494
|
+
*/
|
|
495
|
+
function importedLocalNames(clause) {
|
|
496
|
+
if (/^\s*type\b/.test(clause)) return [];
|
|
497
|
+
const brace = clause.match(/\{([^}]*)\}/);
|
|
498
|
+
if (!brace) return [];
|
|
499
|
+
/** @type {{ local: string, imported: string }[]} */
|
|
500
|
+
const out = [];
|
|
501
|
+
for (const part of brace[1].split(',')) {
|
|
502
|
+
const seg = part.trim().replace(/^type\s+/, '');
|
|
503
|
+
if (!seg) continue;
|
|
504
|
+
const as = seg.split(/\s+as\s+/);
|
|
505
|
+
const imported = as[0].trim();
|
|
506
|
+
const local = (as[1] || as[0]).trim();
|
|
507
|
+
if (/^[A-Za-z_$][\w$]*$/.test(local) && /^[A-Za-z_$][\w$]*$/.test(imported)) {
|
|
508
|
+
out.push({ local, imported });
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
478
514
|
function importedValueNames(clause) {
|
|
479
515
|
if (/^\s*type\b/.test(clause)) return null; // import type { ... }
|
|
480
516
|
const brace = clause.match(/\{([^}]*)\}/);
|
|
@@ -489,8 +525,9 @@ function importedValueNames(clause) {
|
|
|
489
525
|
/**
|
|
490
526
|
* Scan a WebJs app directory and report convention violations.
|
|
491
527
|
*
|
|
492
|
-
* Every rule is a correctness check (a crash, a security leak,
|
|
493
|
-
* build/type-strip failure), so
|
|
528
|
+
* Every rule is a correctness check (a crash, a security leak, a reactive
|
|
529
|
+
* prop that silently stops re-rendering, or a build/type-strip failure), so
|
|
530
|
+
* they all run unconditionally. There is no
|
|
494
531
|
* per-project disabling: project conventions (layout, style, process) live in
|
|
495
532
|
* CONVENTIONS.md as guidance, not in this tool.
|
|
496
533
|
*
|
|
@@ -1333,6 +1370,90 @@ export async function checkConventions(appDir) {
|
|
|
1333
1370
|
}
|
|
1334
1371
|
}
|
|
1335
1372
|
|
|
1373
|
+
// --- Rule: form-action-not-a-get-action (#1155) ---
|
|
1374
|
+
// `<form action=${fn}>` binds a server action to a POST submission. An action
|
|
1375
|
+
// declaring `method = 'GET'` cannot serve one: it is CSRF-exempt and takes its
|
|
1376
|
+
// args in the url, so the dispatcher answers 405. Catch it at edit time.
|
|
1377
|
+
{
|
|
1378
|
+
// A `'use server'` file's declared method, or absent when it declares none.
|
|
1379
|
+
const methodByAbs = new Map();
|
|
1380
|
+
for (const f of files) {
|
|
1381
|
+
if (!/\.server\.m?[jt]s$/.test(f.rel)) continue;
|
|
1382
|
+
if (!hasUseServerDirective(f.content)) continue;
|
|
1383
|
+
const m = /\bexport\s+const\s+method\s*(?::[^=]*)?=\s*['"`]([A-Za-z]+)['"`]/.exec(f.scan);
|
|
1384
|
+
if (m) methodByAbs.set(f.abs, m[1].toUpperCase());
|
|
1385
|
+
}
|
|
1386
|
+
if (methodByAbs.size) {
|
|
1387
|
+
const reImport = /\bimport\s+([^'";]*?)\bfrom\s*(['"])/g;
|
|
1388
|
+
for (const { abs, rel, content } of files) {
|
|
1389
|
+
// PLACEHOLDER redaction, not either mask, and the choice is forced.
|
|
1390
|
+
// The default mask blanks a tagged template outright, so a real
|
|
1391
|
+
// `html`<form action=${fn}>`` disappears and the rule could never fire;
|
|
1392
|
+
// the fully-blanked mask does the same. Placeholder mode keeps `${...}`
|
|
1393
|
+
// holes as REAL code while turning each literal BODY into one opaque
|
|
1394
|
+
// `__STR_n__` token, so a live binding is readable and a `<form
|
|
1395
|
+
// action=${x}>` shown as text inside a docs sample is a single token
|
|
1396
|
+
// with no hole in it. That distinction is the whole carve-out: the
|
|
1397
|
+
// framework's own website renders this exact shape as a code sample.
|
|
1398
|
+
const { redacted, literals } = redactToPlaceholders(content);
|
|
1399
|
+
// A hole binds an action when the literal segment IMMEDIATELY before it
|
|
1400
|
+
// ends inside a start tag at the attribute that tag can really bind:
|
|
1401
|
+
// `action=` on a `<form>` (#1155), or `formaction=` on a `<button>` /
|
|
1402
|
+
// `<input>` submitter (#1207). Reading the segment rather than the whole
|
|
1403
|
+
// template is what keeps `<div action=${x}>` out. The tag and the
|
|
1404
|
+
// attribute are matched as a PAIR, so `<form formaction=${x}>` and
|
|
1405
|
+
// `<button action=${x}>` stay out too: both are refused at render time,
|
|
1406
|
+
// and neither is this rule's failure mode.
|
|
1407
|
+
const bound = new Set();
|
|
1408
|
+
for (const m of redacted.matchAll(/__STR_(\d+)__\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g)) {
|
|
1409
|
+
const before = literals[Number(m[1])] || '';
|
|
1410
|
+
// The last `<` opens the tag the hole sits in. Requiring no `>`
|
|
1411
|
+
// after it keeps the match inside that one start tag.
|
|
1412
|
+
const tagAt = before.lastIndexOf('<');
|
|
1413
|
+
if (tagAt < 0) continue;
|
|
1414
|
+
const tag = before.slice(tagAt);
|
|
1415
|
+
if (tag.includes('>')) continue;
|
|
1416
|
+
const bindsForm = /^<form\b/i.test(tag) && /\saction=$/i.test(tag);
|
|
1417
|
+
const bindsSubmitter = /^<(?:button|input)\b/i.test(tag) && /\sformaction=$/i.test(tag);
|
|
1418
|
+
if (!bindsForm && !bindsSubmitter) continue;
|
|
1419
|
+
bound.add(m[2]);
|
|
1420
|
+
}
|
|
1421
|
+
if (!bound.size) continue;
|
|
1422
|
+
reImport.lastIndex = 0;
|
|
1423
|
+
let im;
|
|
1424
|
+
while ((im = reImport.exec(redacted))) {
|
|
1425
|
+
// The specifier is a placeholder token in this view, so read the real
|
|
1426
|
+
// one back out of `literals`.
|
|
1427
|
+
const tok = /__STR_(\d+)__/.exec(redacted.slice(reImport.lastIndex, reImport.lastIndex + 24));
|
|
1428
|
+
if (!tok) continue;
|
|
1429
|
+
const spec = literals[Number(tok[1])] || '';
|
|
1430
|
+
if (!/^(?:\.|#)/.test(spec)) continue;
|
|
1431
|
+
const names = importedLocalNames(im[1]);
|
|
1432
|
+
if (!names.length) continue;
|
|
1433
|
+
const target = resolveImport(spec, abs, appDir);
|
|
1434
|
+
// ONLY a GET is contradictory, and the rule must say exactly what the
|
|
1435
|
+
// dispatcher does or it is a false positive. A browser form always
|
|
1436
|
+
// submits as a POST, and the dispatcher runs a PUT / PATCH / DELETE
|
|
1437
|
+
// action on that POST: the declared verb governs the RPC transport,
|
|
1438
|
+
// not whether the function can serve a form. A GET is different in
|
|
1439
|
+
// kind (CSRF-exempt, args in the url, no body), which is why it is
|
|
1440
|
+
// the one the dispatcher answers with a 405.
|
|
1441
|
+
const method = target ? methodByAbs.get(target) : undefined;
|
|
1442
|
+
if (method !== 'GET') continue;
|
|
1443
|
+
for (const { local, imported } of names) {
|
|
1444
|
+
if (!bound.has(local)) continue;
|
|
1445
|
+
violations.push({
|
|
1446
|
+
rule: 'form-action-not-a-get-action',
|
|
1447
|
+
file: rel,
|
|
1448
|
+
message: `Binds \`${imported}\` to a form, but \`${spec}\` declares \`method = 'GET'\`. A GET action rides its arguments in the url and skips the CSRF check, so it cannot answer a form POST; the submission is a 405.`,
|
|
1449
|
+
fix: `Drop the \`method\` export from \`${spec}\` (an action with no method is a POST, which is what a form submits), or bind an action that takes a POST.`,
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1336
1457
|
return violations;
|
|
1337
1458
|
}
|
|
1338
1459
|
|
package/src/conditional-get.js
CHANGED
|
@@ -10,9 +10,15 @@
|
|
|
10
10
|
* uniformly.
|
|
11
11
|
*
|
|
12
12
|
* What is EXCLUDED, and why:
|
|
13
|
-
* - `no-store`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - `no-store` responses (the default for a dynamic page), which forbid
|
|
14
|
+
* storage outright, so there is nothing to validate. `private` is NOT
|
|
15
|
+
* excluded: it forbids SHARED storage, not validation, and the ETag hashes
|
|
16
|
+
* THIS response's body, so two users with different bodies get different
|
|
17
|
+
* ETags and neither can match the other's, while two users with identical
|
|
18
|
+
* bodies are asking about identical bytes, where a 304 discloses nothing
|
|
19
|
+
* (#1140).
|
|
20
|
+
* That is what lets the client router's partial responses stay `private`
|
|
21
|
+
* without losing their 304s.
|
|
16
22
|
* - Any body the framework did not positively mark as fully buffered. The
|
|
17
23
|
* funnel only hashes a response that ALREADY carries an `ETag` (a serve
|
|
18
24
|
* branch hashed its own bytes) OR carries the internal `X-Webjs-Buffered`
|
|
@@ -77,11 +83,28 @@ const STRIP_ON_304 = ['content-length', 'content-encoding', 'content-type'];
|
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
85
|
* Is this response cacheable enough to carry a validator? Cacheable means a
|
|
80
|
-
* 200 whose `Cache-Control` is present and does not forbid
|
|
81
|
-
* `no-store`
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
86
|
+
* 200 whose `Cache-Control` is present and does not forbid STORAGE. Only
|
|
87
|
+
* `no-store` is excluded. `no-cache` is INCLUDED: it means "revalidate before
|
|
88
|
+
* reuse", and a 304 is exactly that revalidation answer, so dev's `no-cache`
|
|
89
|
+
* assets still benefit.
|
|
90
|
+
*
|
|
91
|
+
* `private` is INCLUDED too (#1140). It was excluded on the theory that
|
|
92
|
+
* per-user content must never get a "cross-session" 304, but that cannot
|
|
93
|
+
* happen: the ETag is a hash of THIS response's body, and a 304 is returned
|
|
94
|
+
* only when the client's own `If-None-Match` matches it. Two users with
|
|
95
|
+
* different bodies get different ETags, so neither can ever match the other's;
|
|
96
|
+
* two users with identical bodies are asking about identical bytes, where a
|
|
97
|
+
* 304 is the correct answer and carries no body either way. `private` already
|
|
98
|
+
* forbids the one real hazard, a SHARED cache storing the response.
|
|
99
|
+
*
|
|
100
|
+
* Widening this is what makes #1140's fragment downgrade safe to ship. Before
|
|
101
|
+
* that change a reduced fragment INHERITED the page's public header, so it
|
|
102
|
+
* already passed this check and already carried a validator; marking fragments
|
|
103
|
+
* `private` would have taken that away, and since the router fetches them with
|
|
104
|
+
* `cache: 'no-cache'` (#1131), every prefetch and soft navigation would have
|
|
105
|
+
* re-downloaded the whole fragment. The exclusion is dropped rather than
|
|
106
|
+
* special-cased because it was never justified on its own terms either, per
|
|
107
|
+
* the reasoning above.
|
|
85
108
|
*
|
|
86
109
|
* @param {Response} res
|
|
87
110
|
* @returns {boolean}
|
|
@@ -90,7 +113,7 @@ function isCacheable(res) {
|
|
|
90
113
|
if (res.status !== 200) return false;
|
|
91
114
|
const cc = res.headers.get('cache-control');
|
|
92
115
|
if (!cc) return false;
|
|
93
|
-
return !/(?:^|,)\s*
|
|
116
|
+
return !/(?:^|,)\s*no-store\s*(?:,|$)/i.test(cc);
|
|
94
117
|
}
|
|
95
118
|
|
|
96
119
|
/**
|
package/src/context.js
CHANGED
|
@@ -17,7 +17,7 @@ import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
|
17
17
|
*
|
|
18
18
|
* `bodyLimits` holds the resolved request body-size caps (issue #237) so
|
|
19
19
|
* `readBody` (used inside `route.{js,ts}` handlers, which have no handle to the
|
|
20
|
-
* server state) can enforce the same limit the RPC and
|
|
20
|
+
* server state) can enforce the same limit the RPC and form-dispatch paths do. The
|
|
21
21
|
* handler writes it per request via `setBodyLimits`.
|
|
22
22
|
*
|
|
23
23
|
* `requestId` holds the per-request correlation id (issue #239). The handler
|
package/src/csp.js
CHANGED
|
@@ -50,8 +50,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
|
|
|
50
50
|
* with webjs's own output, which is: nonce-signed inline `<script>` tags
|
|
51
51
|
* (the boot script, the public-env shim, the importmap), nonce-signed
|
|
52
52
|
* `<link rel="modulepreload">`, ES modules fetched same-origin and from
|
|
53
|
-
* the configured vendor CDN, and
|
|
54
|
-
*
|
|
53
|
+
* the configured vendor CDN, and one inline `<style>` in the head. The
|
|
54
|
+
* nonce covers scripts only, so that rule and every app-authored style
|
|
55
|
+
* alike are admitted by `'unsafe-inline'` (see the `style-src` note).
|
|
55
56
|
*
|
|
56
57
|
* `script-src` uses `'strict-dynamic'` so a nonce-loaded module can pull
|
|
57
58
|
* in its own dependencies (the importmap-driven per-file ESM graph)
|
|
@@ -67,10 +68,25 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/;
|
|
|
67
68
|
const DEFAULT_DIRECTIVES = {
|
|
68
69
|
'default-src': "'self'",
|
|
69
70
|
'script-src': "'nonce-__NONCE__' 'strict-dynamic' 'self' https:",
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
71
|
+
// NOTE this directive carries no nonce source, unlike `script-src`, so a
|
|
72
|
+
// nonce attribute on a <style> is INERT here and every inline style is
|
|
73
|
+
// admitted by 'unsafe-inline' alone. That includes WebJs's own head rule
|
|
74
|
+
// (the @layer webjs-host block), which SSR does stamp with the nonce but
|
|
75
|
+
// which this policy never checks it against. Dropping 'unsafe-inline'
|
|
76
|
+
// would block that rule too, not only app style.
|
|
77
|
+
//
|
|
78
|
+
// What keeps the allowance necessary is inline style with no realistic
|
|
79
|
+
// nonce path, and it arrives in four shapes. A page or layout may write
|
|
80
|
+
// an inline <style>, which an author could nonce by hand but does not by
|
|
81
|
+
// default. A light-DOM component's <style> and a shadow component's
|
|
82
|
+
// `static styles`, serialized into its declarative shadow root, are both
|
|
83
|
+
// emitted by the core renderer with no nonce and no way to supply one.
|
|
84
|
+
// And every style="..." attribute is unfixable in principle, since a
|
|
85
|
+
// nonce applies to elements and never to attributes. No styling library
|
|
86
|
+
// is involved in any of them.
|
|
87
|
+
//
|
|
88
|
+
// Style elements are not a script-injection vector, so this does not
|
|
89
|
+
// weaken the script protection.
|
|
74
90
|
'style-src': "'self' 'unsafe-inline'",
|
|
75
91
|
'img-src': "'self' data: https:",
|
|
76
92
|
'font-src': "'self' data: https:",
|
package/src/csrf.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* 2. When `Sec-Fetch-Site` is absent (an older browser), fall back to
|
|
20
20
|
* comparing the `Origin` host to the request host; an absent `Origin`
|
|
21
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).
|
|
22
|
+
* can't carry a victim's SameSite cookies cross-site anyway). That
|
|
23
|
+
* request host comes from `requestHost` below, which resolves
|
|
24
|
+
* `x-forwarded-host` through the shared `trustProxy()` seam in
|
|
25
|
+
* `forwarded.js` (#1104), so this file holds no trust posture of its own.
|
|
23
26
|
*
|
|
24
27
|
* Scope:
|
|
25
28
|
* - Internal RPC only. A `route.ts` REST endpoint (hand-written or via the
|
|
@@ -28,6 +31,8 @@
|
|
|
28
31
|
* - Session / auth cookies stay `SameSite=Lax` as defense-in-depth.
|
|
29
32
|
*/
|
|
30
33
|
|
|
34
|
+
import { trustProxy } from './forwarded.js';
|
|
35
|
+
|
|
31
36
|
/**
|
|
32
37
|
* Parse cookies off a standard Request. Retained as a general cookie reader
|
|
33
38
|
* (used by `context.js` for `cookies()`), independent of CSRF.
|
|
@@ -56,21 +61,37 @@ function hostOf(value) {
|
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
|
-
* The host the request was addressed to
|
|
60
|
-
*
|
|
61
|
-
*
|
|
64
|
+
* The host the request was addressed to: `x-forwarded-host` when a proxy is
|
|
65
|
+
* trusted to speak for the client (the Cloudflare-in-front-of-Railway setup),
|
|
66
|
+
* then the `Host` header, then the request URL.
|
|
67
|
+
*
|
|
68
|
+
* Trust note, preserved from before this read went through `trustProxy()`
|
|
69
|
+
* (#1104), because it is correct and non-obvious: reading `x-forwarded-host`
|
|
70
|
+
* here was never a CSRF weakness. A CSRF attack is browser-driven and a
|
|
71
|
+
* browser cannot set `x-forwarded-host` (or `Origin`); only a direct
|
|
72
|
+
* non-browser client can, and such a client carries no victim SameSite
|
|
73
|
+
* cookies to abuse. The primary `Sec-Fetch-Site` path does not reach this
|
|
74
|
+
* function at all, only the legacy no-`Sec-Fetch-Site` fallback in
|
|
75
|
+
* `verifyOrigin` does. The value CAN still be attacker-chosen, since a proxy
|
|
76
|
+
* of the kind above forwards a client-supplied one rather than overwriting
|
|
77
|
+
* it; see the threat model in `forwarded.js` for what that does reach.
|
|
78
|
+
*
|
|
79
|
+
* What changed is the POSTURE, not a hole: the read now honors
|
|
80
|
+
* `WEBJS_NO_TRUST_PROXY=1` like every other forwarded-header read, so an
|
|
81
|
+
* operator who sets the flag because their container is directly reachable
|
|
82
|
+
* gets one answer from the whole package instead of two. Behind a proxy
|
|
83
|
+
* WITHOUT the flag nothing changes. With the flag set on a genuinely proxied
|
|
84
|
+
* deploy (a misconfiguration) the fallback compares `Origin` against the raw
|
|
85
|
+
* `Host`, so a legitimate cross-host request can now be rejected. That is the
|
|
86
|
+
* flag's intended meaning.
|
|
62
87
|
*
|
|
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
88
|
* @param {Request} req
|
|
70
89
|
*/
|
|
71
90
|
export function requestHost(req) {
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
if (trustProxy()) {
|
|
92
|
+
const xfh = req.headers.get('x-forwarded-host');
|
|
93
|
+
if (xfh) return xfh.split(',')[0].trim().toLowerCase();
|
|
94
|
+
}
|
|
74
95
|
const host = req.headers.get('host');
|
|
75
96
|
if (host) return host.toLowerCase();
|
|
76
97
|
return hostOf(req.url);
|
package/src/dev-error.js
CHANGED
|
@@ -91,9 +91,19 @@ export function readCodeFrame(file, line, column, context = 3) {
|
|
|
91
91
|
/**
|
|
92
92
|
* Build a dev error frame from an error. DEV-ONLY (the caller gates on `dev`).
|
|
93
93
|
*
|
|
94
|
+
* `opts.url` stamps the frame with the request URL that produced it (#1047).
|
|
95
|
+
* Only a `render` frame carries one, and the browser overlay uses it to refuse a
|
|
96
|
+
* frame belonging to a page the tab is not looking at (another tab's crash, a
|
|
97
|
+
* background render of some other url, a page navigated away from). A frame with
|
|
98
|
+
* no url renders unconditionally, which is what a `rebuild` / `ts-strip` frame
|
|
99
|
+
* wants: those describe a still-broken build, not one URL.
|
|
100
|
+
*
|
|
101
|
+
* A speculative link prefetch is NOT in that list, and cannot be: `dev.js` drops
|
|
102
|
+
* the report hook for one, so no frame is ever built for it to refuse.
|
|
103
|
+
*
|
|
94
104
|
* @param {unknown} error
|
|
95
|
-
* @param {{ kind?: 'render' | 'ts-strip' | 'rebuild', appDir?: string, file?: string, line?: number, column?: number, hint?: string }} [opts]
|
|
96
|
-
* @returns {{ kind: string, message: string, stack: string|null, file: string|null, line: number|null, column: number|null, codeFrame: string|null, hint: string|null }}
|
|
105
|
+
* @param {{ kind?: 'render' | 'ts-strip' | 'rebuild', appDir?: string, file?: string, line?: number, column?: number, hint?: string, url?: string }} [opts]
|
|
106
|
+
* @returns {{ kind: string, message: string, stack: string|null, file: string|null, line: number|null, column: number|null, codeFrame: string|null, hint: string|null, url: string|null }}
|
|
97
107
|
*/
|
|
98
108
|
export function buildDevErrorFrame(error, opts = {}) {
|
|
99
109
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
@@ -125,5 +135,6 @@ export function buildDevErrorFrame(error, opts = {}) {
|
|
|
125
135
|
column,
|
|
126
136
|
codeFrame,
|
|
127
137
|
hint: opts.hint || null,
|
|
138
|
+
url: typeof opts.url === 'string' && opts.url ? opts.url : null,
|
|
128
139
|
};
|
|
129
140
|
}
|