From b1fcee2a0df608d3d162d85209e5788c3ebde06e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Fri, 21 Aug 2026 23:45:37 +0200 Subject: [PATCH] fix(router-core): harden string encoding/decoding boundaries Centralize all URL-path encoding/decoding primitives in a single string-encoding module with trust-boundary documentation, branded string kinds, and ESLint enforcement banning raw encodeURIComponent/ decodeURI*/btoa/atob elsewhere. Fixes a real crash: malformed percent-encoding (e.g. /post/%E4%BD) threw URIError out of extractParams through findFlatMatch (route masks) and findSingleMatch (router.matchRoute), which had no guard. findMatch is now the single choke point converting URIError into a null match (404), byte-neutral vs the previous per-caller guard. Adds fast-check property-based and concrete security tests for every encoding surface identified in docs/string-handling.md: - matcher totality, path param round-trips, open-redirect defense - search-param parse safety, null prototypes, JSON coercion contract - SSR inline-script XSS resistance (adversarial scroll-restoration keys) and seroval factory interpolation canary - frame protocol round-trips and client frame-decoder DoS limits - server-fn payload handling (size cap, method check, context merge, malformed input) - virtual-module base64url ID round-trips and hostile payload fallbacks - early-hints Link-header injection resistance and prerender SSRF Documents known quirks (legacy '*' splat collision, search-param JSON coercion, early-hints href interpolation) instead of silently changing behavior. --- .changeset/brave-strings-harden.md | 8 + packages/router-core/docs/string-handling.md | 97 +++++++ packages/router-core/eslint.config.js | 86 ++++++ packages/router-core/package.json | 11 +- packages/router-core/src/index.ts | 3 +- .../router-core/src/new-process-route-tree.ts | 51 ++-- packages/router-core/src/path.ts | 51 ++-- packages/router-core/src/router.ts | 18 +- .../src/scroll-restoration-script/server.ts | 2 +- packages/router-core/src/ssr/ssr-server.ts | 2 +- packages/router-core/src/string-encoding.ts | 183 ++++++++++++ packages/router-core/src/utils.ts | 148 ---------- .../tests/malformed-percent.test.ts | 136 +++++++++ packages/router-core/tests/path.test.ts | 2 +- .../tests/search-params.property.test.ts | 86 ++++++ .../router-core/tests/ssr-injection.test.ts | 128 +++++++++ .../tests/string-encoding.bench.ts | 84 ++++++ .../tests/string-encoding.property.test.ts | 250 ++++++++++++++++ packages/router-core/tests/utils.test.ts | 6 +- packages/start-client-core/package.json | 11 +- .../src/tests/frame-decoder.test.ts | 144 ++++++++++ packages/start-plugin-core/package.json | 11 +- .../virtualModules-roundtrip.test.ts | 159 +++++++++++ .../tests/prerender-ssrf.test.ts | 37 +++ packages/start-server-core/package.json | 13 +- .../tests/early-hints-hardening.test.ts | 86 ++++++ .../tests/server-functions-handler.test.ts | 266 ++++++++++++++++++ pnpm-lock.yaml | 64 +++-- 28 files changed, 1875 insertions(+), 268 deletions(-) create mode 100644 .changeset/brave-strings-harden.md create mode 100644 packages/router-core/docs/string-handling.md create mode 100644 packages/router-core/src/string-encoding.ts create mode 100644 packages/router-core/tests/malformed-percent.test.ts create mode 100644 packages/router-core/tests/search-params.property.test.ts create mode 100644 packages/router-core/tests/ssr-injection.test.ts create mode 100644 packages/router-core/tests/string-encoding.bench.ts create mode 100644 packages/router-core/tests/string-encoding.property.test.ts create mode 100644 packages/start-client-core/src/tests/frame-decoder.test.ts create mode 100644 packages/start-plugin-core/tests/importProtection/virtualModules-roundtrip.test.ts create mode 100644 packages/start-server-core/tests/early-hints-hardening.test.ts create mode 100644 packages/start-server-core/tests/server-functions-handler.test.ts diff --git a/.changeset/brave-strings-harden.md b/.changeset/brave-strings-harden.md new file mode 100644 index 00000000000..1b5be7c484d --- /dev/null +++ b/.changeset/brave-strings-harden.md @@ -0,0 +1,8 @@ +--- +'@tanstack/router-core': patch +'@tanstack/start-client-core': patch +'@tanstack/start-plugin-core': patch +'@tanstack/start-server-core': patch +--- + +harden string encoding/decoding: centralize URL-path primitives, fix URIError escape via route masks and matchRoute, add property-based and security tests across SSR scripts, frame protocol, server-fn payloads, virtual-module IDs and early hints diff --git a/packages/router-core/docs/string-handling.md b/packages/router-core/docs/string-handling.md new file mode 100644 index 00000000000..29b5b19eaf3 --- /dev/null +++ b/packages/router-core/docs/string-handling.md @@ -0,0 +1,97 @@ +# String handling in TanStack Router + +All URL-path string encoding/decoding primitives live in +`router-core/src/string-encoding.ts`. ESLint (`no-restricted-globals` in that +package's eslint config) bans direct use of `encodeURIComponent`, +`decodeURIComponent`, `encodeURI`, `decodeURI`, `btoa` and `atob` anywhere else +in router-core `src/` (with two documented exceptions). + +## Scope map: every encoding surface and where its guarantees are tested + +| # | Surface | Package / file | Tests | +| --- | ----------------------------------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| 1 | URL path encode-on-write | `router-core/src/string-encoding.ts` (`encodePathParam`, `compileDecodeCharMap`) | `string-encoding.property.test.ts`, `path.test.ts` | +| 1 | href encoding | `encodePathLikeUrl`, `buildDevStylesUrl` | `utils.test.ts`, property tests | +| 2 | URL path decode-on-read (attacker input) | `decodePath`/`decodeSegment`; matcher `decodeParam` + `findMatch` choke point | `malformed-percent.test.ts`, `string-encoding.property.test.ts`, `utils.test.ts` | +| 2 | prerender page validation (SSRF) | `start-plugin-core/src/prerender.ts` | `prerender-ssrf.test.ts` | +| 3 | search params | `router-core/src/qss.ts`, `searchParams.ts` | `search-params.property.test.ts` | +| 4 | SSR inline scripts (XSS) | `escapeHtml` + scroll-restoration script; seroval stream factories | `ssr-injection.test.ts`, `string-encoding.property.test.ts` | +| 5 | binary ↔ string (SSR streams, RPC frames) | `RawStream.ts`, `frame-protocol.ts`, client `frame-decoder.ts` | `frame-protocol.test.ts`, `frame-decoder.test.ts` | +| 6 | server-fn payload deserialization | `start-server-core/server-functions-handler.ts` | `server-functions-handler.test.ts` | +| 7 | build-time base64url module IDs | `start-plugin-core/import-protection/virtualModules.ts` (+rsbuild twin) | `virtualModules-roundtrip.test.ts` | +| 8 | persistence & headers | scroll-restoration JSON guards; early-hints Link headers | `scroll-restoration*.test.ts`, `early-hints-hardening.test.ts` | + +## Trust boundaries and guarantees + +| Function | Input trust | Guarantee | +| ------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `decodePath` | attacker-controlled (URL) | total: never throws; strips control chars, `"`, `<`, `>`, backtick, braces; collapses protocol-relative prefixes | +| `encodePathParam` | app data (params) | output safe for embedding in a single path segment | +| `escapeHtml` | app data (may contain user content) | output cannot break out of `', + '"}; alert(1); {"', + "'+alert(1)+'", + "\\'; alert(1); \\'", + 'key\u2028with\u2029separators', + '${alert(1)}', + '', + ] + + test.each(maliciousKeys)( + 'script for key %j contains no context-breaking sequences', + (key) => { + const router = createScrollRestorationRouter(() => key) + const script = getScrollRestorationScriptForRouter(router)! + // nothing may close/reopen the surrounding