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

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 (78) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-BNhpxrnV.js → actions-B8Fhsosm.js} +51 -12
  3. package/dist/_chunks/actions-B8Fhsosm.js.map +1 -0
  4. package/dist/_chunks/build-output-helper-K7oUCRqU.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.map +1 -1
  7. package/dist/_chunks/define-DMq-Yjp7.js.map +1 -1
  8. package/dist/_chunks/define-jeHDXZGq.js.map +1 -1
  9. package/dist/_chunks/format-BjN9HLu9.js.map +1 -1
  10. package/dist/_chunks/handler-store-bIAstrxQ.js.map +1 -1
  11. package/dist/_chunks/href-validation-BJmD5mZh.js +124 -0
  12. package/dist/_chunks/href-validation-BJmD5mZh.js.map +1 -0
  13. package/dist/_chunks/logger-CMd9P1xQ.js.map +1 -1
  14. package/dist/_chunks/metadata-routes-DESG8uk5.js.map +1 -1
  15. package/dist/_chunks/navigation-root-B5-RmsPi.js.map +1 -1
  16. package/dist/_chunks/registry-BI9cgCt7.js.map +1 -1
  17. package/dist/_chunks/resolve-schema-DU2nbVOi.js.map +1 -1
  18. package/dist/_chunks/router-ref-B2-2kIIC.js.map +1 -1
  19. package/dist/_chunks/schema-bridge-o0SwFtXW.js.map +1 -1
  20. package/dist/_chunks/segment-classify-BjfuctV2.js.map +1 -1
  21. package/dist/_chunks/segment-context-Bg0pFbLT.js.map +1 -1
  22. package/dist/_chunks/ssr-data-DVX8S7vj.js.map +1 -1
  23. package/dist/_chunks/stale-reload-CCqb62Z3.js.map +1 -1
  24. package/dist/_chunks/tracing-DP5djRq5.js.map +1 -1
  25. package/dist/_chunks/use-query-states-C9G6LP0F.js.map +1 -1
  26. package/dist/_chunks/use-segment-params-CIeinIx2.js.map +1 -1
  27. package/dist/_chunks/validation-AfEFM5ze.js.map +1 -1
  28. package/dist/_chunks/walkers-BbEu2_Pp.js.map +1 -1
  29. package/dist/_chunks/warnings-CAzunaGo.js.map +1 -1
  30. package/dist/adapters/cloudflare-dev.js.map +1 -1
  31. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  32. package/dist/adapters/cloudflare.js.map +1 -1
  33. package/dist/adapters/nitro.js.map +1 -1
  34. package/dist/cache/index.js.map +1 -1
  35. package/dist/cli.js.map +1 -1
  36. package/dist/client/error-boundary.js.map +1 -1
  37. package/dist/client/index.js +5 -17
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/client/internal.js.map +1 -1
  40. package/dist/client/link.d.ts +2 -4
  41. package/dist/client/link.d.ts.map +1 -1
  42. package/dist/client/use-router.d.ts.map +1 -1
  43. package/dist/codec.js.map +1 -1
  44. package/dist/cookies/index.js.map +1 -1
  45. package/dist/index.js +153 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/params/index.js.map +1 -1
  48. package/dist/search-params/index.js.map +1 -1
  49. package/dist/server/deny-renderer.d.ts.map +1 -1
  50. package/dist/server/index.js +1 -1
  51. package/dist/server/index.js.map +1 -1
  52. package/dist/server/internal.js +22 -11
  53. package/dist/server/internal.js.map +1 -1
  54. package/dist/server/pipeline-interception.d.ts.map +1 -1
  55. package/dist/server/pipeline-phases.d.ts +5 -1
  56. package/dist/server/pipeline-phases.d.ts.map +1 -1
  57. package/dist/server/pipeline.d.ts.map +1 -1
  58. package/dist/server/primitives.d.ts +5 -2
  59. package/dist/server/primitives.d.ts.map +1 -1
  60. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  61. package/dist/shared/href-validation.d.ts +38 -0
  62. package/dist/shared/href-validation.d.ts.map +1 -0
  63. package/dist/shims/font-google.js.map +1 -1
  64. package/dist/shims/font-local.js.map +1 -1
  65. package/package.json +10 -11
  66. package/src/cli.ts +0 -0
  67. package/src/client/link.tsx +5 -32
  68. package/src/client/use-router.ts +3 -0
  69. package/src/server/deny-renderer.ts +0 -1
  70. package/src/server/pipeline-interception.ts +1 -0
  71. package/src/server/pipeline-phases.ts +30 -19
  72. package/src/server/pipeline.ts +18 -1
  73. package/src/server/primitives.ts +8 -17
  74. package/src/server/rsc-entry/rsc-payload.ts +0 -4
  75. package/src/shared/href-validation.ts +123 -0
  76. package/dist/_chunks/actions-BNhpxrnV.js.map +0 -1
  77. package/dist/_chunks/merge-search-params-Cm1j99HC.js +0 -41
  78. package/dist/_chunks/merge-search-params-Cm1j99HC.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAaD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,GACnE,uBAAuB,GAAG,IAAI,CAahC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAyBjF"}
1
+ {"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAcD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,GACnE,uBAAuB,GAAG,IAAI,CAahC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAyBjF"}
@@ -46,7 +46,11 @@ export declare function runRenderPhase(config: PipelineConfig, req: Request, mat
46
46
  *
47
47
  * Used both as the top-level entry (when no proxy.ts is configured) and as
48
48
  * the `next()` continuation passed to `runProxy()`.
49
+ *
50
+ * @param pathIsCanonical When true, `path` has already been canonicalized by
51
+ * `createPipeline` — skip re-canonicalization to prevent double-decode.
52
+ * When false (default), runs canonicalize as a safety net for direct callers.
49
53
  */
50
- export declare function handleRequest(config: PipelineConfig, req: Request, method: string, path: string): Promise<Response>;
54
+ export declare function handleRequest(config: PipelineConfig, req: Request, method: string, path: string, pathIsCanonical?: boolean): Promise<Response>;
51
55
  export {};
52
56
  //# sourceMappingURL=pipeline-phases.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,CAavB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,QAAQ,CAAC,CAkRnB"}
1
+ {"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,CAavB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAAC,QAAQ,CAAC,CAwRnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
@@ -130,10 +130,13 @@ export declare function redirect(path: string, statusOrOptions?: number | Redire
130
130
  */
131
131
  export declare function permanentRedirect(path: string, options?: Omit<RedirectOptions, 'status'>): never;
132
132
  /**
133
- * Redirect to an external URL. The hostname must be in the provided allow-list.
133
+ * Redirect to an external URL. The origin must be in the provided allow-list.
134
+ *
135
+ * Only http: and https: schemes are permitted. The allow-list is matched
136
+ * against full origins (scheme + host + port), not bare hostnames.
134
137
  *
135
138
  * @param url - Absolute URL to redirect to.
136
- * @param allowList - Array of allowed hostnames (e.g. ['example.com', 'auth.example.com']).
139
+ * @param allowList - Array of allowed origins (e.g. ['https://example.com', 'https://auth.example.com']).
137
140
  * @param status - HTTP redirect status code (3xx). Defaults to 302.
138
141
  */
139
142
  export declare function redirectExternal(url: string, allowList: string[], status?: number): never;
@@ -1 +1 @@
1
- {"version":3,"file":"primitives.d.ts","sourceRoot":"","sources":["../../src/server/primitives.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"primitives.d.ts","sourceRoot":"","sources":["../../src/server/primitives.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACrF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAQ/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CAyKnB"}
1
+ {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACrF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAQ/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CAqKnB"}
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Shared href/URL validation for all navigation sinks.
3
+ *
4
+ * Used by <Link>, useRouter().push/replace, and redirectExternal().
5
+ * Single source of truth for dangerous-scheme detection — prevents
6
+ * control-character smuggling that bypasses naive regex checks.
7
+ *
8
+ * See design/13-security.md §"Link scheme injection" (test #9)
9
+ * and design/07-routing.md §"<Link> Scheme Validation".
10
+ */
11
+ /**
12
+ * Validate an href for navigation sinks (<Link>, useRouter, etc.).
13
+ *
14
+ * Rejects dangerous schemes (javascript:, data:, vbscript:) even when
15
+ * control characters are embedded to bypass naive regex checks.
16
+ * Also rejects protocol-relative URLs (//evil.com).
17
+ *
18
+ * Throws on rejection. Safe hrefs pass through silently.
19
+ */
20
+ export declare function validateNavigationHref(href: string): void;
21
+ /**
22
+ * Check if an href is internal (same-origin navigation) or external.
23
+ *
24
+ * Strips control characters before checking so that "java\tscript:" is
25
+ * correctly identified as having a scheme (and thus not internal).
26
+ */
27
+ export declare function isInternalHref(href: string): boolean;
28
+ /**
29
+ * Validate and canonicalize an external redirect URL.
30
+ *
31
+ * Requires http: or https: scheme and checks origin against an allow-list.
32
+ * Returns the parsed canonical URL string (from URL.href) to prevent
33
+ * raw user input from reaching the Location header.
34
+ *
35
+ * @throws On invalid URL, dangerous scheme, or origin not in allow-list.
36
+ */
37
+ export declare function validateExternalRedirectUrl(url: string, allowList: string[]): string;
38
+ //# sourceMappingURL=href-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"href-validation.d.ts","sourceRoot":"","sources":["../../src/shared/href-validation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAsBH;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAkBzD;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUpD;AAID;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,MAAM,CAiCpF"}
@@ -1 +1 @@
1
- {"version":3,"file":"font-google.js","names":[],"sources":["../../src/shims/font-google.ts"],"sourcesContent":["/**\n * Fallback shim for `next/font/google` → `@timber/fonts/google`.\n *\n * At build time, the timber-fonts plugin's transform hook replaces font\n * function calls with static FontResult objects containing real class names\n * and font stacks. This file serves two purposes:\n *\n * 1. **TypeScript resolution** — provides types for IDEs and `tsc` outside\n * of Vite's module graph (where the virtual module handles it).\n * 2. **Runtime fallback** — returns empty className/fontFamily values when\n * the plugin hasn't processed a call (e.g. in tests or non-Vite environments).\n *\n * The shim resolution path (`next/font/google` → `\\0@timber/fonts/google`)\n * is unchanged — this file is NOT on that resolution path. It exists as a\n * physical module for direct imports and type re-exports.\n *\n * Design doc: 24-fonts.md, \"Next.js Font Compatibility\"\n */\n\nimport type { GoogleFontConfig, FontResult } from '../fonts/types.js';\n\nexport type { GoogleFontConfig, FontResult };\n\n/**\n * Create a stub FontResult with empty values.\n *\n * The timber-fonts plugin replaces these calls at build time with real\n * class names and font stacks. This stub ensures code that imports\n * font functions outside of the build pipeline (tests, type-checking)\n * gets a valid FontResult shape without runtime errors.\n */\nfunction createStubFontResult(config?: GoogleFontConfig): FontResult {\n return {\n className: '',\n style: { fontFamily: '' },\n variable: config?.variable,\n };\n}\n\n/**\n * Generic font loader — accepts any font name, returns a stub FontResult.\n *\n * Named exports like `Inter`, `Roboto`, etc. are generated dynamically\n * by the virtual module at build time. This function provides a catch-all\n * for non-Vite contexts.\n */\nexport function createFont(_family: string, config?: GoogleFontConfig): FontResult {\n return createStubFontResult(config);\n}\n\n// Common Google Font exports for TypeScript autocomplete.\n// These are stubs — the virtual module and transform hook provide real values.\nexport const Inter = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Roboto = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Open_Sans = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Lato = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Montserrat = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Poppins = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Geist = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Geist_Mono = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const JetBrains_Mono = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Source_Code_Pro = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Playfair_Display = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Merriweather = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\n"],"mappings":";;;;;;;;;AA+BA,SAAS,qBAAqB,QAAuC;AACnE,QAAO;EACL,WAAW;EACX,OAAO,EAAE,YAAY,IAAI;EACzB,UAAU,QAAQ;EACnB;;;;;;;;;AAUH,SAAgB,WAAW,SAAiB,QAAuC;AACjF,QAAO,qBAAqB,OAAO;;AAKrC,IAAa,SAAS,WAA0C,qBAAqB,OAAO;AAC5F,IAAa,UAAU,WAA0C,qBAAqB,OAAO;AAC7F,IAAa,aAAa,WAA0C,qBAAqB,OAAO;AAChG,IAAa,QAAQ,WAA0C,qBAAqB,OAAO;AAC3F,IAAa,cAAc,WAA0C,qBAAqB,OAAO;AACjG,IAAa,WAAW,WAA0C,qBAAqB,OAAO;AAC9F,IAAa,SAAS,WAA0C,qBAAqB,OAAO;AAC5F,IAAa,cAAc,WAA0C,qBAAqB,OAAO;AACjG,IAAa,kBAAkB,WAC7B,qBAAqB,OAAO;AAC9B,IAAa,mBAAmB,WAC9B,qBAAqB,OAAO;AAC9B,IAAa,oBAAoB,WAC/B,qBAAqB,OAAO;AAC9B,IAAa,gBAAgB,WAA0C,qBAAqB,OAAO"}
1
+ {"version":3,"file":"font-google.js","names":[],"sources":["../../src/shims/font-google.ts"],"sourcesContent":["/**\n * Fallback shim for `next/font/google` → `@timber/fonts/google`.\n *\n * At build time, the timber-fonts plugin's transform hook replaces font\n * function calls with static FontResult objects containing real class names\n * and font stacks. This file serves two purposes:\n *\n * 1. **TypeScript resolution** — provides types for IDEs and `tsc` outside\n * of Vite's module graph (where the virtual module handles it).\n * 2. **Runtime fallback** — returns empty className/fontFamily values when\n * the plugin hasn't processed a call (e.g. in tests or non-Vite environments).\n *\n * The shim resolution path (`next/font/google` → `\\0@timber/fonts/google`)\n * is unchanged — this file is NOT on that resolution path. It exists as a\n * physical module for direct imports and type re-exports.\n *\n * Design doc: 24-fonts.md, \"Next.js Font Compatibility\"\n */\n\nimport type { GoogleFontConfig, FontResult } from '../fonts/types.js';\n\nexport type { GoogleFontConfig, FontResult };\n\n/**\n * Create a stub FontResult with empty values.\n *\n * The timber-fonts plugin replaces these calls at build time with real\n * class names and font stacks. This stub ensures code that imports\n * font functions outside of the build pipeline (tests, type-checking)\n * gets a valid FontResult shape without runtime errors.\n */\nfunction createStubFontResult(config?: GoogleFontConfig): FontResult {\n return {\n className: '',\n style: { fontFamily: '' },\n variable: config?.variable,\n };\n}\n\n/**\n * Generic font loader — accepts any font name, returns a stub FontResult.\n *\n * Named exports like `Inter`, `Roboto`, etc. are generated dynamically\n * by the virtual module at build time. This function provides a catch-all\n * for non-Vite contexts.\n */\nexport function createFont(_family: string, config?: GoogleFontConfig): FontResult {\n return createStubFontResult(config);\n}\n\n// Common Google Font exports for TypeScript autocomplete.\n// These are stubs — the virtual module and transform hook provide real values.\nexport const Inter = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Roboto = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Open_Sans = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Lato = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Montserrat = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Poppins = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Geist = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const Geist_Mono = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\nexport const JetBrains_Mono = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Source_Code_Pro = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Playfair_Display = (config?: GoogleFontConfig): FontResult =>\n createStubFontResult(config);\nexport const Merriweather = (config?: GoogleFontConfig): FontResult => createStubFontResult(config);\n"],"mappings":";;;;;;;;;AA+BA,SAAS,qBAAqB,QAAuC;CACnE,OAAO;EACL,WAAW;EACX,OAAO,EAAE,YAAY,IAAI;EACzB,UAAU,QAAQ;EACnB;;;;;;;;;AAUH,SAAgB,WAAW,SAAiB,QAAuC;CACjF,OAAO,qBAAqB,OAAO;;AAKrC,IAAa,SAAS,WAA0C,qBAAqB,OAAO;AAC5F,IAAa,UAAU,WAA0C,qBAAqB,OAAO;AAC7F,IAAa,aAAa,WAA0C,qBAAqB,OAAO;AAChG,IAAa,QAAQ,WAA0C,qBAAqB,OAAO;AAC3F,IAAa,cAAc,WAA0C,qBAAqB,OAAO;AACjG,IAAa,WAAW,WAA0C,qBAAqB,OAAO;AAC9F,IAAa,SAAS,WAA0C,qBAAqB,OAAO;AAC5F,IAAa,cAAc,WAA0C,qBAAqB,OAAO;AACjG,IAAa,kBAAkB,WAC7B,qBAAqB,OAAO;AAC9B,IAAa,mBAAmB,WAC9B,qBAAqB,OAAO;AAC9B,IAAa,oBAAoB,WAC/B,qBAAqB,OAAO;AAC9B,IAAa,gBAAgB,WAA0C,qBAAqB,OAAO"}
@@ -1 +1 @@
1
- {"version":3,"file":"font-local.js","names":[],"sources":["../../src/shims/font-local.ts"],"sourcesContent":["/**\n * Fallback shim for `next/font/local` → `@timber/fonts/local`.\n *\n * At build time, the timber-fonts plugin's transform hook replaces\n * `localFont()` calls with static FontResult objects containing real\n * class names and font stacks. This file serves two purposes:\n *\n * 1. **TypeScript resolution** — provides types for IDEs and `tsc` outside\n * of Vite's module graph (where the virtual module handles it).\n * 2. **Runtime fallback** — returns empty className/fontFamily values when\n * the plugin hasn't processed a call (e.g. in tests or non-Vite environments).\n *\n * Design doc: 24-fonts.md, \"Next.js Font Compatibility\"\n */\n\nimport type { LocalFontConfig, FontResult } from '../fonts/types.js';\n\nexport type { LocalFontConfig, FontResult };\n\n/**\n * Create a local font.\n *\n * The timber-fonts plugin replaces these calls at build time with real\n * class names and font stacks. This stub ensures code that imports\n * `localFont` outside of the build pipeline (tests, type-checking)\n * gets a valid FontResult shape without runtime errors.\n */\nexport default function localFont(config?: LocalFontConfig): FontResult {\n return {\n className: '',\n style: { fontFamily: '' },\n variable: config?.variable,\n };\n}\n"],"mappings":";;;;;;;;;AA2BA,SAAwB,UAAU,QAAsC;AACtE,QAAO;EACL,WAAW;EACX,OAAO,EAAE,YAAY,IAAI;EACzB,UAAU,QAAQ;EACnB"}
1
+ {"version":3,"file":"font-local.js","names":[],"sources":["../../src/shims/font-local.ts"],"sourcesContent":["/**\n * Fallback shim for `next/font/local` → `@timber/fonts/local`.\n *\n * At build time, the timber-fonts plugin's transform hook replaces\n * `localFont()` calls with static FontResult objects containing real\n * class names and font stacks. This file serves two purposes:\n *\n * 1. **TypeScript resolution** — provides types for IDEs and `tsc` outside\n * of Vite's module graph (where the virtual module handles it).\n * 2. **Runtime fallback** — returns empty className/fontFamily values when\n * the plugin hasn't processed a call (e.g. in tests or non-Vite environments).\n *\n * Design doc: 24-fonts.md, \"Next.js Font Compatibility\"\n */\n\nimport type { LocalFontConfig, FontResult } from '../fonts/types.js';\n\nexport type { LocalFontConfig, FontResult };\n\n/**\n * Create a local font.\n *\n * The timber-fonts plugin replaces these calls at build time with real\n * class names and font stacks. This stub ensures code that imports\n * `localFont` outside of the build pipeline (tests, type-checking)\n * gets a valid FontResult shape without runtime errors.\n */\nexport default function localFont(config?: LocalFontConfig): FontResult {\n return {\n className: '',\n style: { fontFamily: '' },\n variable: config?.variable,\n };\n}\n"],"mappings":";;;;;;;;;AA2BA,SAAwB,UAAU,QAAsC;CACtE,OAAO;EACL,WAAW;EACX,OAAO,EAAE,YAAY,IAAI;EACzB,UAAU,QAAQ;EACnB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.133",
3
+ "version": "0.2.0-alpha.134",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -114,17 +114,12 @@
114
114
  "publishConfig": {
115
115
  "access": "public"
116
116
  },
117
- "scripts": {
118
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
119
- "typecheck": "tsgo --noEmit",
120
- "prepublishOnly": "pnpm run build"
121
- },
122
117
  "dependencies": {
123
118
  "@opentelemetry/api": "^1.9.1",
124
- "@opentelemetry/context-async-hooks": "^2.6.1",
125
- "@opentelemetry/sdk-trace-base": "^2.6.1",
119
+ "@opentelemetry/context-async-hooks": "^2.7.1",
120
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
126
121
  "magic-string": "^0.30.21",
127
- "nitro": "3.0.260311-beta"
122
+ "nitro": "3.0.260429-beta"
128
123
  },
129
124
  "peerDependencies": {
130
125
  "@content-collections/core": "^0.14.0",
@@ -136,7 +131,7 @@
136
131
  "nuqs": "^2.0.0",
137
132
  "react": "19.3.0-canary-d5736f09-20260507",
138
133
  "react-dom": "19.3.0-canary-d5736f09-20260507",
139
- "vite": "^8.0.3",
134
+ "vite": "^8.0.8",
140
135
  "wrangler": "^4.0.0",
141
136
  "zod": "^3.22.0 || ^4.0.0"
142
137
  },
@@ -162,5 +157,9 @@
162
157
  },
163
158
  "engines": {
164
159
  "node": ">=22.12.0"
160
+ },
161
+ "scripts": {
162
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
163
+ "typecheck": "tsgo --noEmit"
165
164
  }
166
- }
165
+ }
package/src/cli.ts CHANGED
File without changes
@@ -28,6 +28,10 @@ import type { SearchParamsDefinition } from '../search-params/define.js';
28
28
  import { getSearchParamsDefinition } from '../search-params/registry.js';
29
29
  import type { LinkFunction } from './index.js';
30
30
  import { classifyUrlSegment, type UrlSegment } from '../routing/segment-classify.js';
31
+ import {
32
+ validateNavigationHref as validateLinkHref,
33
+ isInternalHref,
34
+ } from '../shared/href-validation.js';
31
35
  import { LinkStatusContext } from './use-link-status.js';
32
36
  import { getRouterOrNull } from './router-ref.js';
33
37
  import { getSsrData } from './ssr-data.js';
@@ -168,38 +172,7 @@ export type LinkPropsWithParams = LinkRuntimeProps & {
168
172
  };
169
173
  export type LinkProps = LinkRuntimeProps;
170
174
 
171
- // ─── Dangerous URL Scheme Detection ──────────────────────────────
172
-
173
- /**
174
- * Reject dangerous URL schemes that could execute script.
175
- * Security: design/13-security.md § Link scheme injection (test #9)
176
- */
177
- const DANGEROUS_SCHEMES = /^\s*(javascript|data|vbscript):/i;
178
-
179
- export function validateLinkHref(href: string): void {
180
- if (DANGEROUS_SCHEMES.test(href)) {
181
- throw new Error(
182
- `<Link> received a dangerous href: "${href}". ` +
183
- 'javascript:, data:, and vbscript: URLs are not allowed.'
184
- );
185
- }
186
- }
187
-
188
- // ─── Internal Link Detection ─────────────────────────────────────
189
-
190
- /** Returns true if the href is an internal path (not an external URL) */
191
- export function isInternalHref(href: string): boolean {
192
- // Relative paths, root-relative paths, and hash links are internal
193
- if (href.startsWith('/') || href.startsWith('#') || href.startsWith('?')) {
194
- return true;
195
- }
196
- // Anything with a protocol scheme is external
197
- if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
198
- return false;
199
- }
200
- // Bare relative paths (e.g., "dashboard") are internal
201
- return true;
202
- }
175
+ export { validateLinkHref, isInternalHref };
203
176
 
204
177
  // ─── URL Interpolation ──────────────────────────────────────────
205
178
 
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { getRouterOrNull } from './router-ref.js';
27
+ import { validateNavigationHref } from '../shared/href-validation.js';
27
28
 
28
29
  export interface AppRouterInstance {
29
30
  /** Navigate to a URL, pushing a new history entry */
@@ -60,6 +61,7 @@ export interface AppRouterInstance {
60
61
  export function useRouter(): AppRouterInstance {
61
62
  return {
62
63
  push(href: string, options?: { scroll?: boolean }) {
64
+ validateNavigationHref(href);
63
65
  const router = getRouterOrNull();
64
66
  if (!router) {
65
67
  if (process.env.NODE_ENV === 'development') {
@@ -72,6 +74,7 @@ export function useRouter(): AppRouterInstance {
72
74
  void router.navigate(href, { scroll: options?.scroll });
73
75
  },
74
76
  replace(href: string, options?: { scroll?: boolean }) {
77
+ validateNavigationHref(href);
75
78
  const router = getRouterOrNull();
76
79
  if (!router) {
77
80
  if (process.env.NODE_ENV === 'development') {
@@ -228,7 +228,6 @@ export async function renderDenyPageAsRsc(
228
228
  });
229
229
 
230
230
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
231
- responseHeaders.set('Vary', 'Accept');
232
231
  return new Response(rscStream, {
233
232
  status: deny.status,
234
233
  headers: responseHeaders,
@@ -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,30 @@ 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
+ const existingVary = result.headers.get('Vary');
342
+ if (!existingVary) {
343
+ result.headers.set('Vary', 'Accept');
344
+ } else {
345
+ const tokens = existingVary
346
+ .toLowerCase()
347
+ .split(',')
348
+ .map((t) => t.trim());
349
+ if (!tokens.includes('accept')) {
350
+ result.headers.set('Vary', `${existingVary}, Accept`);
351
+ }
352
+ }
353
+
337
354
  // Append Server-Timing header based on configured mode.
338
355
  // Header mutability is guaranteed by the producer-side clone
339
356
  // 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
 
@@ -325,10 +326,13 @@ export function permanentRedirect(path: string, options?: Omit<RedirectOptions,
325
326
  }
326
327
 
327
328
  /**
328
- * Redirect to an external URL. The hostname must be in the provided allow-list.
329
+ * Redirect to an external URL. The origin must be in the provided allow-list.
330
+ *
331
+ * Only http: and https: schemes are permitted. The allow-list is matched
332
+ * against full origins (scheme + host + port), not bare hostnames.
329
333
  *
330
334
  * @param url - Absolute URL to redirect to.
331
- * @param allowList - Array of allowed hostnames (e.g. ['example.com', 'auth.example.com']).
335
+ * @param allowList - Array of allowed origins (e.g. ['https://example.com', 'https://auth.example.com']).
332
336
  * @param status - HTTP redirect status code (3xx). Defaults to 302.
333
337
  */
334
338
  export function redirectExternal(url: string, allowList: string[], status: number = 302): never {
@@ -336,21 +340,8 @@ export function redirectExternal(url: string, allowList: string[], status: numbe
336
340
  throw new Error(`redirectExternal() requires a 3xx status code, got ${status}.`);
337
341
  }
338
342
 
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);
343
+ const canonicalUrl = validateExternalRedirectUrl(url, allowList);
344
+ throw new RedirectSignal(canonicalUrl, status);
354
345
  }
355
346
 
356
347
  // ─── RenderError ────────────────────────────────────────────────────────────
@@ -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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared href/URL validation for all navigation sinks.
3
+ *
4
+ * Used by <Link>, useRouter().push/replace, and redirectExternal().
5
+ * Single source of truth for dangerous-scheme detection — prevents
6
+ * control-character smuggling that bypasses naive regex checks.
7
+ *
8
+ * See design/13-security.md §"Link scheme injection" (test #9)
9
+ * and design/07-routing.md §"<Link> Scheme Validation".
10
+ */
11
+
12
+ /**
13
+ * ASCII control characters that browsers strip during URL preprocessing.
14
+ * A naive prefix regex like /^javascript:/i can be bypassed with
15
+ * "java\tscript:alert(1)" because the browser removes the tab before
16
+ * interpreting the scheme. We strip these before checking the scheme.
17
+ */
18
+ const CONTROL_CHARS_RE = /[\t\n\r]/g;
19
+
20
+ const DANGEROUS_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:']);
21
+
22
+ /**
23
+ * Strip C0 control chars and leading whitespace, then extract the scheme.
24
+ * Returns the lowercase scheme (e.g. "javascript:") or null if no scheme.
25
+ */
26
+ function extractScheme(href: string): string | null {
27
+ const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart();
28
+ const match = /^([a-z][a-z0-9+.-]*:)/i.exec(cleaned);
29
+ return match ? match[1].toLowerCase() : null;
30
+ }
31
+
32
+ /**
33
+ * Validate an href for navigation sinks (<Link>, useRouter, etc.).
34
+ *
35
+ * Rejects dangerous schemes (javascript:, data:, vbscript:) even when
36
+ * control characters are embedded to bypass naive regex checks.
37
+ * Also rejects protocol-relative URLs (//evil.com).
38
+ *
39
+ * Throws on rejection. Safe hrefs pass through silently.
40
+ */
41
+ export function validateNavigationHref(href: string): void {
42
+ const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart();
43
+
44
+ // Protocol-relative URLs can redirect to attacker-controlled origins
45
+ if (cleaned.startsWith('//')) {
46
+ throw new Error(
47
+ `Navigation received a protocol-relative URL: "${href}". ` +
48
+ 'Use an absolute URL with an explicit scheme or a relative path.'
49
+ );
50
+ }
51
+
52
+ const scheme = extractScheme(href);
53
+ if (scheme && DANGEROUS_SCHEMES.has(scheme)) {
54
+ throw new Error(
55
+ `Navigation received a dangerous href: "${href}". ` +
56
+ 'javascript:, data:, and vbscript: URLs are not allowed.'
57
+ );
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Check if an href is internal (same-origin navigation) or external.
63
+ *
64
+ * Strips control characters before checking so that "java\tscript:" is
65
+ * correctly identified as having a scheme (and thus not internal).
66
+ */
67
+ export function isInternalHref(href: string): boolean {
68
+ const cleaned = href.replace(CONTROL_CHARS_RE, '').trimStart();
69
+
70
+ if (cleaned.startsWith('//')) return false;
71
+ if (cleaned.startsWith('/') || cleaned.startsWith('#') || cleaned.startsWith('?')) return true;
72
+
73
+ const scheme = extractScheme(href);
74
+ if (scheme) return false;
75
+
76
+ return true;
77
+ }
78
+
79
+ const SAFE_REDIRECT_SCHEMES = new Set(['http:', 'https:']);
80
+
81
+ /**
82
+ * Validate and canonicalize an external redirect URL.
83
+ *
84
+ * Requires http: or https: scheme and checks origin against an allow-list.
85
+ * Returns the parsed canonical URL string (from URL.href) to prevent
86
+ * raw user input from reaching the Location header.
87
+ *
88
+ * @throws On invalid URL, dangerous scheme, or origin not in allow-list.
89
+ */
90
+ export function validateExternalRedirectUrl(url: string, allowList: string[]): string {
91
+ // Strip control chars before parsing to prevent scheme smuggling
92
+ const cleaned = url.replace(CONTROL_CHARS_RE, '').trimStart();
93
+
94
+ if (cleaned.startsWith('//')) {
95
+ throw new Error(
96
+ `redirectExternal() received a protocol-relative URL: "${url}". ` +
97
+ 'Use an absolute URL with http: or https: scheme.'
98
+ );
99
+ }
100
+
101
+ let parsed: URL;
102
+ try {
103
+ parsed = new URL(cleaned);
104
+ } catch {
105
+ throw new Error(`redirectExternal() received an invalid URL: "${url}"`);
106
+ }
107
+
108
+ if (!SAFE_REDIRECT_SCHEMES.has(parsed.protocol)) {
109
+ throw new Error(
110
+ `redirectExternal() received a non-http(s) URL: "${url}". ` +
111
+ 'Only http: and https: schemes are allowed for external redirects.'
112
+ );
113
+ }
114
+
115
+ if (!allowList.includes(parsed.origin)) {
116
+ throw new Error(
117
+ `redirectExternal() target "${parsed.origin}" is not in the allow-list. ` +
118
+ `Allowed: [${allowList.join(', ')}]`
119
+ );
120
+ }
121
+
122
+ return parsed.href;
123
+ }