@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
package/dist/server/internal.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { r as RenderTimeoutError, t as DEFAULT_RENDER_TIMEOUT_MS } from "../_chunks/render-timeout-DOf_BBcO.js";
|
|
2
2
|
import { n as isRscContentType, r as isRscRequest } from "../_chunks/rsc-media-type-DDc7duTD.js";
|
|
3
|
-
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DSDjM_hJ.js";
|
|
4
|
-
import { r as canonicalize } from "../_chunks/canonicalize-CgHoscYO.js";
|
|
5
3
|
import { n as classifyUrlSegment, r as effectiveUrlSegment } from "../_chunks/segment-classify-C539Pa2O.js";
|
|
6
4
|
import { t as computeSegmentTreePaths } from "../_chunks/segment-keys-D5hu1hz4.js";
|
|
5
|
+
import { i as getMetadataRouteServePath, n as classifyMetadataRoute, r as getMetadataRouteAutoLink, t as METADATA_ROUTE_CONVENTIONS } from "../_chunks/metadata-routes-DSDjM_hJ.js";
|
|
6
|
+
import { r as canonicalize } from "../_chunks/canonicalize-CgHoscYO.js";
|
|
7
7
|
import { a as requestContextAls, r as prebuiltCaptureAls, s as timingAls, t as earlyHintsSenderAls } from "../_chunks/als-registry-DaxkVjt5.js";
|
|
8
|
-
import {
|
|
8
|
+
import { C as isRedirectSignal, F as withSpan, M as replaceTraceId, N as runWithTraceId, O as generateTraceId, P as setSpanAttribute, S as isDenySignal, _ as setLogger, a as logMiddlewareError, c as logRenderError, d as logRouteError, f as logRouteSetupError, g as logWaitUntilUnsupported, h as logWaitUntilRejected, j as getTraceId, k as getOtelTraceId, l as logRequestCompleted, m as logSwrRefetchFailed, n as logCacheMiss, o as logMiddlewareShortCircuit, p as logSlowRequest, r as logCsrfRejected, s as logProxyError, t as getLogger, u as logRequestReceived, v as swallow, w as isDebug, x as isControlFlowSignal } from "../_chunks/logger-BP0LN6vP.js";
|
|
9
9
|
import { i as collectRouteModulepreloads, n as collectRouteCss, r as collectRouteFonts } from "../_chunks/build-manifest-DWppEdLB.js";
|
|
10
10
|
import { i as isRscCacheKeyShape, n as RSC_KEY_PARAM, s as rscCacheKey, t as RSC_KEY_HEADERS } from "../_chunks/rsc-cache-key-ClUiXQnK.js";
|
|
11
|
-
import { C as setMutableCookieContext, S as setMatchedSegmentPath, T as runOutsideReactCacheScope, b as markResponseFlushed, f as getCookie, g as getHeader, h as applyRequestHeaderOverlay, i as coerce, m as getSetCookieHeaders, o as DenySignal, s as RedirectSignal, t as executeAction, v as getSearchParams, w as setSegmentParams, x as runWithRequestContext, y as getSegmentParams } from "../_chunks/actions-
|
|
12
|
-
import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as getWaitUntil, y as lastInvalidationEpochFor } from "../_chunks/cache-api-
|
|
11
|
+
import { C as setMutableCookieContext, S as setMatchedSegmentPath, T as runOutsideReactCacheScope, b as markResponseFlushed, f as getCookie, g as getHeader, h as applyRequestHeaderOverlay, i as coerce, m as getSetCookieHeaders, o as DenySignal, s as RedirectSignal, t as executeAction, v as getSearchParams, w as setSegmentParams, x as runWithRequestContext, y as getSegmentParams } from "../_chunks/actions-Rjk4htmA.js";
|
|
12
|
+
import { a as getManifestEntry, b as wasInvalidatedSince, c as lookupPrebuiltPayload, d as checkVersionSkew, i as storeOverlayEntry, l as setPrebuiltPayloadSource, n as lookupOverlay, o as hasPrebuiltPayloadSource, r as overlayKey, s as isParamIndependent, u as applyReloadHeaders, v as currentInvalidationEpoch, x as getWaitUntil, y as lastInvalidationEpochFor } from "../_chunks/cache-api-DGdYfNJn.js";
|
|
13
13
|
import { n as createSingleflight } from "../_chunks/singleflight-BeVMMKi0.js";
|
|
14
14
|
import { n as toBracketKey } from "../_chunks/resolve-schema-CBR6Lm4i.js";
|
|
15
15
|
import { t as normalizeParamValue } from "../_chunks/param-value-C8TNYchQ.js";
|
|
16
|
-
import { l as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-
|
|
16
|
+
import { l as TIMBER_ERROR_HEADER, t as StatusPageMarker } from "../_chunks/status-page-marker-BRX9Ib-d.js";
|
|
17
17
|
import { createHash } from "node:crypto";
|
|
18
18
|
import { readFile } from "node:fs/promises";
|
|
19
19
|
import React, { createElement, useContext } from "react";
|
|
@@ -317,16 +317,7 @@ var ModuleLoadError = class extends Error {
|
|
|
317
317
|
* On success: returns the module object (same as `loader.load()`).
|
|
318
318
|
* On failure: throws `ModuleLoadError` with file path and original cause.
|
|
319
319
|
*
|
|
320
|
-
*
|
|
321
|
-
* callers should catch at the call site:
|
|
322
|
-
*
|
|
323
|
-
* ```ts
|
|
324
|
-
* // Throwing (default) — route-element-builder, api-handler, etc.
|
|
325
|
-
* const mod = await loadModule(segment.page);
|
|
326
|
-
*
|
|
327
|
-
* // Fallthrough — error-renderer, error-boundary-wrapper
|
|
328
|
-
* const mod = await loadModule(segment.error).catch(() => null);
|
|
329
|
-
* ```
|
|
320
|
+
* Error rendering paths that need fallthrough use `loadFallbackModule()`.
|
|
330
321
|
*/
|
|
331
322
|
async function loadModule(loader) {
|
|
332
323
|
try {
|
|
@@ -347,12 +338,248 @@ function isApiRouteChain(segments) {
|
|
|
347
338
|
const leaf = segments[segments.length - 1];
|
|
348
339
|
return !!leaf?.route && !leaf.page;
|
|
349
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* Whether a matched chain is a route.ts handler that opted out of the CSRF
|
|
343
|
+
* check with `export const csrf = false`. Keyed on the same leaf
|
|
344
|
+
* `isApiRouteChain` dispatches on, so the exemption can only ever apply to
|
|
345
|
+
* the route.ts that will actually run — never to a page, and so never to a
|
|
346
|
+
* server action. See design/08-forms-and-actions.md §"Per-route exemption".
|
|
347
|
+
*/
|
|
348
|
+
function isCsrfExemptRoute(segments) {
|
|
349
|
+
return isApiRouteChain(segments) && segments[segments.length - 1]?.csrfExempt === true;
|
|
350
|
+
}
|
|
350
351
|
var SERVE_PATH_TO_CONVENTION = /* @__PURE__ */ new Map();
|
|
351
352
|
for (const [baseName, convention] of Object.entries(METADATA_ROUTE_CONVENTIONS)) if (convention.serveExtension) for (const ext of [...convention.staticExtensions, convention.serveExtension]) SERVE_PATH_TO_CONVENTION.set(`${convention.servePath}.${ext}`, baseName);
|
|
352
353
|
else SERVE_PATH_TO_CONVENTION.set(convention.servePath, baseName);
|
|
353
354
|
//#endregion
|
|
355
|
+
//#region src/shared/csrf-reject.ts
|
|
356
|
+
/**
|
|
357
|
+
* Marker header on CSRF rejections.
|
|
358
|
+
*
|
|
359
|
+
* A CSRF rejection is a 403, the same status as an access-control denial.
|
|
360
|
+
* The server stamps this header on every CSRF 403 so callers — the browser
|
|
361
|
+
* action runtime, a same-origin `fetch` in user code, a curl session — can
|
|
362
|
+
* tell the two apart without parsing the body. A cross-origin `fetch` cannot
|
|
363
|
+
* read it: the rejection returns before proxy.ts, so no CORS headers are set
|
|
364
|
+
* and the browser reports a network error instead. Shared so the server writer and the client
|
|
365
|
+
* reader cannot drift.
|
|
366
|
+
*
|
|
367
|
+
* See design/08-forms-and-actions.md §"CSRF Protection" (TIM-1459).
|
|
368
|
+
*/
|
|
369
|
+
var CSRF_REJECT_HEADER = "X-Timber-Reject";
|
|
370
|
+
var CSRF_REJECT_VALUE = "csrf";
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/server/csrf.ts
|
|
373
|
+
/**
|
|
374
|
+
* CSRF protection — Fetch Metadata first, Origin as the fallback.
|
|
375
|
+
*
|
|
376
|
+
* CSRF needs a victim's browser to attach cookies, and browsers mark the
|
|
377
|
+
* requests they send: `Sec-Fetch-Site` on HTTPS (and localhost) pages in
|
|
378
|
+
* every current browser, `Origin` on cross-site POSTs everywhere else. So
|
|
379
|
+
* the check trusts those two headers in that order, and lets a request
|
|
380
|
+
* carrying neither through — a webhook, curl, or a server-to-server
|
|
381
|
+
* `fetch`. The residual risk is browsers old enough to send neither on a
|
|
382
|
+
* form POST (Firefox < 70, IE11); SameSite cookies are the defense there.
|
|
383
|
+
* See design/08-forms-and-actions.md §"CSRF Protection".
|
|
384
|
+
*
|
|
385
|
+
* Prior art: Go's `net/http.CrossOriginProtection` (Go 1.25) and the OWASP
|
|
386
|
+
* CSRF cheat sheet (Fetch Metadata as a primary defense).
|
|
387
|
+
*
|
|
388
|
+
* The per-route exemption (`export const csrf = false` in route.ts) is not
|
|
389
|
+
* decided here: the caller owns route matching and applies it to a
|
|
390
|
+
* rejection. Disable globally with csrf: false (not recommended).
|
|
391
|
+
*
|
|
392
|
+
* See design/08-forms-and-actions.md §"CSRF Protection"
|
|
393
|
+
* See design/13-security.md §"Security Testing Checklist" #6
|
|
394
|
+
*/
|
|
395
|
+
/** HTTP methods that are considered safe (no mutation). */
|
|
396
|
+
var SAFE_METHODS = /* @__PURE__ */ new Set([
|
|
397
|
+
"GET",
|
|
398
|
+
"HEAD",
|
|
399
|
+
"OPTIONS"
|
|
400
|
+
]);
|
|
401
|
+
/**
|
|
402
|
+
* `Sec-Fetch-Site` values that mean the browser itself vouches for the
|
|
403
|
+
* request: `same-origin`, or `none` (user-initiated — typed URL, bookmark).
|
|
404
|
+
* Every other value, including ones no browser sends, is not same-origin.
|
|
405
|
+
* The header is forbidden to page scripts, so a page cannot forge it.
|
|
406
|
+
*/
|
|
407
|
+
var TRUSTED_FETCH_SITES = /* @__PURE__ */ new Set(["same-origin", "none"]);
|
|
408
|
+
/**
|
|
409
|
+
* Derive the request's scheme from trusted sources.
|
|
410
|
+
*
|
|
411
|
+
* Priority:
|
|
412
|
+
* 1. X-Forwarded-Proto (set by reverse proxies / load balancers)
|
|
413
|
+
* 2. Request URL protocol
|
|
414
|
+
*
|
|
415
|
+
* Falls back to 'https' (fail closed) if neither is available.
|
|
416
|
+
*/
|
|
417
|
+
function deriveRequestScheme(req) {
|
|
418
|
+
const forwarded = req.headers.get("X-Forwarded-Proto");
|
|
419
|
+
if (forwarded) {
|
|
420
|
+
const proto = forwarded.split(",")[0].trim().toLowerCase();
|
|
421
|
+
if (proto === "http" || proto === "https") return proto;
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
return new URL(req.url).protocol.replace(":", "");
|
|
425
|
+
} catch {
|
|
426
|
+
return "https";
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Decide whether an unsafe-method request may proceed.
|
|
431
|
+
*
|
|
432
|
+
* In order:
|
|
433
|
+
* 1. `Sec-Fetch-Site` is `same-origin` or `none` → allow.
|
|
434
|
+
* 2. `Sec-Fetch-Site` has any other value → allow only if `Origin` is in
|
|
435
|
+
* `allowedOrigins`.
|
|
436
|
+
* 3. No `Sec-Fetch-Site`, `Origin` present (a plain-HTTP page, or an
|
|
437
|
+
* older browser) → `Origin` must be in `allowedOrigins` or equal the
|
|
438
|
+
* origin derived from the scheme and `Host`.
|
|
439
|
+
* 4. Neither header → allow: not a browser, so no ambient cookies to abuse.
|
|
440
|
+
*
|
|
441
|
+
* Safe methods (GET, HEAD, OPTIONS) and `csrf: false` always pass.
|
|
442
|
+
*/
|
|
443
|
+
function validateCsrf(req, config) {
|
|
444
|
+
if (SAFE_METHODS.has(req.method)) return { ok: true };
|
|
445
|
+
if (config.csrf === false) return { ok: true };
|
|
446
|
+
const fetchSite = req.headers.get("Sec-Fetch-Site");
|
|
447
|
+
const origin = req.headers.get("Origin");
|
|
448
|
+
const rejection = (reason, expected) => ({
|
|
449
|
+
ok: false,
|
|
450
|
+
status: 403,
|
|
451
|
+
reason,
|
|
452
|
+
origin,
|
|
453
|
+
fetchSite,
|
|
454
|
+
expected
|
|
455
|
+
});
|
|
456
|
+
if (fetchSite !== null) {
|
|
457
|
+
if (TRUSTED_FETCH_SITES.has(fetchSite)) return { ok: true };
|
|
458
|
+
if (origin !== null && config.allowedOrigins?.includes(origin)) return { ok: true };
|
|
459
|
+
return rejection("cross-origin", config.allowedOrigins ?? []);
|
|
460
|
+
}
|
|
461
|
+
if (origin === null) return { ok: true };
|
|
462
|
+
const allowed = config.allowedOrigins ?? [];
|
|
463
|
+
if (allowed.includes(origin)) return { ok: true };
|
|
464
|
+
const host = req.headers.get("Host");
|
|
465
|
+
if (!host) return rejection("missing-host", allowed);
|
|
466
|
+
let originOrigin;
|
|
467
|
+
try {
|
|
468
|
+
originOrigin = new URL(origin).origin;
|
|
469
|
+
} catch {
|
|
470
|
+
return rejection("invalid-origin", allowed);
|
|
471
|
+
}
|
|
472
|
+
let expectedOrigin;
|
|
473
|
+
try {
|
|
474
|
+
expectedOrigin = new URL(`${deriveRequestScheme(req)}://${host}`).origin;
|
|
475
|
+
} catch {
|
|
476
|
+
return rejection("invalid-host", allowed);
|
|
477
|
+
}
|
|
478
|
+
return originOrigin === expectedOrigin ? { ok: true } : rejection("origin-mismatch", [expectedOrigin, ...allowed]);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Longest request-derived value echoed into the server log. Origin, Host,
|
|
482
|
+
* Sec-Fetch-Site, and the path are attacker-controlled; a request can carry
|
|
483
|
+
* kilobytes in each, and every rejection writes a log line. Real values are
|
|
484
|
+
* far shorter.
|
|
485
|
+
*/
|
|
486
|
+
var MAX_LOGGED_VALUE = 200;
|
|
487
|
+
function clip(value) {
|
|
488
|
+
return value.length > MAX_LOGGED_VALUE ? `${value.slice(0, MAX_LOGGED_VALUE)}…` : value;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Clip and quote a request-derived value for the log. JSON quoting escapes
|
|
492
|
+
* quotes and control characters, so a crafted header cannot close the quote
|
|
493
|
+
* and forge `key=value` fields or a second line.
|
|
494
|
+
*/
|
|
495
|
+
function quote(value) {
|
|
496
|
+
return JSON.stringify(clip(value));
|
|
497
|
+
}
|
|
498
|
+
var PROXY_HINT = "If the app runs behind a proxy that rewrites Host or terminates TLS, forward the original Host and X-Forwarded-Proto, or list the public origin in allowedOrigins in timber.config.ts.";
|
|
499
|
+
var EXEMPTION_HINT = "If this route.ts must accept cross-site browser requests (OIDC form_post, SAML, 3-D Secure returns), add `export const csrf = false` to it.";
|
|
500
|
+
/**
|
|
501
|
+
* Browsers send `Origin: null` from opaque contexts: sandboxed iframes, a POST
|
|
502
|
+
* redirected across origins, file: pages. Every such context shares the one
|
|
503
|
+
* value — an attacker's `<iframe sandbox>` included — so config validation
|
|
504
|
+
* refuses it in `allowedOrigins` and the hint points at the exemption instead.
|
|
505
|
+
*/
|
|
506
|
+
var OPAQUE_ORIGIN_DETAIL = `origin "null" is an opaque origin (sandboxed iframe, a POST redirected across origins, or a file: page). allowedOrigins cannot admit it: every opaque context sends "null", including an attacker's sandboxed iframe. ${EXEMPTION_HINT}`;
|
|
507
|
+
/** One sentence per rejection path: what was observed, and what to change. */
|
|
508
|
+
function describeRejection(r, host) {
|
|
509
|
+
const opaque = r.origin === "null";
|
|
510
|
+
const origin = quote(r.origin ?? "");
|
|
511
|
+
const expected = r.expected.map(quote).join(", ");
|
|
512
|
+
switch (r.reason) {
|
|
513
|
+
case "cross-origin": {
|
|
514
|
+
if (opaque) return OPAQUE_ORIGIN_DETAIL;
|
|
515
|
+
const from = r.origin === null ? "with no Origin header" : `from origin ${origin}`;
|
|
516
|
+
const allowed = r.expected.length > 0 ? ` and is not in allowedOrigins [${expected}]` : "";
|
|
517
|
+
return `Sec-Fetch-Site ${quote(r.fetchSite ?? "")} request ${from}: the browser reports it is not same-origin${allowed}. If this origin is legitimate, add it to allowedOrigins in timber.config.ts. ${EXEMPTION_HINT}`;
|
|
518
|
+
}
|
|
519
|
+
case "missing-host": return `origin ${origin} could not be checked: request has no Host header. ${PROXY_HINT}`;
|
|
520
|
+
case "invalid-host": return `origin ${origin} could not be checked: Host header ${quote(host ?? "")} is not a valid host. ${PROXY_HINT}`;
|
|
521
|
+
case "invalid-origin":
|
|
522
|
+
if (opaque) return OPAQUE_ORIGIN_DETAIL;
|
|
523
|
+
return `Origin header ${origin} is not a valid origin.`;
|
|
524
|
+
case "origin-mismatch": {
|
|
525
|
+
const [own, ...listed] = r.expected.map(quote);
|
|
526
|
+
return `origin ${origin} does not match the request origin ${own}${listed.length > 0 ? ` or allowedOrigins [${listed.join(", ")}]` : ""}. ${PROXY_HINT}`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Turn a failed check into the response the client sees, and log why.
|
|
532
|
+
*
|
|
533
|
+
* The response is a 403 marked as a CSRF rejection (header + plain-text
|
|
534
|
+
* body) so it cannot be mistaken for an access-control 403. It never
|
|
535
|
+
* carries the observed or expected origins — `allowedOrigins` is server
|
|
536
|
+
* configuration, and a Host-derived origin can be an internal hostname
|
|
537
|
+
* behind a proxy. The server log gets the full diagnosis.
|
|
538
|
+
*/
|
|
539
|
+
function csrfRejectionResponse(req, rejection) {
|
|
540
|
+
logCsrfRejected(describeRejection(rejection, req.headers.get("Host")), {
|
|
541
|
+
method: req.method,
|
|
542
|
+
path: clip(new URL(req.url).pathname),
|
|
543
|
+
reason: rejection.reason
|
|
544
|
+
});
|
|
545
|
+
return new Response("Forbidden: CSRF origin check failed\n", {
|
|
546
|
+
status: rejection.status,
|
|
547
|
+
headers: {
|
|
548
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
549
|
+
[CSRF_REJECT_HEADER]: CSRF_REJECT_VALUE
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
//#endregion
|
|
354
554
|
//#region src/server/pipeline-helpers.ts
|
|
355
555
|
/**
|
|
556
|
+
* The pipeline-boundary CSRF gate: the 403 to answer with, or null to go on.
|
|
557
|
+
*
|
|
558
|
+
* Runs on every request after canonicalization and before proxy.ts, so it
|
|
559
|
+
* covers route.ts handlers and server actions alike, and no user code has
|
|
560
|
+
* run when it decides. The per-route exemption is looked up only for a
|
|
561
|
+
* request the check would reject — the common accepted request never pays
|
|
562
|
+
* for a match. The lookup uses the pipeline's own matcher on the canonical
|
|
563
|
+
* path, the same match `handleRequest` dispatches on, and reads a flag the
|
|
564
|
+
* scanner took from route.ts source, so deciding it loads no module.
|
|
565
|
+
*
|
|
566
|
+
* A matcher that throws leaves the request unexempted: fail closed.
|
|
567
|
+
*
|
|
568
|
+
* See design/08-forms-and-actions.md §"CSRF Protection".
|
|
569
|
+
*/
|
|
570
|
+
function csrfGate(req, canonicalPath, csrf, matchRoute) {
|
|
571
|
+
const result = validateCsrf(req, csrf);
|
|
572
|
+
if (result.ok) return null;
|
|
573
|
+
let exempt = false;
|
|
574
|
+
try {
|
|
575
|
+
const match = matchRoute(canonicalPath);
|
|
576
|
+
exempt = match !== null && isCsrfExemptRoute(match.segments);
|
|
577
|
+
} catch (err) {
|
|
578
|
+
swallow(err, "csrf gate: route match for exemption lookup threw");
|
|
579
|
+
}
|
|
580
|
+
return exempt ? null : csrfRejectionResponse(req, result);
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
356
583
|
* Build a proxy resolver closure from the declared source. Called exactly
|
|
357
584
|
* once at `createPipeline` setup time, so the hot path sees only the branch
|
|
358
585
|
* that corresponds to this pipeline's configured variant.
|
|
@@ -1784,6 +2011,7 @@ function createPipeline(config) {
|
|
|
1784
2011
|
const canonResult = canonicalize(url.pathname, stripTrailingSlash);
|
|
1785
2012
|
if (!canonResult.ok) {
|
|
1786
2013
|
if (isDebug()) console.warn(`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n This usually means the URL contains encoded separators (%2f, %5c),\n null bytes (%00), path traversal (..), or malformed percent-encoding.`);
|
|
2014
|
+
await setSpanAttribute("http.response.status_code", canonResult.status);
|
|
1787
2015
|
return new Response(null, { status: canonResult.status });
|
|
1788
2016
|
}
|
|
1789
2017
|
const canonicalPath = canonResult.pathname;
|
|
@@ -1793,6 +2021,11 @@ function createPipeline(config) {
|
|
|
1793
2021
|
canonicalUrl.pathname = canonicalPath;
|
|
1794
2022
|
canonicalReq = new Request(canonicalUrl.toString(), req);
|
|
1795
2023
|
}
|
|
2024
|
+
const csrfRejection = csrfGate(canonicalReq, canonicalPath, config.csrf ?? {}, config.matchRoute);
|
|
2025
|
+
if (csrfRejection) {
|
|
2026
|
+
await setSpanAttribute("http.response.status_code", csrfRejection.status);
|
|
2027
|
+
return csrfRejection;
|
|
2028
|
+
}
|
|
1796
2029
|
const innerHandler = async () => {
|
|
1797
2030
|
if (config.dispatchAction) {
|
|
1798
2031
|
const actionResult = await config.dispatchAction(canonicalReq, canonicalPath, pipelineFn);
|