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