@timber-js/app 0.2.0-alpha.133 → 0.2.0-alpha.135

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.
Files changed (177) hide show
  1. package/dist/_chunks/{actions-BNhpxrnV.js → actions-CHFblsdT.js} +62 -19
  2. package/dist/_chunks/actions-CHFblsdT.js.map +1 -0
  3. package/dist/_chunks/{build-output-helper-K7oUCRqU.js → build-output-helper-CfBfOU9m.js} +1 -1
  4. package/dist/_chunks/{build-output-helper-K7oUCRqU.js.map → build-output-helper-CfBfOU9m.js.map} +1 -1
  5. package/dist/_chunks/cli-schema-sync-q-Ce6-iG.js.map +1 -1
  6. package/dist/_chunks/{codec-registry-DqkeAydt.js → codec-registry-G9hD5LOq.js} +1 -1
  7. package/dist/_chunks/{codec-registry-DqkeAydt.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
  8. package/dist/_chunks/{define-jeHDXZGq.js → define-DXWUJb-K.js} +3 -3
  9. package/dist/_chunks/{define-jeHDXZGq.js.map → define-DXWUJb-K.js.map} +1 -1
  10. package/dist/_chunks/{define-DMq-Yjp7.js → define-Dp-qu3ql.js} +3 -3
  11. package/dist/_chunks/{define-DMq-Yjp7.js.map → define-Dp-qu3ql.js.map} +1 -1
  12. package/dist/_chunks/fast-hash-DXZ7S2D5.js +36 -0
  13. package/dist/_chunks/fast-hash-DXZ7S2D5.js.map +1 -0
  14. package/dist/_chunks/{format-BjN9HLu9.js → format-CR6v1pWE.js} +1 -1
  15. package/dist/_chunks/{format-BjN9HLu9.js.map → format-CR6v1pWE.js.map} +1 -1
  16. package/dist/_chunks/{handler-store-bIAstrxQ.js → handler-store-aQMpqPe7.js} +1 -1
  17. package/dist/_chunks/{handler-store-bIAstrxQ.js.map → handler-store-aQMpqPe7.js.map} +1 -1
  18. package/dist/_chunks/href-validation-2G-HZaQ8.js +124 -0
  19. package/dist/_chunks/href-validation-2G-HZaQ8.js.map +1 -0
  20. package/dist/_chunks/logger-CMd9P1xQ.js.map +1 -1
  21. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -1
  22. package/dist/_chunks/{navigation-root-B5-RmsPi.js → navigation-root-BQqFQI-n.js} +3 -3
  23. package/dist/_chunks/{navigation-root-B5-RmsPi.js.map → navigation-root-BQqFQI-n.js.map} +1 -1
  24. package/dist/_chunks/{registry-BI9cgCt7.js → registry-Dp_hJL4s.js} +1 -1
  25. package/dist/_chunks/{registry-BI9cgCt7.js.map → registry-Dp_hJL4s.js.map} +1 -1
  26. package/dist/_chunks/{resolve-schema-DU2nbVOi.js → resolve-schema-D3CbcdCW.js} +2 -2
  27. package/dist/_chunks/{resolve-schema-DU2nbVOi.js.map → resolve-schema-D3CbcdCW.js.map} +1 -1
  28. package/dist/_chunks/{router-ref-B2-2kIIC.js → router-ref-B50j42zB.js} +2 -2
  29. package/dist/_chunks/{router-ref-B2-2kIIC.js.map → router-ref-B50j42zB.js.map} +1 -1
  30. package/dist/_chunks/{schema-bridge-o0SwFtXW.js → schema-bridge-Cuy8BaYu.js} +2 -2
  31. package/dist/_chunks/{schema-bridge-o0SwFtXW.js.map → schema-bridge-Cuy8BaYu.js.map} +1 -1
  32. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -1
  33. package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-BnxoPSFQ.js} +1 -1
  34. package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-BnxoPSFQ.js.map} +1 -1
  35. package/dist/_chunks/{ssr-data-DVX8S7vj.js → ssr-data-DxXyoRrz.js} +1 -1
  36. package/dist/_chunks/{ssr-data-DVX8S7vj.js.map → ssr-data-DxXyoRrz.js.map} +1 -1
  37. package/dist/_chunks/{stale-reload-CCqb62Z3.js → stale-reload-DIIIVGh_.js} +1 -1
  38. package/dist/_chunks/{stale-reload-CCqb62Z3.js.map → stale-reload-DIIIVGh_.js.map} +1 -1
  39. package/dist/_chunks/tracing-DP5djRq5.js.map +1 -1
  40. package/dist/_chunks/{use-query-states-C9G6LP0F.js → use-query-states-D9SBlAfp.js} +2 -2
  41. package/dist/_chunks/{use-query-states-C9G6LP0F.js.map → use-query-states-D9SBlAfp.js.map} +1 -1
  42. package/dist/_chunks/{use-segment-params-CIeinIx2.js → use-segment-params-x2poKmk8.js} +2 -2
  43. package/dist/_chunks/use-segment-params-x2poKmk8.js.map +1 -0
  44. package/dist/_chunks/{validation-AfEFM5ze.js → validation-BoWM_qI1.js} +31 -2
  45. package/dist/_chunks/validation-BoWM_qI1.js.map +1 -0
  46. package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
  47. package/dist/_chunks/warnings-CAzunaGo.js.map +1 -1
  48. package/dist/adapters/cloudflare-dev.js.map +1 -1
  49. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  50. package/dist/adapters/cloudflare.js +1 -1
  51. package/dist/adapters/cloudflare.js.map +1 -1
  52. package/dist/adapters/nitro.js +1 -1
  53. package/dist/adapters/nitro.js.map +1 -1
  54. package/dist/cache/cache-api.d.ts +1 -1
  55. package/dist/cache/cache-api.d.ts.map +1 -1
  56. package/dist/cache/index.js +17 -39
  57. package/dist/cache/index.js.map +1 -1
  58. package/dist/cache/timber-cache.d.ts +1 -1
  59. package/dist/cache/timber-cache.d.ts.map +1 -1
  60. package/dist/cli.js.map +1 -1
  61. package/dist/client/error-boundary.js +2 -2
  62. package/dist/client/error-boundary.js.map +1 -1
  63. package/dist/client/index.d.ts.map +1 -1
  64. package/dist/client/index.js +14 -26
  65. package/dist/client/index.js.map +1 -1
  66. package/dist/client/internal.js +8 -8
  67. package/dist/client/internal.js.map +1 -1
  68. package/dist/client/link.d.ts +2 -4
  69. package/dist/client/link.d.ts.map +1 -1
  70. package/dist/client/navigation-context.d.ts.map +1 -1
  71. package/dist/client/use-pathname.d.ts.map +1 -1
  72. package/dist/client/use-router.d.ts.map +1 -1
  73. package/dist/client/use-search-params.d.ts.map +1 -1
  74. package/dist/codec.js +1 -1
  75. package/dist/codec.js.map +1 -1
  76. package/dist/cookies/index.js +3 -3
  77. package/dist/cookies/index.js.map +1 -1
  78. package/dist/cookies/validation.d.ts +11 -23
  79. package/dist/cookies/validation.d.ts.map +1 -1
  80. package/dist/dev-tools/error-page.d.ts.map +1 -1
  81. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +259 -3
  84. package/dist/index.js.map +1 -1
  85. package/dist/params/define-schema.d.ts.map +1 -1
  86. package/dist/params/index.js +2 -2
  87. package/dist/params/index.js.map +1 -1
  88. package/dist/plugins/cache.d.ts +31 -0
  89. package/dist/plugins/cache.d.ts.map +1 -0
  90. package/dist/routing/link-codegen.d.ts.map +1 -1
  91. package/dist/schema-bridge.d.ts.map +1 -1
  92. package/dist/search-params/index.js +3 -3
  93. package/dist/search-params/index.js.map +1 -1
  94. package/dist/segment-params/index.js +1 -1
  95. package/dist/server/actions.d.ts.map +1 -1
  96. package/dist/server/body-limits.d.ts.map +1 -1
  97. package/dist/server/cookie-context.d.ts +2 -2
  98. package/dist/server/cookie-context.d.ts.map +1 -1
  99. package/dist/server/csrf.d.ts +3 -2
  100. package/dist/server/csrf.d.ts.map +1 -1
  101. package/dist/server/deny-renderer.d.ts.map +1 -1
  102. package/dist/server/html-injector-core.d.ts.map +1 -1
  103. package/dist/server/html-injectors.d.ts.map +1 -1
  104. package/dist/server/index.js +17 -8
  105. package/dist/server/index.js.map +1 -1
  106. package/dist/server/internal.js +69 -21
  107. package/dist/server/internal.js.map +1 -1
  108. package/dist/server/pipeline-interception.d.ts.map +1 -1
  109. package/dist/server/pipeline-phases.d.ts +5 -1
  110. package/dist/server/pipeline-phases.d.ts.map +1 -1
  111. package/dist/server/pipeline.d.ts.map +1 -1
  112. package/dist/server/primitives.d.ts +5 -2
  113. package/dist/server/primitives.d.ts.map +1 -1
  114. package/dist/server/route-element-builder.d.ts.map +1 -1
  115. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  116. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  117. package/dist/server/rsc-entry/revalidate-renderer.d.ts +108 -0
  118. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -0
  119. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  120. package/dist/server/rsc-entry/ssr-renderer.d.ts +15 -0
  121. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  122. package/dist/server/sitemap-handler.d.ts.map +1 -1
  123. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  124. package/dist/server/tree-builder.d.ts.map +1 -1
  125. package/dist/shared/href-validation.d.ts +38 -0
  126. package/dist/shared/href-validation.d.ts.map +1 -0
  127. package/dist/shims/font-google.js.map +1 -1
  128. package/dist/shims/font-local.js.map +1 -1
  129. package/package.json +5 -5
  130. package/src/cache/cache-api.ts +3 -2
  131. package/src/cache/timber-cache.ts +27 -4
  132. package/src/client/index.ts +1 -0
  133. package/src/client/link.tsx +13 -37
  134. package/src/client/navigation-context.ts +2 -0
  135. package/src/client/use-pathname.ts +1 -0
  136. package/src/client/use-router.ts +3 -0
  137. package/src/client/use-search-params.ts +1 -0
  138. package/src/client/use-segment-params.ts +1 -0
  139. package/src/config-validation.ts +1 -1
  140. package/src/cookies/validation.ts +58 -0
  141. package/src/dev-tools/error-page.ts +1 -0
  142. package/src/fonts/virtual-modules.ts +2 -0
  143. package/src/index.ts +5 -1
  144. package/src/params/define-schema.ts +1 -0
  145. package/src/plugins/cache.ts +180 -0
  146. package/src/routing/link-codegen.ts +5 -3
  147. package/src/schema-bridge.ts +3 -2
  148. package/src/server/action-client.ts +31 -13
  149. package/src/server/actions.ts +20 -3
  150. package/src/server/body-limits.ts +10 -2
  151. package/src/server/cookie-context.ts +7 -2
  152. package/src/server/cookie-parsing.ts +1 -1
  153. package/src/server/csrf.ts +39 -6
  154. package/src/server/deny-renderer.ts +0 -1
  155. package/src/server/html-injector-core.ts +6 -5
  156. package/src/server/html-injectors.ts +8 -5
  157. package/src/server/pipeline-interception.ts +1 -0
  158. package/src/server/pipeline-phases.ts +30 -19
  159. package/src/server/pipeline.ts +27 -1
  160. package/src/server/primitives.ts +19 -18
  161. package/src/server/route-element-builder.ts +2 -0
  162. package/src/server/rsc-entry/error-renderer.ts +4 -3
  163. package/src/server/rsc-entry/index.ts +16 -36
  164. package/src/server/rsc-entry/revalidate-renderer.ts +344 -0
  165. package/src/server/rsc-entry/rsc-payload.ts +0 -4
  166. package/src/server/rsc-entry/ssr-renderer.ts +76 -0
  167. package/src/server/sitemap-handler.ts +24 -2
  168. package/src/server/slot-resolver.ts +8 -2
  169. package/src/server/ssr-wrappers.tsx +2 -0
  170. package/src/server/tree-builder.ts +2 -0
  171. package/src/server/types.ts +2 -2
  172. package/src/shared/href-validation.ts +127 -0
  173. package/dist/_chunks/actions-BNhpxrnV.js.map +0 -1
  174. package/dist/_chunks/merge-search-params-Cm1j99HC.js +0 -41
  175. package/dist/_chunks/merge-search-params-Cm1j99HC.js.map +0 -1
  176. package/dist/_chunks/use-segment-params-CIeinIx2.js.map +0 -1
  177. package/dist/_chunks/validation-AfEFM5ze.js.map +0 -1
@@ -24,6 +24,7 @@ export interface InterceptionMatchResult {
24
24
  * followed by '/' or be an exact match.
25
25
  */
26
26
  function hasSegmentPrefix(pathname: string, prefix: string): boolean {
27
+ if (prefix === '/') return true;
27
28
  if (!pathname.startsWith(prefix)) return false;
28
29
  return pathname.length === prefix.length || pathname[prefix.length] === '/';
29
30
  }
@@ -82,7 +82,7 @@ export async function runProxyPhase(
82
82
  try {
83
83
  const proxyExport = await getProxy();
84
84
  const proxyFn = () =>
85
- runProxy(proxyExport, req, () => handleRequest(config, req, method, path));
85
+ runProxy(proxyExport, req, () => handleRequest(config, req, method, path, true));
86
86
  const response = await withSpan('timber.proxy', {}, () =>
87
87
  detailed ? withTiming('proxy', 'proxy.ts', proxyFn) : proxyFn()
88
88
  );
@@ -214,33 +214,44 @@ export async function runRenderPhase(
214
214
  *
215
215
  * Used both as the top-level entry (when no proxy.ts is configured) and as
216
216
  * the `next()` continuation passed to `runProxy()`.
217
+ *
218
+ * @param pathIsCanonical When true, `path` has already been canonicalized by
219
+ * `createPipeline` — skip re-canonicalization to prevent double-decode.
220
+ * When false (default), runs canonicalize as a safety net for direct callers.
217
221
  */
218
222
  export async function handleRequest(
219
223
  config: PipelineConfig,
220
224
  req: Request,
221
225
  method: string,
222
- path: string
226
+ path: string,
227
+ pathIsCanonical?: boolean
223
228
  ): Promise<Response> {
224
229
  const stripTrailingSlash = config.stripTrailingSlash ?? true;
225
230
 
226
- // Stage 1: URL canonicalization (belt-and-suspenders).
227
- // The primary canonicalization runs in createPipeline before proxy.ts
228
- // (design/07-routing.md §"URL Canonicalization & Security"). This second
229
- // pass is a safety net for direct callers (tests, non-pipeline usage) —
230
- // on already-canonical paths from createPipeline it's a cheap no-op.
231
- const url = new URL(req.url);
232
- const result = canonicalize(url.pathname, stripTrailingSlash);
233
- if (!result.ok) {
234
- if (isDebug()) {
235
- console.warn(
236
- `[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${result.status}\n` +
237
- ` This usually means the URL contains encoded separators (%2f, %5c),\n` +
238
- ` null bytes (%00), path traversal (..), or malformed percent-encoding.`
239
- );
231
+ // Stage 1: URL canonicalization.
232
+ // When pathIsCanonical is true, createPipeline has already run canonicalize()
233
+ // and passed the result as `path`. Re-canonicalizing would double-decode
234
+ // percent-encoded characters (e.g., %61dmin → admin). See TIM-1004.
235
+ //
236
+ // When pathIsCanonical is false, this is a safety net for direct callers
237
+ // (tests, non-pipeline usage) that haven't pre-canonicalized.
238
+ let canonicalPathname: string;
239
+ if (pathIsCanonical) {
240
+ canonicalPathname = path;
241
+ } else {
242
+ const result = canonicalize(path, stripTrailingSlash);
243
+ if (!result.ok) {
244
+ if (isDebug()) {
245
+ console.warn(
246
+ `[timber] URL canonicalization rejected ${method} ${path} — responding with ${result.status}\n` +
247
+ ` This usually means the URL contains encoded separators (%2f, %5c),\n` +
248
+ ` null bytes (%00), path traversal (..), or malformed percent-encoding.`
249
+ );
250
+ }
251
+ return new Response(null, { status: result.status });
240
252
  }
241
- return new Response(null, { status: result.status });
253
+ canonicalPathname = result.pathname;
242
254
  }
243
- const canonicalPathname = result.pathname;
244
255
 
245
256
  // Stage 1b: Metadata route matching — runs before regular route matching.
246
257
  // Metadata routes skip middleware.ts and access.ts (public endpoints for crawlers).
@@ -377,7 +388,7 @@ export async function handleRequest(
377
388
  if (isDebug()) {
378
389
  console.warn(
379
390
  `[timber] No route matched for pathname: ${canonicalPathname}\n` +
380
- ` Raw URL: ${url.pathname}\n` +
391
+ ` Input path: ${path}\n` +
381
392
  ` Method: ${method}`
382
393
  );
383
394
  }
@@ -327,13 +327,39 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
327
327
  path: canonicalPath,
328
328
  });
329
329
  } else {
330
- result = await handleRequest(config, canonicalReq, method, canonicalPath);
330
+ result = await handleRequest(config, canonicalReq, method, canonicalPath, true);
331
331
  }
332
332
 
333
333
  // Set response status on the root span before it ends —
334
334
  // DevSpanProcessor reads this for tree/summary output.
335
335
  await setSpanAttribute('http.response.status_code', result.status);
336
336
 
337
+ // Vary: Accept — CDNs must cache HTML and RSC payload responses
338
+ // separately for the same URL. Without this, a shared cache may
339
+ // serve an RSC Flight payload to a browser expecting HTML (or
340
+ // vice versa). See GHSA-wfc6-r584-vfw7, design/13-security.md.
341
+ //
342
+ // Vary: X-Timber-URL — intercepting route responses depend on
343
+ // the source URL, so caches must store them separately.
344
+ const varyTokens = ['Accept'];
345
+ if (config.interceptionRewrites?.length) {
346
+ varyTokens.push('X-Timber-URL');
347
+ }
348
+ const existingVary = result.headers.get('Vary');
349
+ const existingTokens = existingVary
350
+ ? existingVary
351
+ .toLowerCase()
352
+ .split(',')
353
+ .map((t) => t.trim())
354
+ : [];
355
+ const newTokens = varyTokens.filter((t) => !existingTokens.includes(t.toLowerCase()));
356
+ if (newTokens.length > 0) {
357
+ result.headers.set(
358
+ 'Vary',
359
+ existingVary ? `${existingVary}, ${newTokens.join(', ')}` : newTokens.join(', ')
360
+ );
361
+ }
362
+
337
363
  // Append Server-Timing header based on configured mode.
338
364
  // Header mutability is guaranteed by the producer-side clone
339
365
  // in `outcomeToResponse` and the metadata-route / auto-sitemap
@@ -8,6 +8,7 @@ import { getWaitUntil as _getWaitUntil } from './waituntil-bridge.js';
8
8
  import { isDebug } from './debug.js';
9
9
  import { getRequestSearchString } from './request-context.js';
10
10
  import { mergePreservedSearchParams } from '../shared/merge-search-params.js';
11
+ import { validateExternalRedirectUrl } from '../shared/href-validation.js';
11
12
 
12
13
  // ─── Dev-mode validation ────────────────────────────────────────────────────
13
14
 
@@ -239,6 +240,9 @@ export class RedirectSignal extends Error {
239
240
  /** Pattern matching absolute URLs: http(s):// or protocol-relative // */
240
241
  const ABSOLUTE_URL_RE = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
241
242
 
243
+ // eslint-disable-next-line no-control-regex -- must match all C0 controls to prevent bypass
244
+ const REDIRECT_CONTROL_CHARS_RE = /[\x00-\x1f]/g;
245
+
242
246
  /**
243
247
  * Options for redirect() — alternative to passing a bare status code.
244
248
  */
@@ -299,7 +303,14 @@ export function redirect(path: string, statusOrOptions?: number | RedirectOption
299
303
  if (status < 300 || status > 399) {
300
304
  throw new Error(`redirect() requires a 3xx status code, got ${status}.`);
301
305
  }
302
- if (ABSOLUTE_URL_RE.test(path)) {
306
+ // Strip C0 control characters and trim whitespace before checking — browsers
307
+ // do this during URL resolution, so " \thttp://evil.com" resolves as absolute.
308
+ // Normalize for validation only: strip C0 controls, trim, then replace backslashes
309
+ // with forward slashes. Browsers normalize \ → / during URL resolution, so
310
+ // "/\evil.com" becomes "//evil.com" (protocol-relative open redirect).
311
+ const cleaned = path.replace(REDIRECT_CONTROL_CHARS_RE, '').trimStart().replaceAll('\\', '/');
312
+
313
+ if (ABSOLUTE_URL_RE.test(cleaned)) {
303
314
  throw new Error(
304
315
  `redirect() only accepts relative URLs. Got absolute URL: "${path}". ` +
305
316
  'Use redirectExternal(url, allowList) for external redirects.'
@@ -325,10 +336,13 @@ export function permanentRedirect(path: string, options?: Omit<RedirectOptions,
325
336
  }
326
337
 
327
338
  /**
328
- * Redirect to an external URL. The hostname must be in the provided allow-list.
339
+ * Redirect to an external URL. The origin must be in the provided allow-list.
340
+ *
341
+ * Only http: and https: schemes are permitted. The allow-list is matched
342
+ * against full origins (scheme + host + port), not bare hostnames.
329
343
  *
330
344
  * @param url - Absolute URL to redirect to.
331
- * @param allowList - Array of allowed hostnames (e.g. ['example.com', 'auth.example.com']).
345
+ * @param allowList - Array of allowed origins (e.g. ['https://example.com', 'https://auth.example.com']).
332
346
  * @param status - HTTP redirect status code (3xx). Defaults to 302.
333
347
  */
334
348
  export function redirectExternal(url: string, allowList: string[], status: number = 302): never {
@@ -336,21 +350,8 @@ export function redirectExternal(url: string, allowList: string[], status: numbe
336
350
  throw new Error(`redirectExternal() requires a 3xx status code, got ${status}.`);
337
351
  }
338
352
 
339
- let hostname: string;
340
- try {
341
- hostname = new URL(url).hostname;
342
- } catch {
343
- throw new Error(`redirectExternal() received an invalid URL: "${url}"`);
344
- }
345
-
346
- if (!allowList.includes(hostname)) {
347
- throw new Error(
348
- `redirectExternal() target "${hostname}" is not in the allow-list. ` +
349
- `Allowed: [${allowList.join(', ')}]`
350
- );
351
- }
352
-
353
- throw new RedirectSignal(url, status);
353
+ const canonicalUrl = validateExternalRedirectUrl(url, allowList);
354
+ throw new RedirectSignal(canonicalUrl, status);
354
355
  }
355
356
 
356
357
  // ─── RenderError ────────────────────────────────────────────────────────────
@@ -67,10 +67,12 @@ function replaceOutermostSegmentOutlet(
67
67
  const props = element.props as Record<string, unknown>;
68
68
  if (element.type === AccessGate && props.children) {
69
69
  const inner = replaceOutermostSegmentOutlet(props.children as React.ReactElement, replacement);
70
+ /* eslint-disable react/no-children-prop -- createElement API */
70
71
  return createElement(element.type as React.FunctionComponent<Record<string, unknown>>, {
71
72
  ...props,
72
73
  children: inner,
73
74
  });
75
+ /* eslint-enable react/no-children-prop */
74
76
  }
75
77
  return element;
76
78
  }
@@ -152,10 +152,11 @@ function getLayoutsForErrorFile(
152
152
  */
153
153
  function toSerializableError(error: unknown): SerializableError {
154
154
  const err = error instanceof Error ? error : new Error(String(error));
155
+ const devMode = isDevMode();
155
156
  return {
156
- message: err.message,
157
- name: err.name,
158
- ...(isDevMode() && err.stack ? { stack: err.stack } : {}),
157
+ message: devMode ? err.message : 'An unexpected error occurred.',
158
+ name: devMode ? err.name : 'Error',
159
+ ...(devMode && err.stack ? { stack: err.stack } : {}),
159
160
  };
160
161
  }
161
162
 
@@ -45,11 +45,10 @@ import { buildClientScripts } from '../html-injectors.js';
45
45
  import type { InterceptionContext, PipelineConfig, RouteMatch } from '../pipeline.js';
46
46
  import { createPipeline } from '../pipeline.js';
47
47
  import { coerceSegmentParams, setGlobalSchemaCodecs } from '../param-coercion.js';
48
- import { setSegmentParams } from '../request-context.js';
49
- import { buildRouteElement } from '../route-element-builder.js';
50
48
  import type { ManifestSegmentNode } from '../route-matcher.js';
51
49
  import { createMetadataRouteMatcher, createRouteMatcher } from '../route-matcher.js';
52
50
  import { initDevTracing } from '../tracing.js';
51
+ import { createRevalidateRendererFactory } from './revalidate-renderer.js';
53
52
 
54
53
  import { renderFallbackError as renderFallback } from '../fallback-error.js';
55
54
  import { loadInstrumentation } from '../instrumentation.js';
@@ -422,40 +421,21 @@ async function createRequestHandler(manifest: typeof routeManifest, runtimeConfi
422
421
  // action POSTs) matches the page pipeline exactly. Defaults to `true`
423
422
  // both here and inside the wrapper.
424
423
  stripTrailingSlash: pipelineConfig.stripTrailingSlash,
425
- buildRevalidateRenderer: (req) => async (path: string) => {
426
- // Build the React element tree for the route at `path`.
427
- // Returns the element tree (not serialized) so the action handler can
428
- // combine it with the action result in a single renderToReadableStream call.
429
- // Forward original request headers (cookies, session IDs, etc.).
430
- const revalidateHeaders = new Headers(req.headers);
431
- revalidateHeaders.set('Accept', 'text/x-component');
432
- const revalidateReq = new Request(new URL(path, req.url), {
433
- headers: revalidateHeaders,
434
- });
435
- const revalidateMatch = matchRoute(new URL(revalidateReq.url).pathname);
436
- if (!revalidateMatch) {
437
- throw new Error(`revalidatePath('${path}') — no matching route`);
438
- }
439
- // Coerce segment params (params.ts) before building the element tree.
440
- // Without this, components receive raw strings instead of typed values.
441
- await coerceSegmentParams(revalidateMatch);
442
- // Set coerced params in ALS so getSegmentParams() works during the
443
- // revalidation render (AccessGate reads params via ALS). Without this,
444
- // AccessGate → getSegmentParams() throws because segmentParams
445
- // is never set. See TIM-667.
446
- setSegmentParams(revalidateMatch.segmentParams);
447
- const routeResult = await buildRouteElement(
448
- revalidateReq,
449
- revalidateMatch,
450
- undefined,
451
- null,
452
- (buildManifest as BuildManifest).metadataRouteHashes
453
- );
454
- return {
455
- element: routeResult.element,
456
- headElements: routeResult.headElements,
457
- };
458
- },
424
+ // Revalidation renderer — runs the target route's `middleware.ts`
425
+ // chain BEFORE building the element tree so a server action that
426
+ // revalidates a protected path honors that path's auth, tenant
427
+ // isolation, and request-header injection. Closes the silent-bypass
428
+ // bug where `revalidatePath('/admin')` from a public action would
429
+ // serialize the admin RSC tree without ever consulting admin's
430
+ // middleware. Also validates the user-supplied `path` as a local
431
+ // root-relative URL to reject `revalidatePath('//evil.com/admin')`
432
+ // and similar scheme/authority injections. See TIM-996 and
433
+ // design/08-forms-and-actions.md §"revalidatePath()".
434
+ buildRevalidateRenderer: createRevalidateRendererFactory({
435
+ matchRoute,
436
+ coerceSegmentParams,
437
+ buildManifest: buildManifest as BuildManifest,
438
+ }),
459
439
  });
460
440
 
461
441
  return { handler, pipelineConfig };
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Revalidation renderer — builds the React element tree for the target
3
+ * path requested by `revalidatePath()` from inside a server action, with
4
+ * the same security guarantees as a normal route render.
5
+ *
6
+ * Before this module existed, `revalidatePath()` matched the route, coerced
7
+ * params, and called `buildRouteElement()` directly. That bypassed the
8
+ * target route's `middleware.ts` chain entirely. An action that revalidated
9
+ * a higher-privilege or user-controlled path could leak an authenticated
10
+ * RSC tree to the action submitter even when middleware on that path
11
+ * would have denied them.
12
+ *
13
+ * The fix (TIM-996) is to run the target route's middleware chain inside
14
+ * a fresh `runWithRequestContext` scope BEFORE building the element tree,
15
+ * propagate `DenySignal` and `RedirectSignal` to `executeAction`, drop the
16
+ * revalidation when middleware returns a short-circuit `Response`, and
17
+ * lift the request-header overlay onto the action's ALS store so the
18
+ * revalidated component render sees middleware-injected headers via
19
+ * `getHeaders()`.
20
+ *
21
+ * The input `path` is also validated as a local root-relative pathname so
22
+ * `revalidatePath('//evil.com/admin')` and similar scheme/authority
23
+ * injections cannot smuggle a foreign host through `new URL(path, base)`.
24
+ *
25
+ * See design/08-forms-and-actions.md §"revalidatePath()" and
26
+ * design/13-security.md §"Server actions silently bypass middleware".
27
+ */
28
+
29
+ import type { BuildManifest } from '../build-manifest.js';
30
+ import { canonicalize } from '../canonicalize.js';
31
+ import { getCookiesForSsr, seedRequestCookies } from '../cookie-context.js';
32
+ import { logRenderError } from '../logger.js';
33
+ import type { RouteMatch, RouteMatcher } from '../pipeline.js';
34
+ import { DenySignal, RedirectSignal } from '../primitives.js';
35
+ import { applyRequestHeaderOverlay, setSegmentParams } from '../request-context.js';
36
+ import { requestContextAls } from '../als-registry.js';
37
+ import { buildRouteElement } from '../route-element-builder.js';
38
+ import type { RevalidateRenderer, RevalidationResult } from '../actions.js';
39
+
40
+ import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
41
+
42
+ /**
43
+ * Sentinel thrown when revalidation cannot proceed because middleware
44
+ * returned a short-circuit `Response` (a non-redirect HTTP response that
45
+ * we cannot translate back to an element tree). `executeAction` catches
46
+ * this and drops the revalidation — the action result is still delivered,
47
+ * but no `_tree` is piggybacked onto the response. The matching name
48
+ * `RevalidationDropped` is exported so callers can identify it via
49
+ * `instanceof` without leaking the message text to clients.
50
+ */
51
+ export class RevalidationDropped extends Error {
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = 'RevalidationDropped';
55
+ }
56
+ }
57
+
58
+ /** Result of validating a `revalidatePath()` argument. */
59
+ type PathValidation =
60
+ | { ok: true; pathname: string; search: string }
61
+ | { ok: false; reason: string };
62
+
63
+ /**
64
+ * Validate a `revalidatePath()` argument.
65
+ *
66
+ * Accepts local root-relative pathnames only. Rejects anything that
67
+ * `new URL(path, base)` would resolve to a different host or scheme:
68
+ *
69
+ * - Empty / non-string input
70
+ * - Anything not starting with `/` (relative, scheme-bearing, etc.)
71
+ * - Protocol-relative `//evil.com/admin` — resolves to scheme://evil.com
72
+ * - Backslash-prefixed `/\evil.com` — some clients fold `\` into `/`
73
+ * - Control characters and null bytes
74
+ * - Paths that fail canonicalization (encoded separators, `..` escaping
75
+ * root, malformed percent-encoding)
76
+ *
77
+ * The pathname is canonicalized so the rest of the renderer matches the
78
+ * same shape the page pipeline matches.
79
+ */
80
+ export function validateRevalidatePath(path: unknown): PathValidation {
81
+ if (typeof path !== 'string' || path.length === 0) {
82
+ return { ok: false, reason: 'must be a non-empty string' };
83
+ }
84
+ if (!path.startsWith('/')) {
85
+ return { ok: false, reason: 'must start with "/" (root-relative path)' };
86
+ }
87
+ if (path.startsWith('//')) {
88
+ return { ok: false, reason: 'must not start with "//" (protocol-relative URL)' };
89
+ }
90
+ if (path.startsWith('/\\')) {
91
+ return { ok: false, reason: 'must not start with "/\\" (backslash injection)' };
92
+ }
93
+ for (let i = 0; i < path.length; i++) {
94
+ const code = path.charCodeAt(i);
95
+ if (code <= 0x1f || code === 0x7f) {
96
+ return { ok: false, reason: 'must not contain control characters' };
97
+ }
98
+ }
99
+ const hashIndex = path.indexOf('#');
100
+ // Fragments are meaningless server-side and would not be sent back to
101
+ // the client on revalidation anyway. Drop them silently before splitting
102
+ // the query so a `#` cannot smuggle additional path content past the
103
+ // canonicalizer.
104
+ const noFragment = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
105
+ const queryIndex = noFragment.indexOf('?');
106
+ const pathnameOnly = queryIndex >= 0 ? noFragment.slice(0, queryIndex) : noFragment;
107
+ const search = queryIndex >= 0 ? noFragment.slice(queryIndex) : '';
108
+ const canonical = canonicalize(pathnameOnly, true);
109
+ if (!canonical.ok) {
110
+ return { ok: false, reason: `failed URL canonicalization (status ${canonical.status})` };
111
+ }
112
+ return { ok: true, pathname: canonical.pathname, search };
113
+ }
114
+
115
+ /**
116
+ * Serialize a cookie map into a `Cookie:` request header value
117
+ * (`name1=value1; name2=value2`).
118
+ *
119
+ * Cookie values are RFC 6265 cookie-octet (no `;`, no `,`, no whitespace,
120
+ * no `"` / `\`) by construction — `assertValidCookieValue()` rejects
121
+ * anything else at write time. Names are validated similarly. Joining
122
+ * with `; ` is therefore safe and cannot be used to smuggle additional
123
+ * cookies via a malformed value.
124
+ */
125
+ function serializeCookieMap(cookies: Map<string, string>): string {
126
+ const parts: string[] = [];
127
+ cookies.forEach((value, name) => {
128
+ parts.push(`${name}=${value}`);
129
+ });
130
+ return parts.join('; ');
131
+ }
132
+
133
+ /** Dependencies for the revalidate renderer factory. */
134
+ export interface RevalidateRendererDeps {
135
+ matchRoute: RouteMatcher;
136
+ coerceSegmentParams: CoerceSegmentParamsFn;
137
+ buildManifest: BuildManifest;
138
+ }
139
+
140
+ /**
141
+ * Build a per-request `RevalidateRenderer`.
142
+ *
143
+ * The returned renderer:
144
+ * 1. Validates the requested path.
145
+ * 2. Matches the canonicalized path against the route tree.
146
+ * 3. Coerces segment params via the matched chain's `params.ts` codecs.
147
+ * 4. Runs the matched route's `middleware.ts` chain inside a fresh
148
+ * `runWithRequestContext` scope (via `runMiddlewareForAction`).
149
+ * Deny/redirect signals are re-thrown so `executeAction` can apply
150
+ * its existing redirect handling and the new deny-drop behavior.
151
+ * A short-circuit `Response` becomes `RevalidationDropped`.
152
+ * 5. Lifts the request-header overlay onto the OUTER action's ALS store
153
+ * so the revalidated component render (which happens later inside
154
+ * `renderToReadableStream` in the action's scope) observes
155
+ * middleware-injected headers via `getHeaders()`. Acceptable because
156
+ * revalidation runs after the action body has returned — the only
157
+ * remaining ALS readers are render-phase code that should see the
158
+ * same overlay a normal page render would see.
159
+ * 6. Builds and returns the route element tree, ready for the action
160
+ * handler to serialize alongside the action result.
161
+ *
162
+ * Cookies set inside the revalidation middleware are intentionally NOT
163
+ * propagated to the action response. The middleware on a *different* path
164
+ * setting cookies via the action's response is surprising semantics; the
165
+ * scoped cookie jar on the inner ALS run is discarded with the scope.
166
+ * The primary security boundary closed here is auth — `deny()` from the
167
+ * target's middleware now prevents the revalidated tree from leaking.
168
+ */
169
+ export function createRevalidateRendererFactory(
170
+ deps: RevalidateRendererDeps
171
+ ): (req: Request) => RevalidateRenderer {
172
+ return (req: Request): RevalidateRenderer => {
173
+ return async (path: string): Promise<RevalidationResult> => {
174
+ // ── 1. Validate the path ────────────────────────────────────────
175
+ const validation = validateRevalidatePath(path);
176
+ if (!validation.ok) {
177
+ // Throwing a typed error here drops the revalidation. We do not
178
+ // surface the message to the client (logRenderError sinks it).
179
+ const err = new RevalidationDropped(
180
+ `revalidatePath(${JSON.stringify(path)}) rejected: ${validation.reason}`
181
+ );
182
+ logRenderError({ method: req.method, path: new URL(req.url).pathname, error: err });
183
+ throw err;
184
+ }
185
+
186
+ // ── 2. Build the revalidation Request on the canonical pathname ─
187
+ //
188
+ // Use the canonical pathname (not the raw input) so the rebuilt
189
+ // URL cannot carry a hostile authority — `new URL('//evil.com/x',
190
+ // base)` would otherwise resolve to scheme://evil.com/x. Forward
191
+ // the original action request's headers (session IDs, accept-
192
+ // language, etc.) so middleware on the target route sees the same
193
+ // identity the action did.
194
+ //
195
+ // The `Cookie:` header is REBUILT from the action's
196
+ // read-your-own-writes cookie state rather than copied from the
197
+ // inbound request. An action that rotates the session via
198
+ // `getCookieJar().set('session', newToken)` and then calls
199
+ // `revalidatePath('/dashboard')` should have the target route's
200
+ // middleware see the NEW session — otherwise a session-rotation
201
+ // action would succeed only to have the piggybacked revalidation
202
+ // redirect to /login. `getCookieJar()` writes only update the ALS
203
+ // jar, not the immutable `Request.headers`, so without this step
204
+ // middleware reading `ctx.req.headers.get('cookie')` sees stale
205
+ // values. Codex P2.
206
+ const baseUrl = new URL(req.url);
207
+ const revalidateUrl = new URL(baseUrl.origin);
208
+ revalidateUrl.pathname = validation.pathname;
209
+ revalidateUrl.search = validation.search;
210
+ const revalidateHeaders = new Headers(req.headers);
211
+ revalidateHeaders.set('Accept', 'text/x-component');
212
+ // Snapshot the action's post-mutation cookie map (deletions
213
+ // included) and serialize it into a `Cookie:` header value the
214
+ // target middleware can read off `ctx.req.headers.get('cookie')`.
215
+ // Only attempt this inside a request context — `getCookiesForSsr`
216
+ // throws otherwise — and bail out gracefully if no context is
217
+ // active (the legacy call-site contract permitted this).
218
+ const inRequestContext = Boolean(requestContextAls.getStore());
219
+ const ryw = inRequestContext ? getCookiesForSsr() : new Map<string, string>();
220
+ if (inRequestContext) {
221
+ if (ryw.size === 0) {
222
+ revalidateHeaders.delete('cookie');
223
+ } else {
224
+ revalidateHeaders.set('cookie', serializeCookieMap(ryw));
225
+ }
226
+ }
227
+ const revalidateReq = new Request(revalidateUrl.toString(), {
228
+ headers: revalidateHeaders,
229
+ });
230
+ // Seed the parsed map directly so the child `runWithRequestContext`
231
+ // inside `runMiddlewareForAction` skips `parseCookieHeader` — the
232
+ // same H-3 smuggling primitive mitigated by TIM-868. The Cookie
233
+ // header above remains in place for code that reads
234
+ // `ctx.req.headers.get('cookie')` directly.
235
+ if (inRequestContext) {
236
+ seedRequestCookies(revalidateReq, new Map(ryw));
237
+ }
238
+
239
+ // ── 3. Match the canonical path ─────────────────────────────────
240
+ const match = deps.matchRoute(validation.pathname);
241
+ if (!match) {
242
+ throw new Error(`revalidatePath('${path}') — no matching route`);
243
+ }
244
+
245
+ // ── 4. Run middleware in a fresh ALS scope ──────────────────────
246
+ //
247
+ // We only run middleware when the matched chain has at least one
248
+ // entry. Routes with no middleware skip this step (no scope, no
249
+ // overlay) — matching the page pipeline's behavior in
250
+ // `handleRequest` where `runMiddlewarePhase` is gated on
251
+ // `middlewareChain.length > 0`.
252
+ let overlay: Headers | undefined;
253
+ if (match.middlewareChain.length > 0) {
254
+ const outcome = await runMiddlewareForAction(
255
+ revalidateReq,
256
+ match,
257
+ deps.coerceSegmentParams
258
+ );
259
+
260
+ if (outcome.kind === 'param-coercion-error') {
261
+ // Bad params on the target path — drop revalidation. The
262
+ // action's own response still goes back to the client.
263
+ throw new RevalidationDropped(
264
+ `revalidatePath('${path}') — segment param coercion failed`
265
+ );
266
+ }
267
+ if (outcome.kind === 'error') {
268
+ // Re-throw unhandled middleware errors so `executeAction`'s
269
+ // existing `console.error` fallback logs them. Not surfaced
270
+ // to the action client (revalidation is best-effort).
271
+ throw outcome.error;
272
+ }
273
+ if (outcome.kind === 'redirect') {
274
+ // Propagate the redirect — `executeAction` catches
275
+ // RedirectSignal and converts it to an action-level redirect.
276
+ // See actions.ts §"Process path revalidation".
277
+ throw outcome.signal;
278
+ }
279
+ if (outcome.kind === 'deny') {
280
+ // Propagate the deny — `executeAction` catches DenySignal
281
+ // (added in this PR) and drops the revalidation.
282
+ throw outcome.signal;
283
+ }
284
+ if (outcome.kind === 'short-circuit') {
285
+ // Middleware returned a Response directly. A redirect Response
286
+ // (`return Response.redirect('/login')` or a 3xx + Location)
287
+ // is the same outcome as throwing `redirect()` — common for
288
+ // session-expiry / feature-flag gates — and the action client
289
+ // should follow it. Translate to a `RedirectSignal` so
290
+ // `executeAction` surfaces it as the action redirect, matching
291
+ // the normal page-pipeline behavior promised in
292
+ // design/08-forms-and-actions.md §"revalidatePath()". Codex P2.
293
+ const status = outcome.response.status;
294
+ const location = outcome.response.headers.get('location');
295
+ if (status >= 300 && status < 400 && location) {
296
+ throw new RedirectSignal(location, status);
297
+ }
298
+ // Any other short-circuit Response (200/403/418/etc.) cannot
299
+ // be translated to a piggy-backed element tree. Drop the
300
+ // revalidation; the action result still ships.
301
+ throw new RevalidationDropped(
302
+ `revalidatePath('${path}') — middleware returned a short-circuit Response (status ${status})`
303
+ );
304
+ }
305
+
306
+ // `continue` — capture the overlay so we can apply it to the
307
+ // action's request-context store below. We deliberately do NOT
308
+ // forward `outcome.cookies` or `outcome.setCookieHeaders` here;
309
+ // see the module docstring for why.
310
+ overlay = outcome.overlay;
311
+ } else {
312
+ // No middleware on the target — still coerce params so segment
313
+ // params reach `setSegmentParams` typed. `runMiddlewareForAction`
314
+ // would have done this for us; do it manually on the bypass path.
315
+ await deps.coerceSegmentParams(match);
316
+ }
317
+
318
+ // ── 5. Lift state onto the action's ALS store ───────────────────
319
+ //
320
+ // Segment params are set unconditionally so `getSegmentParams()`
321
+ // inside the revalidated tree returns the target path's coerced
322
+ // params, not the action route's. The overlay is applied only
323
+ // when middleware ran and produced headers — otherwise we leave
324
+ // the action's `headers()` view untouched.
325
+ setSegmentParams(match.segmentParams);
326
+ if (overlay) {
327
+ applyRequestHeaderOverlay(overlay);
328
+ }
329
+
330
+ // ── 6. Build the route element tree ─────────────────────────────
331
+ const routeResult = await buildRouteElement(
332
+ revalidateReq,
333
+ match,
334
+ undefined,
335
+ null,
336
+ deps.buildManifest.metadataRouteHashes
337
+ );
338
+ return {
339
+ element: routeResult.element,
340
+ headElements: routeResult.headElements,
341
+ };
342
+ };
343
+ };
344
+ }
@@ -172,10 +172,6 @@ export async function buildRscPayloadResponse(
172
172
  });
173
173
 
174
174
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
175
- // Vary on Accept so CDNs cache HTML and RSC responses separately
176
- // for the same URL. The client appends ?_rsc=<id> as a cache-bust,
177
- // but Vary ensures correct behavior even without the query param.
178
- responseHeaders.set('Vary', 'Accept');
179
175
 
180
176
  // Send resolved head elements so the client can update document.title
181
177
  // and <meta> tags after SPA navigation. See design/16-metadata.md.