Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/opaque-ssr-route-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Encode dehydrated SSR route identifiers with an opaque URL-safe token so crawlers cannot interpret internal route IDs, manifest route keys, or not-found route IDs as paths.
13 changes: 13 additions & 0 deletions packages/router-core/src/ssr/ssr-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ function hydrateMatch(
match.ssr = deyhydratedMatch.ssr
match.updatedAt = deyhydratedMatch.u
match.error = deyhydratedMatch.e
if (isNotFound(match.error) && typeof match.error.routeId === 'string') {
match.error.routeId = hydrateSsrMatchId(match.error.routeId) as any
}
// Only hydrate global-not-found when a defined value is present in the
// dehydrated payload. If omitted, preserve the value computed from the
// current client location (important for SPA fallback HTML served at unknown
Expand Down Expand Up @@ -81,6 +84,16 @@ export async function hydrate(router: AnyRouter): Promise<any> {
dehydratedRouter.lastMatchId,
)
}
if (dehydratedRouter.manifest) {
dehydratedRouter.manifest.routes = Object.fromEntries(
Object.entries(dehydratedRouter.manifest.routes).map(
([routeId, routeManifest]) => [
hydrateSsrMatchId(routeId),
routeManifest,
],
),
)
}
const { manifest, dehydratedData, lastMatchId } = dehydratedRouter

router.ssr = {
Expand Down
32 changes: 31 additions & 1 deletion packages/router-core/src/ssr/ssr-match-id.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,37 @@
const encodedSsrMatchIdPrefix = '__TSR__'

export function dehydrateSsrMatchId(id: string): string {
return id.replaceAll('/', '\0')
if (
!id.includes('/') &&
!id.includes('\0') &&
!id.includes('\uFFFD') &&
!id.startsWith(encodedSsrMatchIdPrefix)
) {
return id
}

return `${encodedSsrMatchIdPrefix}${btoa(encodeURIComponent(id))
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '')}`
}

export function hydrateSsrMatchId(id: string): string {
if (id.startsWith(encodedSsrMatchIdPrefix)) {
try {
const base64 = id
.slice(encodedSsrMatchIdPrefix.length)
.replaceAll('-', '+')
.replaceAll('_', '/')
const paddedBase64 = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
'=',
)
return decodeURIComponent(atob(paddedBase64))
} catch {
return id
}
}

return id.replaceAll('\0', '/').replaceAll('\uFFFD', '/')
}
30 changes: 26 additions & 4 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getStylesheetHref,
isInlinableStylesheet,
} from '../manifest'
import { isNotFound } from '../not-found'
import { decodePath } from '../utils'
import { createLRUCache } from '../lru-cache'
import { rootRouteId } from '../root'
Expand Down Expand Up @@ -58,7 +59,18 @@ export function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch {

for (const [key, shorthand] of properties) {
if (match[key] !== undefined) {
dehydratedMatch[shorthand] = match[key]
let value = match[key]
if (
key === 'error' &&
isNotFound(value) &&
typeof value.routeId === 'string'
) {
value = {
...value,
routeId: dehydrateSsrMatchId(value.routeId),
}
}
dehydratedMatch[shorthand] = value
}
}
if (match.globalNotFound) {
Expand Down Expand Up @@ -279,6 +291,15 @@ function stripInlinedStylesheetAssets(
return nextRoutes
}

function dehydrateManifestRouteKeys(routes: FilteredRoutes): FilteredRoutes {
return Object.fromEntries(
Object.entries(routes).map(([routeId, routeManifest]) => [
dehydrateSsrMatchId(routeId),
routeManifest,
]),
)
}

export function attachRouterServerSsrUtils({
router,
manifest,
Expand Down Expand Up @@ -399,13 +420,14 @@ export function attachRouterServerSsrUtils({
}

manifestToDehydrate = {
routes: { ...filteredRoutes },
routes: dehydrateManifestRouteKeys(filteredRoutes),
}

// Merge request-scoped assets into root route (without mutating cached manifest)
if (opts?.requestAssets?.length) {
const existingRoot = manifestToDehydrate.routes[rootRouteId]
manifestToDehydrate.routes[rootRouteId] = {
const dehydratedRootRouteId = dehydrateSsrMatchId(rootRouteId)
const existingRoot = manifestToDehydrate.routes[dehydratedRootRouteId]
manifestToDehydrate.routes[dehydratedRootRouteId] = {
...existingRoot,
assets: [...opts.requestAssets, ...(existingRoot?.assets ?? [])],
}
Expand Down
66 changes: 64 additions & 2 deletions packages/router-core/tests/hydrate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,13 @@ describe('hydrate', () => {
})

it('should set manifest in router.ssr', async () => {
const testManifest = { routes: {} }
const testManifest = {
routes: {
[dehydrateSsrMatchId('/_layout/posts/$postId')]: {
preloads: ['/assets/post.js'],
},
},
}
mockWindow.$_TSR = {
router: {
manifest: testManifest,
Expand All @@ -160,7 +166,13 @@ describe('hydrate', () => {
await hydrate(mockRouter)

expect(mockRouter.ssr).toEqual({
manifest: testManifest,
manifest: {
routes: {
'/_layout/posts/$postId': {
preloads: ['/assets/post.js'],
},
},
},
})
})

Expand Down Expand Up @@ -395,6 +407,56 @@ describe('hydrate', () => {
expect((mockRouter.state.matches[0] as AnyRouteMatch).id).toBe('/')
})

it('should decode notFound error route ids during hydration', async () => {
const rawRouteId = '/other'
const mockMatches = [
{
id: rawRouteId,
routeId: rawRouteId,
index: 0,
ssr: undefined,
_nonReactive: {},
},
]

mockRouter.matchRoutes = vi.fn().mockReturnValue(mockMatches)
mockRouter.state.matches = mockMatches

mockWindow.$_TSR = {
router: {
manifest: { routes: {} },
dehydratedData: {},
lastMatchId: dehydrateSsrMatchId(rawRouteId),
matches: [
{
i: dehydrateSsrMatchId(rawRouteId),
e: {
isNotFound: true,
routeId: dehydrateSsrMatchId(rawRouteId),
},
s: 'notFound',
ssr: true,
u: Date.now(),
},
],
},
h: vi.fn(),
e: vi.fn(),
c: vi.fn(),
p: vi.fn(),
buffer: [],
initialized: false,
}

await hydrate(mockRouter)

const match = mockRouter.state.matches[0] as AnyRouteMatch
expect(match.error).toEqual({
isNotFound: true,
routeId: rawRouteId,
})
})

it('should handle errors during route context hydration', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockHead.mockImplementation(() => {
Expand Down
22 changes: 21 additions & 1 deletion packages/router-core/tests/ssr-match-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { describe, expect, it } from 'vitest'
import { dehydrateSsrMatchId, hydrateSsrMatchId } from '../src/ssr/ssr-match-id'

describe('ssr match id codec', () => {
it('removes forward slashes in dehydrated ids', () => {
it('removes crawler-normalizable path separators in dehydrated ids', () => {
const dehydratedId = dehydrateSsrMatchId(
'/$orgId/projects/$projectId//acme/projects/dashboard/{}',
)
const crawlerNormalized = dehydratedId
.replaceAll('\0', '/')
.replaceAll('\uFFFD', '/')

expect(dehydratedId).not.toContain('/')
expect(dehydratedId).not.toContain('\0')
expect(crawlerNormalized).not.toContain('/$orgId')
expect(crawlerNormalized).not.toContain('$projectId')
expect(hydrateSsrMatchId(dehydratedId)).toBe(
'/$orgId/projects/$projectId//acme/projects/dashboard/{}',
)
Expand All @@ -23,4 +29,18 @@ describe('ssr match id codec', () => {
it('decodes browser-normalized replacement chars back to slashes', () => {
expect(hydrateSsrMatchId('\uFFFDposts\uFFFD1')).toBe('/posts/1')
})

it('round trips ids that start with the encoded prefix', () => {
const id = '__TSR__route-id'
const dehydratedId = dehydrateSsrMatchId(id)

expect(dehydratedId).not.toBe(id)
expect(hydrateSsrMatchId(dehydratedId)).toBe(id)
})

it('leaves invalid encoded-prefix ids unchanged for backwards compatibility', () => {
expect(hydrateSsrMatchId('__TSR__not-valid-base64%')).toBe(
'__TSR__not-valid-base64%',
)
})
})
37 changes: 37 additions & 0 deletions packages/router-core/tests/ssr-route-id-dehydration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest'
import { notFound } from '../src'
import { dehydrateMatch } from '../src/ssr/ssr-server'
import { hydrateSsrMatchId } from '../src/ssr/ssr-match-id'
import type { AnyRouteMatch } from '../src'

function normalizeCrawlerCandidate(id: string) {
return id.replaceAll('\0', '/').replaceAll('\uFFFD', '/')
}

describe('SSR route id dehydration', () => {
test('hides internal match ids and notFound route ids from slash-normalizing crawlers', () => {
const routeId = '/_layout/posts/$postId'
const matchId = `${routeId}/posts/1`
const dehydratedMatch = dehydrateMatch({
id: matchId,
routeId,
updatedAt: 1,
status: 'notFound',
error: notFound({ routeId }),
ssr: true,
_nonReactive: {},
} as AnyRouteMatch)

const normalizedMatchId = normalizeCrawlerCandidate(dehydratedMatch.i)
const normalizedErrorRouteId = normalizeCrawlerCandidate(
(dehydratedMatch.e as any).routeId,
)

expect(normalizedMatchId).not.toContain('/_layout')
expect(normalizedMatchId).not.toContain('$postId')
expect(normalizedErrorRouteId).not.toContain('/_layout')
expect(normalizedErrorRouteId).not.toContain('$postId')
expect(hydrateSsrMatchId(dehydratedMatch.i)).toBe(matchId)
expect(hydrateSsrMatchId((dehydratedMatch.e as any).routeId)).toBe(routeId)
})
})
Loading