@timber-js/app 0.2.0-alpha.209 → 0.2.0-alpha.210
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/dist/_chunks/{actions-BerlqoXA.js → actions-Rjk4htmA.js} +19 -25
- package/dist/_chunks/{actions-BerlqoXA.js.map → actions-Rjk4htmA.js.map} +1 -1
- package/dist/_chunks/als-registry-DaxkVjt5.js.map +1 -1
- package/dist/_chunks/{cache-api-CR23J_NC.js → cache-api-DGdYfNJn.js} +4 -4
- package/dist/_chunks/{cache-api-CR23J_NC.js.map → cache-api-DGdYfNJn.js.map} +1 -1
- package/dist/_chunks/{chains-CpFg56UB.js → chains-BoO51joc.js} +2 -2
- package/dist/_chunks/{chains-CpFg56UB.js.map → chains-BoO51joc.js.map} +1 -1
- package/dist/_chunks/{cli-check-C6Ev6wBO.js → cli-check-ajNY3B2e.js} +3 -3
- package/dist/_chunks/{cli-check-C6Ev6wBO.js.map → cli-check-ajNY3B2e.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js → cli-schema-sync-D2eI8jEg.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-CbT2AUUI.js.map → cli-schema-sync-D2eI8jEg.js.map} +1 -1
- package/dist/_chunks/{file-cache-Dw6BJPG7.js → codegen-Bps1sLKJ.js} +3 -29
- package/dist/_chunks/codegen-Bps1sLKJ.js.map +1 -0
- package/dist/_chunks/{convention-lint-jKTwKwPe.js → convention-lint-DLmhGsRS.js} +54 -316
- package/dist/_chunks/convention-lint-DLmhGsRS.js.map +1 -0
- package/dist/_chunks/{dev-server-C4WZdB7L.js → dev-server-v97rQH4b.js} +99 -9
- package/dist/_chunks/dev-server-v97rQH4b.js.map +1 -0
- package/dist/_chunks/{error-boundary-tA7kVfs4.js → error-boundary-DsNScGRM.js} +4 -4
- package/dist/_chunks/{error-boundary-tA7kVfs4.js.map → error-boundary-DsNScGRM.js.map} +1 -1
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js → json-lossy-check-CVuRs2hG.js} +2 -2
- package/dist/_chunks/{json-lossy-check-ip0Qi0MT.js.map → json-lossy-check-CVuRs2hG.js.map} +1 -1
- package/dist/_chunks/{live-graph-Dv-JJCZw.js → live-graph-9cSnn_h9.js} +3 -3
- package/dist/_chunks/{live-graph-Dv-JJCZw.js.map → live-graph-9cSnn_h9.js.map} +1 -1
- package/dist/_chunks/{logger-DiDt5ppH.js → logger-BP0LN6vP.js} +17 -2
- package/dist/_chunks/{logger-DiDt5ppH.js.map → logger-BP0LN6vP.js.map} +1 -1
- package/dist/_chunks/metadata-routes-DSDjM_hJ.js.map +1 -1
- package/dist/_chunks/navigation-root-BQfo1-kG.js.map +1 -1
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js → poison-scan-vGV7Re0B.js} +2 -2
- package/dist/_chunks/{poison-scan-CpeT6_OJ.js.map → poison-scan-vGV7Re0B.js.map} +1 -1
- package/dist/_chunks/{scanner-Bw0oq1HB.js → scanner-DmqdxzbW.js} +392 -7
- package/dist/_chunks/scanner-DmqdxzbW.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{sizeof-UwzwB1uM.js → sizeof-BM1409x2.js} +2 -2
- package/dist/_chunks/{sizeof-UwzwB1uM.js.map → sizeof-BM1409x2.js.map} +1 -1
- package/dist/_chunks/{status-page-marker-gaihi0KZ.js → status-page-marker-BRX9Ib-d.js} +1 -45
- package/dist/_chunks/status-page-marker-BRX9Ib-d.js.map +1 -0
- package/dist/_chunks/{walkers-BXExhzzk.js → walkers-Czu2jXFq.js} +3 -3
- package/dist/_chunks/{walkers-BXExhzzk.js.map → walkers-Czu2jXFq.js.map} +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +2 -2
- package/dist/cache/stores/memory.js +1 -1
- package/dist/cli.js +3 -3
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/history.d.ts +0 -9
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +9 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +25 -38
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +22 -0
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-api.d.ts +14 -2
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +9 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +6 -1
- package/dist/client/navigation-transition.d.ts.map +1 -1
- package/dist/client/react-root.d.ts.map +1 -1
- package/dist/client/router-effects.d.ts +10 -3
- package/dist/client/router-effects.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts +3 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +65 -9
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +0 -15
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-router.d.ts +13 -6
- package/dist/client/use-router.d.ts.map +1 -1
- package/dist/config-validation.d.ts +19 -2
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +19 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/types.d.ts +7 -0
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/actions.d.ts +9 -4
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/csrf.d.ts +38 -19
- package/dist/server/csrf.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +8 -4
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/fallback-error.d.ts.map +1 -1
- package/dist/server/form-flash.d.ts +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.js +249 -16
- package/dist/server/internal.js.map +1 -1
- package/dist/server/logger.d.ts +16 -3
- package/dist/server/logger.d.ts.map +1 -1
- package/dist/server/metadata-collector.d.ts +2 -0
- package/dist/server/metadata-collector.d.ts.map +1 -1
- package/dist/server/metadata-routes.d.ts +12 -1
- package/dist/server/metadata-routes.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts +29 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts +8 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +8 -0
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/{wrap-action-dispatch.d.ts → action-dispatcher.d.ts} +18 -40
- package/dist/server/rsc-entry/action-dispatcher.d.ts.map +1 -0
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/safe-load.d.ts +5 -12
- package/dist/server/safe-load.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/31-api-client.mdx +23 -20
- package/docs/api/34-api-config.mdx +4 -2
- package/package.json +1 -1
- package/src/client/browser-entry/action-dispatch.ts +28 -28
- package/src/client/browser-entry/post-hydration.ts +11 -1
- package/src/client/browser-entry/router-init.ts +10 -4
- package/src/client/history.ts +0 -22
- package/src/client/index.ts +2 -1
- package/src/client/link.tsx +30 -1
- package/src/client/navigation-api.ts +36 -3
- package/src/client/navigation-root.tsx +13 -1
- package/src/client/navigation-transition.ts +17 -7
- package/src/client/react-root.ts +11 -2
- package/src/client/router-effects.ts +11 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router-types.ts +80 -6
- package/src/client/router.ts +36 -24
- package/src/client/segment-cache.ts +0 -65
- package/src/client/use-router.ts +25 -6
- package/src/config-validation.ts +121 -5
- package/src/index.ts +5 -8
- package/src/routing/convention-lint.ts +75 -0
- package/src/routing/export-detect.ts +88 -0
- package/src/routing/manifest-codegen.ts +6 -0
- package/src/routing/scanner.ts +9 -0
- package/src/routing/types.ts +7 -0
- package/src/server/action-handler.ts +14 -11
- package/src/server/actions.ts +37 -42
- package/src/server/als-registry.ts +1 -1
- package/src/server/csrf.ts +100 -72
- package/src/server/error-boundary-wrapper.ts +11 -12
- package/src/server/fallback-error.ts +13 -21
- package/src/server/form-flash.ts +1 -1
- package/src/server/logger.ts +19 -3
- package/src/server/metadata-collector.ts +4 -1
- package/src/server/metadata-routes.ts +23 -8
- package/src/server/pipeline-helpers.ts +89 -8
- package/src/server/pipeline.ts +27 -2
- package/src/server/route-element-builder.ts +1 -0
- package/src/server/route-matcher.ts +11 -0
- package/src/server/rsc-entry/{wrap-action-dispatch.ts → action-dispatcher.ts} +20 -62
- package/src/server/rsc-entry/index.ts +14 -27
- package/src/server/safe-load.ts +5 -12
- package/dist/_chunks/convention-lint-jKTwKwPe.js.map +0 -1
- package/dist/_chunks/dev-server-C4WZdB7L.js.map +0 -1
- package/dist/_chunks/file-cache-Dw6BJPG7.js.map +0 -1
- package/dist/_chunks/scanner-Bw0oq1HB.js.map +0 -1
- package/dist/_chunks/status-page-marker-gaihi0KZ.js.map +0 -1
- package/dist/server/rsc-entry/wrap-action-dispatch.d.ts.map +0 -1
|
@@ -425,6 +425,94 @@ export function getPrerenderExport(filePath: string): PrerenderExportValue | und
|
|
|
425
425
|
}
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
/**
|
|
429
|
+
* How a file declares the per-route CSRF exemption.
|
|
430
|
+
*
|
|
431
|
+
* - `'absent'` — no runtime export named `csrf`.
|
|
432
|
+
* - `'exempt'` — exactly `export const csrf = false` (wrappers like
|
|
433
|
+
* `false as const` allowed), declared in this file.
|
|
434
|
+
* - `'invalid'` — some other `csrf` export: another value, `let`, a
|
|
435
|
+
* destructured binding, a specifier or named re-export (`export { csrf }`,
|
|
436
|
+
* `export { x as "csrf" }`), a function or class. `export * from` is not
|
|
437
|
+
* followed: a `csrf` it carries is neither exempt nor an error.
|
|
438
|
+
*
|
|
439
|
+
* The exemption is read from source at build time, never from the loaded
|
|
440
|
+
* module, because the CSRF gate runs before any user code (see
|
|
441
|
+
* design/08-forms-and-actions.md §"Per-route exemption"). Only the literal
|
|
442
|
+
* form can be read that way; any other spelling is a build error rather
|
|
443
|
+
* than a silent "not exempt", so what the developer wrote is what runs.
|
|
444
|
+
*/
|
|
445
|
+
export type CsrfExport = 'absent' | 'exempt' | 'invalid';
|
|
446
|
+
|
|
447
|
+
export function getCsrfExport(filePath: string): CsrfExport {
|
|
448
|
+
if (!existsSync(filePath)) return 'absent';
|
|
449
|
+
// Unparseable: not exempt (fail closed). The compiler reports the syntax
|
|
450
|
+
// error; guessing at a half-written file would misreport it.
|
|
451
|
+
const program = tryParse(readFileCached(filePath));
|
|
452
|
+
if (!program) return 'absent';
|
|
453
|
+
|
|
454
|
+
// Walk the statements directly rather than through `collectNamedExports`:
|
|
455
|
+
// every spelling that exports a runtime `csrf` must be seen here, and
|
|
456
|
+
// that helper does not see destructured declarations or string-literal
|
|
457
|
+
// specifier names. What cannot be seen at all — `export * from` — is
|
|
458
|
+
// not read, and is documented as such.
|
|
459
|
+
let found: CsrfExport = 'absent';
|
|
460
|
+
for (const stmt of program.body) {
|
|
461
|
+
if (stmt.type !== 'ExportNamedDeclaration' || stmt.exportKind === 'type') continue;
|
|
462
|
+
for (const spec of stmt.specifiers ?? []) {
|
|
463
|
+
if (spec.exportKind !== 'type' && specifierName(spec.exported) === 'csrf') {
|
|
464
|
+
return 'invalid';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const decl = stmt.declaration as (AstNode & { kind?: string }) | undefined;
|
|
468
|
+
if (!decl) continue;
|
|
469
|
+
if (decl.id?.name === 'csrf') return 'invalid'; // function or class
|
|
470
|
+
if (decl.type !== 'VariableDeclaration') continue;
|
|
471
|
+
for (const d of decl.declarations ?? []) {
|
|
472
|
+
const id = d.id as AstNode | undefined;
|
|
473
|
+
if (id?.type === 'Identifier') {
|
|
474
|
+
if (id.name !== 'csrf') continue;
|
|
475
|
+
const init = d.init && (unwrapTransparent(d.init) as AstNode & { value?: unknown });
|
|
476
|
+
const literalFalse = init?.type === 'Literal' && init.value === false;
|
|
477
|
+
if (decl.kind !== 'const' || !literalFalse) return 'invalid';
|
|
478
|
+
found = 'exempt';
|
|
479
|
+
} else if (id && patternBinds(id, 'csrf')) {
|
|
480
|
+
return 'invalid'; // `export const { csrf } = …`
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return found;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** An export specifier's name: `csrf` or the string literal `"csrf"`. */
|
|
488
|
+
function specifierName(node: { name?: string; value?: unknown } | undefined): string | undefined {
|
|
489
|
+
if (!node) return undefined;
|
|
490
|
+
return typeof node.value === 'string' ? node.value : node.name;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Whether a destructuring pattern binds `name` anywhere inside it. */
|
|
494
|
+
function patternBinds(node: unknown, name: string): boolean {
|
|
495
|
+
if (!node || typeof node !== 'object') return false;
|
|
496
|
+
const n = node as Record<string, unknown>;
|
|
497
|
+
switch (n.type) {
|
|
498
|
+
case 'Identifier':
|
|
499
|
+
return n.name === name;
|
|
500
|
+
case 'ObjectPattern':
|
|
501
|
+
return ((n.properties as unknown[]) ?? []).some((prop) => {
|
|
502
|
+
const p = prop as Record<string, unknown>;
|
|
503
|
+
return patternBinds(p.type === 'RestElement' ? p.argument : p.value, name);
|
|
504
|
+
});
|
|
505
|
+
case 'ArrayPattern':
|
|
506
|
+
return ((n.elements as unknown[]) ?? []).some((el) => patternBinds(el, name));
|
|
507
|
+
case 'RestElement':
|
|
508
|
+
return patternBinds(n.argument, name);
|
|
509
|
+
case 'AssignmentPattern':
|
|
510
|
+
return patternBinds(n.left, name);
|
|
511
|
+
default:
|
|
512
|
+
return false;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
428
516
|
/**
|
|
429
517
|
* Check if a file starts with a specific directive (e.g. "use client").
|
|
430
518
|
* Directives are string literal expression statements at the top of the file.
|
|
@@ -105,6 +105,12 @@ export function generateManifestModule(tree: RouteTree, viteRoot: string): strin
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
// Read from route.ts source at scan time; the CSRF gate consults it
|
|
109
|
+
// before any user module loads (design/08 §"Per-route exemption").
|
|
110
|
+
if (node.csrfExempt) {
|
|
111
|
+
parts.push(`${nextIndent}csrfExempt: true,`);
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
// Record-shaped conventions — table-driven
|
|
109
115
|
for (const key of RECORD_FILE_KEYS) {
|
|
110
116
|
const record = node[key];
|
package/src/routing/scanner.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { validateSlotPlacement } from './slot-placement.ts';
|
|
|
25
25
|
import { DEFAULT_PAGE_EXTENSIONS } from './types.ts';
|
|
26
26
|
import { classifyMetadataRoute, isDynamicMetadataExtension } from '../server/metadata-routes.ts';
|
|
27
27
|
import { swallow } from '../server/logger.ts';
|
|
28
|
+
import { getCsrfExport } from './export-detect.ts';
|
|
28
29
|
import { ENCODED_SEPARATOR_RE, NULL_BYTE_RE } from '../server/canonicalize.ts';
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -303,6 +304,14 @@ function scanSegmentFiles(dirPath: string, node: SegmentNode, extSet: Set<string
|
|
|
303
304
|
`A URL is either an API endpoint or a rendered page, not both.`
|
|
304
305
|
);
|
|
305
306
|
}
|
|
307
|
+
|
|
308
|
+
// The per-route CSRF exemption is read from source here, at scan time, so
|
|
309
|
+
// the manifest carries it and the request gate never loads user code to
|
|
310
|
+
// decide it. Any other spelling of the export is a lint error
|
|
311
|
+
// (convention-lint.ts `checkCsrfExports`).
|
|
312
|
+
if (node.route && getCsrfExport(node.route.filePath) === 'exempt') {
|
|
313
|
+
node.csrfExempt = true;
|
|
314
|
+
}
|
|
306
315
|
}
|
|
307
316
|
|
|
308
317
|
/**
|
package/src/routing/types.ts
CHANGED
|
@@ -96,6 +96,13 @@ export interface SegmentNode<TFile = RouteFile> {
|
|
|
96
96
|
middleware?: TFile;
|
|
97
97
|
access?: TFile;
|
|
98
98
|
route?: TFile;
|
|
99
|
+
/**
|
|
100
|
+
* Set when this segment's route.ts declares `export const csrf = false`.
|
|
101
|
+
* Read from source by the scanner, so the CSRF gate can consult it before
|
|
102
|
+
* any user module loads. See design/08-forms-and-actions.md
|
|
103
|
+
* §"Per-route exemption".
|
|
104
|
+
*/
|
|
105
|
+
csrfExempt?: true;
|
|
99
106
|
error?: TFile;
|
|
100
107
|
default?: TFile;
|
|
101
108
|
/** Status-code files: 4xx.tsx, 5xx.tsx, {status}.tsx (component format) */
|
|
@@ -73,7 +73,7 @@ export interface ActionDispatchConfig {
|
|
|
73
73
|
*
|
|
74
74
|
* **Important:** This function returns true for ANY POST with a form
|
|
75
75
|
* Content-Type, including non-action POSTs to route.ts API handlers.
|
|
76
|
-
* The caller (
|
|
76
|
+
* The caller (rsc-entry/action-dispatcher.ts) MUST check the matched route type
|
|
77
77
|
* before entering the action path — route.ts matches skip action detection
|
|
78
78
|
* entirely so their body is not pre-parsed. See TIM-870.
|
|
79
79
|
*/
|
|
@@ -148,11 +148,12 @@ export async function handleActionRequest(
|
|
|
148
148
|
|
|
149
149
|
// CSRF validation — reject cross-origin mutation requests.
|
|
150
150
|
//
|
|
151
|
-
// Defense-in-depth: the pipeline
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
151
|
+
// Defense-in-depth: the pipeline's CSRF gate (`csrfGate`) already checks
|
|
152
|
+
// every unsafe-method request before dispatch reaches here, so this call
|
|
153
|
+
// is a no-op on the happy path. It is intentionally retained so that
|
|
154
|
+
// handleActionRequest remains safe to call from any entry point that
|
|
155
|
+
// bypasses the gate — and it never applies the per-route exemption, which
|
|
156
|
+
// is for route.ts only. See LOCAL-773, TIM-1543.
|
|
156
157
|
const csrfResult = validateCsrf(req, config.csrf);
|
|
157
158
|
if (!csrfResult.ok) {
|
|
158
159
|
return csrfRejectionResponse(req, csrfResult);
|
|
@@ -398,8 +399,8 @@ async function handleRscAction(
|
|
|
398
399
|
'Content-Type': RSC_CONTENT_TYPE,
|
|
399
400
|
'X-Timber-Redirect': result.redirectTo,
|
|
400
401
|
};
|
|
401
|
-
if (result.
|
|
402
|
-
redirectHeaders['X-Timber-
|
|
402
|
+
if (result.onlyOtherPagesNamed) {
|
|
403
|
+
redirectHeaders['X-Timber-Invalidated'] = '1';
|
|
403
404
|
}
|
|
404
405
|
return new Response(rscStream, { status: 200, headers: redirectHeaders });
|
|
405
406
|
}
|
|
@@ -412,9 +413,11 @@ async function handleRscAction(
|
|
|
412
413
|
'Content-Type': RSC_CONTENT_TYPE,
|
|
413
414
|
};
|
|
414
415
|
|
|
415
|
-
// TIM-1454: Tell the client
|
|
416
|
-
|
|
417
|
-
|
|
416
|
+
// TIM-1454: Tell the client that revalidatePath named only other pages. A
|
|
417
|
+
// flag, not a path list: the client evicts every cached payload either
|
|
418
|
+
// way (TIM-1461).
|
|
419
|
+
if (result.onlyOtherPagesNamed) {
|
|
420
|
+
headers['X-Timber-Invalidated'] = '1';
|
|
418
421
|
}
|
|
419
422
|
|
|
420
423
|
if (result.revalidation) {
|
package/src/server/actions.ts
CHANGED
|
@@ -81,11 +81,16 @@ export interface ActionHandlerResult {
|
|
|
81
81
|
/** Revalidation result if revalidatePath was called (element tree, not yet serialized). */
|
|
82
82
|
revalidation?: RevalidationResult;
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
* (TIM-1454). The client
|
|
86
|
-
*
|
|
84
|
+
* True when `revalidatePath()` was called only for pages other than the
|
|
85
|
+
* current one (TIM-1454). The client then evicts its caches and skips the
|
|
86
|
+
* refresh — nothing on screen was named. Whenever the current page was
|
|
87
|
+
* named this is false, even if other pages were named too: its re-render,
|
|
88
|
+
* or the refresh that replaces a dropped one, re-renders every layout it
|
|
89
|
+
* shares with them. Which paths were named is not reported: the client
|
|
90
|
+
* evicts every cached payload after any revalidation (TIM-1476), so a path
|
|
91
|
+
* list would carry nothing it acts on (TIM-1461).
|
|
87
92
|
*/
|
|
88
|
-
|
|
93
|
+
onlyOtherPagesNamed: boolean;
|
|
89
94
|
/** Redirect location if a RedirectSignal was thrown during revalidation. */
|
|
90
95
|
redirectTo?: string;
|
|
91
96
|
/** Redirect status code. */
|
|
@@ -166,13 +171,6 @@ export function revalidateTag(tag: string): Promise<void> {
|
|
|
166
171
|
return Promise.resolve();
|
|
167
172
|
}
|
|
168
173
|
|
|
169
|
-
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
170
|
-
|
|
171
|
-
function stripFragment(path: string): string {
|
|
172
|
-
const idx = path.indexOf('#');
|
|
173
|
-
return idx >= 0 ? path.slice(0, idx) : path;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
174
|
// ─── Action Handler ──────────────────────────────────────────────────────
|
|
177
175
|
|
|
178
176
|
/**
|
|
@@ -251,37 +249,35 @@ export async function executeAction(
|
|
|
251
249
|
|
|
252
250
|
// Process path revalidation — build element tree (not yet serialized).
|
|
253
251
|
//
|
|
254
|
-
// TIM-1454: Only
|
|
255
|
-
//
|
|
256
|
-
// caches
|
|
252
|
+
// TIM-1454: Only a path that matches the current page is rendered. When
|
|
253
|
+
// every call names another page, `onlyOtherPagesNamed` tells the client to
|
|
254
|
+
// evict its caches instead. The matching path is looked for across every
|
|
255
|
+
// call, not just the first: an action that calls revalidatePath('/other')
|
|
256
|
+
// and then revalidatePath('/current') must still re-render the current
|
|
257
|
+
// page (TIM-1461).
|
|
257
258
|
let revalidation: RevalidationResult | undefined;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
const { requestPathname, requestSearch } = config;
|
|
260
|
+
const isCurrentPage = (path: string) =>
|
|
261
|
+
!requestPathname || revalidationPathMatches(path, requestPathname, requestSearch);
|
|
262
|
+
const path = state.paths.find(isCurrentPage);
|
|
263
|
+
const onlyOtherPagesNamed = state.paths.length > 0 && path === undefined;
|
|
262
264
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
} else if (renderError instanceof RevalidationDropped) {
|
|
280
|
-
console.error(`[timber] ${renderError.message}`);
|
|
281
|
-
} else {
|
|
282
|
-
console.error('[timber] revalidatePath render failed:', renderError);
|
|
283
|
-
revalidationErrors.push(renderError);
|
|
284
|
-
}
|
|
265
|
+
if (path !== undefined && config.renderer) {
|
|
266
|
+
try {
|
|
267
|
+
revalidation = await config.renderer(path);
|
|
268
|
+
} catch (renderError) {
|
|
269
|
+
if (isRedirectSignal(renderError)) {
|
|
270
|
+
redirectTo = renderError.location;
|
|
271
|
+
redirectStatus = renderError.status;
|
|
272
|
+
} else if (isDenySignal(renderError)) {
|
|
273
|
+
console.error(
|
|
274
|
+
`[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
|
|
275
|
+
);
|
|
276
|
+
} else if (renderError instanceof RevalidationDropped) {
|
|
277
|
+
console.error(`[timber] ${renderError.message}`);
|
|
278
|
+
} else {
|
|
279
|
+
console.error('[timber] revalidatePath render failed:', renderError);
|
|
280
|
+
revalidationErrors.push(renderError);
|
|
285
281
|
}
|
|
286
282
|
}
|
|
287
283
|
}
|
|
@@ -289,7 +285,7 @@ export async function executeAction(
|
|
|
289
285
|
return {
|
|
290
286
|
actionResult,
|
|
291
287
|
revalidation,
|
|
292
|
-
|
|
288
|
+
onlyOtherPagesNamed,
|
|
293
289
|
revalidationErrors,
|
|
294
290
|
...(redirectTo ? { redirectTo, redirectStatus } : {}),
|
|
295
291
|
};
|
|
@@ -297,7 +293,6 @@ export async function executeAction(
|
|
|
297
293
|
|
|
298
294
|
/**
|
|
299
295
|
* Check if a `revalidatePath(path)` argument matches the request pathname.
|
|
300
|
-
/**
|
|
301
296
|
* Canonicalizes the revalidated path the same way the renderer does so
|
|
302
297
|
* equivalent paths compare equal. Returns true when the paths match or
|
|
303
298
|
* when comparison is inconclusive (validation failure → render to be safe).
|
|
@@ -238,7 +238,7 @@ export const revalidationAls = getOrCreateAls<RevalidationState>(
|
|
|
238
238
|
|
|
239
239
|
// ─── Form Flash ───────────────────────────────────────────────────────────
|
|
240
240
|
// Used by: form-flash.ts (getFormFlash())
|
|
241
|
-
// Design doc: design/08-forms-and-actions.md §"No-JS
|
|
241
|
+
// Design doc: design/08-forms-and-actions.md §"No-JS Result Round-Trip"
|
|
242
242
|
|
|
243
243
|
/** @internal — import via form-flash.ts public API */
|
|
244
244
|
export const formFlashAls = getOrCreateAls<import('./form-flash.ts').FormFlashData>(
|
package/src/server/csrf.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CSRF protection — Origin
|
|
2
|
+
* CSRF protection — Fetch Metadata first, Origin as the fallback.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* CSRF needs a victim's browser to attach cookies, and browsers mark the
|
|
5
|
+
* requests they send: `Sec-Fetch-Site` on HTTPS (and localhost) pages in
|
|
6
|
+
* every current browser, `Origin` on cross-site POSTs everywhere else. So
|
|
7
|
+
* the check trusts those two headers in that order, and lets a request
|
|
8
|
+
* carrying neither through — a webhook, curl, or a server-to-server
|
|
9
|
+
* `fetch`. The residual risk is browsers old enough to send neither on a
|
|
10
|
+
* form POST (Firefox < 70, IE11); SameSite cookies are the defense there.
|
|
11
|
+
* See design/08-forms-and-actions.md §"CSRF Protection".
|
|
12
|
+
*
|
|
13
|
+
* Prior art: Go's `net/http.CrossOriginProtection` (Go 1.25) and the OWASP
|
|
14
|
+
* CSRF cheat sheet (Fetch Metadata as a primary defense).
|
|
15
|
+
*
|
|
16
|
+
* The per-route exemption (`export const csrf = false` in route.ts) is not
|
|
17
|
+
* decided here: the caller owns route matching and applies it to a
|
|
18
|
+
* rejection. Disable globally with csrf: false (not recommended).
|
|
7
19
|
*
|
|
8
20
|
* See design/08-forms-and-actions.md §"CSRF Protection"
|
|
9
21
|
* See design/13-security.md §"Security Testing Checklist" #6
|
|
@@ -15,33 +27,36 @@ import { CSRF_REJECT_HEADER, CSRF_REJECT_VALUE } from '../shared/csrf-reject.ts'
|
|
|
15
27
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
16
28
|
|
|
17
29
|
export interface CsrfConfig {
|
|
18
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* Origins trusted in addition to the request's own. Canonical origins
|
|
32
|
+
* only (`new URL(x).origin === x`); `validateConfig` enforces that.
|
|
33
|
+
*/
|
|
19
34
|
allowedOrigins?: string[];
|
|
20
35
|
/** Set to false to disable CSRF validation entirely. */
|
|
21
36
|
csrf?: boolean;
|
|
22
37
|
}
|
|
23
38
|
|
|
24
|
-
/** Why a request failed the
|
|
39
|
+
/** Why a request failed the check — one value per rejection path. */
|
|
25
40
|
export type CsrfRejectReason =
|
|
26
|
-
| '
|
|
27
|
-
| 'origin-not-allowed'
|
|
41
|
+
| 'cross-origin'
|
|
28
42
|
| 'missing-host'
|
|
29
43
|
| 'invalid-host'
|
|
30
44
|
| 'invalid-origin'
|
|
31
45
|
| 'origin-mismatch';
|
|
32
46
|
|
|
33
47
|
/**
|
|
34
|
-
* A failed
|
|
35
|
-
* absent); `expected` is the origin(s)
|
|
36
|
-
* `
|
|
37
|
-
* `
|
|
38
|
-
*
|
|
48
|
+
* A failed check. `origin` is the request's Origin header and `fetchSite`
|
|
49
|
+
* its Sec-Fetch-Site header (null when absent); `expected` is the origin(s)
|
|
50
|
+
* `origin` was compared against: the `allowedOrigins` list, preceded on
|
|
51
|
+
* `origin-mismatch` by the origin derived from the Host header. All three are for
|
|
52
|
+
* the server log only — never the response.
|
|
39
53
|
*/
|
|
40
54
|
export interface CsrfRejection {
|
|
41
55
|
ok: false;
|
|
42
56
|
status: 403;
|
|
43
57
|
reason: CsrfRejectReason;
|
|
44
58
|
origin: string | null;
|
|
59
|
+
fetchSite: string | null;
|
|
45
60
|
expected: readonly string[];
|
|
46
61
|
}
|
|
47
62
|
|
|
@@ -52,6 +67,14 @@ export type CsrfResult = { ok: true } | CsrfRejection;
|
|
|
52
67
|
/** HTTP methods that are considered safe (no mutation). */
|
|
53
68
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
54
69
|
|
|
70
|
+
/**
|
|
71
|
+
* `Sec-Fetch-Site` values that mean the browser itself vouches for the
|
|
72
|
+
* request: `same-origin`, or `none` (user-initiated — typed URL, bookmark).
|
|
73
|
+
* Every other value, including ones no browser sends, is not same-origin.
|
|
74
|
+
* The header is forbidden to page scripts, so a page cannot forge it.
|
|
75
|
+
*/
|
|
76
|
+
const TRUSTED_FETCH_SITES = new Set(['same-origin', 'none']);
|
|
77
|
+
|
|
55
78
|
// ─── Implementation ───────────────────────────────────────────────────────
|
|
56
79
|
|
|
57
80
|
/**
|
|
@@ -78,46 +101,53 @@ function deriveRequestScheme(req: Request): string {
|
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
/**
|
|
81
|
-
*
|
|
82
|
-
* (scheme + host + port).
|
|
104
|
+
* Decide whether an unsafe-method request may proceed.
|
|
83
105
|
*
|
|
84
|
-
*
|
|
85
|
-
* -
|
|
86
|
-
*
|
|
87
|
-
*
|
|
106
|
+
* In order:
|
|
107
|
+
* 1. `Sec-Fetch-Site` is `same-origin` or `none` → allow.
|
|
108
|
+
* 2. `Sec-Fetch-Site` has any other value → allow only if `Origin` is in
|
|
109
|
+
* `allowedOrigins`.
|
|
110
|
+
* 3. No `Sec-Fetch-Site`, `Origin` present (a plain-HTTP page, or an
|
|
111
|
+
* older browser) → `Origin` must be in `allowedOrigins` or equal the
|
|
112
|
+
* origin derived from the scheme and `Host`.
|
|
113
|
+
* 4. Neither header → allow: not a browser, so no ambient cookies to abuse.
|
|
88
114
|
*
|
|
89
|
-
* Safe methods (GET, HEAD, OPTIONS) always pass.
|
|
115
|
+
* Safe methods (GET, HEAD, OPTIONS) and `csrf: false` always pass.
|
|
90
116
|
*/
|
|
91
117
|
export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
return { ok: true };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Explicitly disabled
|
|
98
|
-
if (config.csrf === false) {
|
|
99
|
-
return { ok: true };
|
|
100
|
-
}
|
|
118
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
119
|
+
if (config.csrf === false) return { ok: true };
|
|
101
120
|
|
|
121
|
+
const fetchSite = req.headers.get('Sec-Fetch-Site');
|
|
102
122
|
const origin = req.headers.get('Origin');
|
|
123
|
+
const rejection = (reason: CsrfRejectReason, expected: readonly string[]): CsrfRejection => ({
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 403,
|
|
126
|
+
reason,
|
|
127
|
+
origin,
|
|
128
|
+
fetchSite,
|
|
129
|
+
expected,
|
|
130
|
+
});
|
|
103
131
|
|
|
104
|
-
//
|
|
105
|
-
if (
|
|
106
|
-
|
|
132
|
+
// Steps 1–2: the browser told us where the request came from.
|
|
133
|
+
if (fetchSite !== null) {
|
|
134
|
+
if (TRUSTED_FETCH_SITES.has(fetchSite)) return { ok: true };
|
|
135
|
+
if (origin !== null && config.allowedOrigins?.includes(origin)) return { ok: true };
|
|
136
|
+
return rejection('cross-origin', config.allowedOrigins ?? []);
|
|
107
137
|
}
|
|
108
138
|
|
|
109
|
-
//
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
// Step 4: no browser marker at all.
|
|
140
|
+
if (origin === null) return { ok: true };
|
|
141
|
+
|
|
142
|
+
// Step 3: Origin only. The request's own origin, or a listed one — the
|
|
143
|
+
// same two sources step 2 trusts, so a same-origin POST from a page that
|
|
144
|
+
// gets no Fetch Metadata (plain HTTP) never needs listing (Go's
|
|
145
|
+
// CrossOriginProtection makes the same union).
|
|
146
|
+
const allowed = config.allowedOrigins ?? [];
|
|
147
|
+
if (allowed.includes(origin)) return { ok: true };
|
|
115
148
|
|
|
116
|
-
// Auto-derive from Host header
|
|
117
149
|
const host = req.headers.get('Host');
|
|
118
|
-
if (!host)
|
|
119
|
-
return reject('missing-host', origin, []);
|
|
120
|
-
}
|
|
150
|
+
if (!host) return rejection('missing-host', allowed);
|
|
121
151
|
|
|
122
152
|
// Compare full origins (scheme + host + port) using URL.origin for
|
|
123
153
|
// canonical port normalization (e.g. https://x:443 → https://x).
|
|
@@ -125,36 +155,28 @@ export function validateCsrf(req: Request, config: CsrfConfig): CsrfResult {
|
|
|
125
155
|
try {
|
|
126
156
|
originOrigin = new URL(origin).origin;
|
|
127
157
|
} catch {
|
|
128
|
-
return
|
|
158
|
+
return rejection('invalid-origin', allowed);
|
|
129
159
|
}
|
|
130
160
|
|
|
131
|
-
const scheme = deriveRequestScheme(req);
|
|
132
161
|
let expectedOrigin: string;
|
|
133
162
|
try {
|
|
134
|
-
expectedOrigin = new URL(`${
|
|
163
|
+
expectedOrigin = new URL(`${deriveRequestScheme(req)}://${host}`).origin;
|
|
135
164
|
} catch {
|
|
136
|
-
return
|
|
165
|
+
return rejection('invalid-host', allowed);
|
|
137
166
|
}
|
|
138
167
|
|
|
139
168
|
return originOrigin === expectedOrigin
|
|
140
169
|
? { ok: true }
|
|
141
|
-
:
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function reject(
|
|
145
|
-
reason: CsrfRejectReason,
|
|
146
|
-
origin: string | null,
|
|
147
|
-
expected: readonly string[]
|
|
148
|
-
): CsrfRejection {
|
|
149
|
-
return { ok: false, status: 403, reason, origin, expected };
|
|
170
|
+
: rejection('origin-mismatch', [expectedOrigin, ...allowed]);
|
|
150
171
|
}
|
|
151
172
|
|
|
152
|
-
// ─── Rejection Response
|
|
173
|
+
// ─── Rejection Response ───────────────────────────────────────────────────
|
|
153
174
|
|
|
154
175
|
/**
|
|
155
176
|
* Longest request-derived value echoed into the server log. Origin, Host,
|
|
156
|
-
* and the path are attacker-controlled; a request can carry
|
|
157
|
-
* each, and every rejection writes a log line. Real values are
|
|
177
|
+
* Sec-Fetch-Site, and the path are attacker-controlled; a request can carry
|
|
178
|
+
* kilobytes in each, and every rejection writes a log line. Real values are
|
|
179
|
+
* far shorter.
|
|
158
180
|
*/
|
|
159
181
|
const MAX_LOGGED_VALUE = 200;
|
|
160
182
|
|
|
@@ -176,16 +198,20 @@ const PROXY_HINT =
|
|
|
176
198
|
'original Host and X-Forwarded-Proto, or list the public origin in allowedOrigins ' +
|
|
177
199
|
'in timber.config.ts.';
|
|
178
200
|
|
|
201
|
+
const EXEMPTION_HINT =
|
|
202
|
+
'If this route.ts must accept cross-site browser requests (OIDC form_post, SAML, ' +
|
|
203
|
+
'3-D Secure returns), add `export const csrf = false` to it.';
|
|
204
|
+
|
|
179
205
|
/**
|
|
180
206
|
* Browsers send `Origin: null` from opaque contexts: sandboxed iframes, a POST
|
|
181
207
|
* redirected across origins, file: pages. Every such context shares the one
|
|
182
|
-
* value — an attacker's `<iframe sandbox>` included — so
|
|
183
|
-
*
|
|
208
|
+
* value — an attacker's `<iframe sandbox>` included — so config validation
|
|
209
|
+
* refuses it in `allowedOrigins` and the hint points at the exemption instead.
|
|
184
210
|
*/
|
|
185
211
|
const OPAQUE_ORIGIN_DETAIL =
|
|
186
212
|
'origin "null" is an opaque origin (sandboxed iframe, a POST redirected across ' +
|
|
187
|
-
'origins, or a file: page).
|
|
188
|
-
|
|
213
|
+
'origins, or a file: page). allowedOrigins cannot admit it: every opaque context ' +
|
|
214
|
+
`sends "null", including an attacker's sandboxed iframe. ${EXEMPTION_HINT}`;
|
|
189
215
|
|
|
190
216
|
/** One sentence per rejection path: what was observed, and what to change. */
|
|
191
217
|
function describeRejection(r: CsrfRejection, host: string | null): string {
|
|
@@ -193,20 +219,19 @@ function describeRejection(r: CsrfRejection, host: string | null): string {
|
|
|
193
219
|
const origin = quote(r.origin ?? '');
|
|
194
220
|
const expected = r.expected.map(quote).join(', ');
|
|
195
221
|
switch (r.reason) {
|
|
196
|
-
case 'missing-origin':
|
|
197
|
-
return (
|
|
198
|
-
'request has no Origin header. Browsers send Origin on fetch and form POSTs; ' +
|
|
199
|
-
'non-browser clients calling an unsafe method must set it.'
|
|
200
|
-
);
|
|
201
222
|
// The opaque-origin note replaces only the reasons where the origin
|
|
202
223
|
// itself is the fault. A null-origin request that fails for another
|
|
203
224
|
// reason (no Host, a bad Host) keeps that reason's diagnosis and hint.
|
|
204
|
-
case 'origin
|
|
225
|
+
case 'cross-origin': {
|
|
205
226
|
if (opaque) return OPAQUE_ORIGIN_DETAIL;
|
|
227
|
+
const from = r.origin === null ? 'with no Origin header' : `from origin ${origin}`;
|
|
228
|
+
const allowed = r.expected.length > 0 ? ` and is not in allowedOrigins [${expected}]` : '';
|
|
206
229
|
return (
|
|
207
|
-
`
|
|
208
|
-
|
|
230
|
+
`Sec-Fetch-Site ${quote(r.fetchSite ?? '')} request ${from}: the browser reports ` +
|
|
231
|
+
`it is not same-origin${allowed}. If this origin is legitimate, add it to ` +
|
|
232
|
+
`allowedOrigins in timber.config.ts. ${EXEMPTION_HINT}`
|
|
209
233
|
);
|
|
234
|
+
}
|
|
210
235
|
case 'missing-host':
|
|
211
236
|
return `origin ${origin} could not be checked: request has no Host header. ${PROXY_HINT}`;
|
|
212
237
|
case 'invalid-host':
|
|
@@ -214,8 +239,11 @@ function describeRejection(r: CsrfRejection, host: string | null): string {
|
|
|
214
239
|
case 'invalid-origin':
|
|
215
240
|
if (opaque) return OPAQUE_ORIGIN_DETAIL;
|
|
216
241
|
return `Origin header ${origin} is not a valid origin.`;
|
|
217
|
-
case 'origin-mismatch':
|
|
218
|
-
|
|
242
|
+
case 'origin-mismatch': {
|
|
243
|
+
const [own, ...listed] = r.expected.map(quote);
|
|
244
|
+
const list = listed.length > 0 ? ` or allowedOrigins [${listed.join(', ')}]` : '';
|
|
245
|
+
return `origin ${origin} does not match the request origin ${own}${list}. ${PROXY_HINT}`;
|
|
246
|
+
}
|
|
219
247
|
}
|
|
220
248
|
}
|
|
221
249
|
|