Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/brave-strings-harden.md
Original file line number Diff line number Diff line change
@@ -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
97 changes: 97 additions & 0 deletions packages/router-core/docs/string-handling.md
Original file line number Diff line number Diff line change
@@ -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 `<script>` text context |
| `encodePathLikeUrl` | internally-decoded paths | output contains no whitespace or non-ASCII characters; not fully encoded (ASCII specials pass through), hence unbranded |
| `buildDevStylesUrl` | dev-only, internal route IDs | n/a |
| frame decoder | network bytes | size/count caps (16MiB/frame, 32MiB buffer, 1024 streams, 100k frames); unknown types and convention violations rejected |

## Malformed percent-encoding policy

A URL segment with malformed percent-encoding (e.g. `/post/%E4%BD`, `/post/%zz`,
`/post/%`) must never crash the router.

- `decodeParam` (string-encoding.ts) throws `URIError` on malformed input.
- `findMatch` (new-process-route-tree.ts) is the **single choke point** that
converts that `URIError` into "no match". Every public matching entry point
(`findRouteMatch`, `findFlatMatch`, `findSingleMatch`) funnels through it.
- Totality of all entry points is enforced by
`tests/string-encoding.property.test.ts`; concrete regressions live in
`tests/malformed-percent.test.ts`.
- The ESLint exception that permits `decodeParam` to exist is
`tanstack/router-core/matching-decode-contract` in eslint.config.js.

## Branded string kinds

Percent-encoding is not idempotent: confusing an encoded string for a decoded one
produces double-encoded URLs, and vice versa. The brands make kinds distinct at
type level; they are erased at runtime (zero bundle cost) and extend `string`, so
consuming plain-string APIs stays free:

| Type | Produced by | Meaning |
| ------------------ | ----------------- | ----------------------------------------- |
| `EncodedPathParam` | `encodePathParam` | percent-encoded single path-segment value |
| `DecodedPathParam` | `decodeParam` | decoded param value extracted from a URL |
| `EncodedPath` | `interpolatePath` | full path with all params encoded |
| `DecodedPath` | `decodePath` | decoded pathname (`location.pathname`) |

`compileDecodeCharMap`'s decoder receives `EncodedPathParam`: custom decoders are
handed values produced by `encodeURIComponent` and may contain `%XX` sequences.

## Known issues / quirks (documented, deliberately not "fixed")

### `*` splat value collides with legacy wildcard syntax

A splat param value of `*` interpolates to `/files/*` — which is itself the legacy
wildcard syntax. Matching such a URL resolves to the legacy route node with empty
params instead of the `$` splat route carrying `_splat: '*'`. Reproduced in
`tests/malformed-percent.test.ts`. Fixing it requires encoding `*` as `%2A` in
splat interpolation _and_ preserving `%2A` through `decodePath` (alongside `%25`
/`%5C`); the latter changes static-segment matching for paths containing literal
encoded asterisks, so it needs dedicated e2e coverage before attempting.

### Search-param JSON coercion

The default search parser JSON-parses any value that could start valid JSON, so a
plain string `' 42'`, `'true'` or `'[1]'` changes type across a read. Long-standing
designed behavior; pinned in `search-params.property.test.ts`. Apps needing exact
strings must use custom search serialization.

### Early-hints `href` interpolation

`serializeEarlyHint` interpolates `href` verbatim between `<...>`. Attribute
values are injection-proof (token allowlist or quoted-string), but href comes
from the asset manifest — if user data ever reaches it, escaping must be added.
Pinned in `early-hints-hardening.test.ts`.

### Server-fn wire format

Server-function payloads are seroval JSON, not plain JSON. Plain JSON in a POST
body yields a 500 with the seroval error serialized — intentional (matches how
errors are surfaced elsewhere); covered in `server-functions-handler.test.ts`.
86 changes: 86 additions & 0 deletions packages/router-core/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,90 @@ export default [
'@typescript-eslint/no-unnecessary-condition': 'off',
},
},
{
// All URL-path string encoding/decoding must go through src/string-encoding.ts
// so its guarantees (totality, sanitization, XSS safety) are enforced in one
// reviewed place. See the trust-boundary documentation in that module.
name: 'tanstack/router-core/string-encoding-boundary',
files: ['src/**/*.ts'],
ignores: ['src/string-encoding.ts'],
rules: {
'no-restricted-globals': [
'error',
{
name: 'encodeURIComponent',
message: 'Use encodePathParam from ./string-encoding instead.',
},
{
name: 'decodeURIComponent',
message: 'Decoding must go through ./string-encoding helpers instead.',
},
{
name: 'encodeURI',
message: 'Use helpers from ./string-encoding instead.',
},
{
name: 'decodeURI',
message:
'Decoding must go through decodePath/decodeSegment in ./string-encoding.',
},
{
name: 'btoa',
message:
'Binary encoding belongs in ssr/serializer; ask before adding new uses.',
},
{
name: 'atob',
message:
'Binary decoding belongs in ssr/serializer; ask before adding new uses.',
},
],
},
},
{
// Exception to the rule above: in the matcher, throwing URIError from
// decodeParam is the documented "malformed percent-encoding = no match"
// contract. findMatch is the single choke point that converts it into a
// null match; every public matching entry point funnels through it and
// tests/string-encoding.property.test.ts asserts their totality.
name: 'tanstack/router-core/matching-decode-contract',
files: ['src/new-process-route-tree.ts'],
rules: {
'no-restricted-globals': [
'error',
{
name: 'encodeURIComponent',
message: 'Use encodePathParam from ./string-encoding instead.',
},
{
name: 'encodeURI',
message: 'Use helpers from ./string-encoding instead.',
},
{
name: 'decodeURI',
message:
'Decoding must go through decodePath/decodeSegment in ./string-encoding.',
},
{
name: 'btoa',
message:
'Binary encoding belongs in ssr/serializer; ask before adding new uses.',
},
{
name: 'atob',
message:
'Binary decoding belongs in ssr/serializer; ask before adding new uses.',
},
],
},
},
{
// Base64 lives canonically in ssr/serializer (SSR stream serialization);
// URL-path primitives live in string-encoding.ts.
name: 'tanstack/router-core/binary-encoding-boundary',
files: ['src/ssr/serializer/**/*.ts', 'src/string-encoding.ts'],
rules: {
'no-restricted-globals': 'off',
},
},
]
11 changes: 6 additions & 5 deletions packages/router-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,15 +188,16 @@
"seroval-plugins": "^1.6.2"
},
"devDependencies": {
"@arethetypeswrong/cli": "catalog:",
"@tanstack/store": "^0.9.3",
"@tanstack/vite-config": "catalog:",
"@types/node": "25.0.9",
"esbuild": "^0.27.4",
"vite": "catalog:",
"@tanstack/vite-config": "catalog:",
"vitest": "catalog:",
"fast-check": "^4.9.0",
"jsdom": "catalog:",
"@arethetypeswrong/cli": "catalog:",
"publint": "catalog:",
"rimraf": "catalog:"
"rimraf": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
}
}
3 changes: 1 addition & 2 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,9 @@ export {
createControlledPromise,
isModuleNotFoundError,
DEFAULT_PROTOCOL_ALLOWLIST,
escapeHtml,
isDangerousProtocol,
buildDevStylesUrl,
} from './utils'
export { buildDevStylesUrl, escapeHtml } from './string-encoding'
export type {
NoInfer,
IsAny,
Expand Down
51 changes: 25 additions & 26 deletions packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { invariant } from './invariant'
import { createLRUCache } from './lru-cache'
import { decodeParam } from './string-encoding'
import { last } from './utils'
import type { LRUCache } from './lru-cache'

Expand Down Expand Up @@ -633,21 +634,11 @@ export function findRouteMatch<
const cached = processedTree.matchCache.get(key)
if (cached !== undefined) return cached
path ||= '/'
let result: RouteMatch<T> | null

try {
result = findMatch(
path,
processedTree.segmentTree,
fuzzy,
) as RouteMatch<T> | null
} catch (err) {
if (err instanceof URIError) {
result = null
} else {
throw err
}
}
const result = findMatch(
path,
processedTree.segmentTree,
fuzzy,
) as RouteMatch<T> | null

if (result) result.branch = buildRouteBranch(result.route)
processedTree.matchCache.set(key, result)
Expand Down Expand Up @@ -753,12 +744,20 @@ function findMatch<T extends RouteLike>(
rawParams: Record<string, string>
} | null {
const parts = path.split('/')
const leaf = getNodeMatch(path, parts, segmentTree, fuzzy)
if (!leaf) return null
const [rawParams] = extractParams(path, parts, leaf)
return {
route: leaf.node.route!,
rawParams,
// extractParams throws URIError when a param value contains malformed
// percent-encoding. This is the single choke point that converts that into
// "no match" — all public matching entry points funnel through here.
try {
const leaf = getNodeMatch(path, parts, segmentTree, fuzzy)
if (!leaf) return null
const [rawParams] = extractParams(path, parts, leaf)
return {
route: leaf.node.route!,
rawParams,
}
} catch (err) {
if (err instanceof URIError) return null
throw err
}
}

Expand Down Expand Up @@ -829,10 +828,10 @@ function extractParams<T extends RouteLike>(
nodePart.length - sufLength - 1,
)
const value = part!.substring(preLength, part!.length - sufLength)
rawParams[name] = decodeURIComponent(value)
rawParams[name] = decodeParam(value)
} else {
const name = nodePart.substring(1)
rawParams[name] = decodeURIComponent(part!)
rawParams[name] = decodeParam(part!)
}
} else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) {
if (leaf.skipped & (1 << nodeIndex)) {
Expand All @@ -852,14 +851,14 @@ function extractParams<T extends RouteLike>(
node.suffix || node.prefix
? part!.substring(preLength, part!.length - sufLength)
: part
if (value) rawParams[name] = decodeURIComponent(value)
if (value) rawParams[name] = decodeParam(value)
} else if (node.kind === SEGMENT_TYPE_WILDCARD) {
const n = node
const value = path.substring(
currentPathIndex + (n.prefix?.length ?? 0),
path.length - (n.suffix?.length ?? 0),
)
const splat = decodeURIComponent(value)
const splat = decodeParam(value)
// TODO: Deprecate *
rawParams['*'] = splat
rawParams._splat = splat
Expand Down Expand Up @@ -1210,7 +1209,7 @@ function getNodeMatch<T extends RouteLike>(
}
const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex)
bestFuzzy.rawParams ??= Object.create(null)
bestFuzzy.rawParams!['**'] = decodeURIComponent(splat)
bestFuzzy.rawParams!['**'] = decodeParam(splat)
return bestFuzzy
}

Expand Down
Loading
Loading