Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .changeset/gentle-nights-bet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@tanstack/history': patch
'@tanstack/router-core': patch
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
'@tanstack/start-plugin-core': patch
'@tanstack/start-server-core': patch
---

Validate navigation and redirect destinations, keep ambiguous relative URLs on the current origin, and constrain prerender requests and output paths. Prevent redirect headers from appearing in serialized server function response bodies.

Preserve native form HTTP redirects, route error handling and masks for document redirects, and per-navigation destinations for shared loader redirects. Avoid redundant origin parsing and reduce link styling and server-rendering work. Configured origins must already be normalized.

Keep blocked-link inactive props consistent during React hydration, honor explicit redirect Location headers before checking route options, and refresh Vue link state when destinations become internal. Reuse the protocol-relative URL check while parsing redirect schemes once.

Reduce React link bundle size by sharing pathname comparisons, state-prop selection, and element creation.

Share normalized pathname comparisons in Solid and Vue links to reduce bundle size.
5 changes: 4 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ node_modules
/.nx/workspace-data
**/src/routeTree.gen.ts
packages/router-plugin/tests/**/test-files/**
.nx/self-healing
.nx/self-healing

# Preserve the pinned upstream URL fixtures byte for byte.
packages/router-core/tests/fixtures/wpt-url/urltestdata*.json
7 changes: 7 additions & 0 deletions docs/router/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,13 @@ const router = createRouter({
- Defaults to `/`
- The basepath for the entire router. This is useful for mounting a router instance at a subpath.

### `origin` property

- Type: `string`
- Optional
- The origin used to resolve URLs. Defaults to the browser origin, or `http://localhost` on the server and in browsers with an opaque origin.
- Pass a normalized origin, such as `https://example.com` or `http://localhost:3000`, without a path or trailing slash. The router uses this value as provided; if you have a full URL, normalize it with `new URL(url).origin` before passing it to the router.

### `rewrite` property

- Type: `LocationRewrite`
Expand Down
69 changes: 45 additions & 24 deletions packages/history/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface RouterHistory {
notify: (action: SubscriberHistoryAction) => void
_getBlockers: () => Array<NavigationBlocker>
_ignoreSubscribers?: boolean
_ignoreNextBeforeUnload?: () => void
}

export interface HistoryLocation extends ParsedPath {
Expand Down Expand Up @@ -97,6 +98,34 @@ const stateIndexKey = '__TSR_index'
const popStateEvent = 'popstate'
const beforeUnloadEvent = 'beforeunload'

/**
* Turn protocol-relative inputs such as "//evil.example" into paths
* such as "/evil.example", keeping navigation on the current origin.
*
* For HTTP(S) URLs, WHATWG parsing ignores leading C0 controls and spaces,
* removes tabs/newlines, and treats backslashes as slashes, so inputs like
* "/\evil.example" also need normalization. This only allocates when those
* rules would make the input protocol-relative.
*/
// eslint-disable-next-line no-control-regex
const protocolRelativePrefix = /^[\x00-\x20]*(?:[\\/][\t\n\r]*){2,}/

export function normalizeProtocolRelative(url: string): string {
const match = protocolRelativePrefix.exec(url)
return match ? '/' + url.slice(match[0].length) : url
}

function normalizeHref(href: string): string {
// eslint-disable-next-line no-control-regex
if (/[\x00-\x1f\x7f]/.test(href)) {
// eslint-disable-next-line no-control-regex
href = href.replace(/[\x00-\x1f\x7f]/g, (character) =>
'\t\n\r'.includes(character) ? '' : encodeURIComponent(character),
)
}
return normalizeProtocolRelative(href)
}

export function createHistory(opts: {
getLocation: () => HistoryLocation
getLength: () => number
Expand Down Expand Up @@ -297,7 +326,8 @@ export function createBrowserHistory(opts?: {
const _setBlockers = (newBlockers: Array<NavigationBlocker>) =>
(blockers = newBlockers)

const createHref = opts?.createHref ?? ((path) => path)
const createHref = (path: string) =>
normalizeHref(opts?.createHref ? opts.createHref(path) : path)
const parseLocation =
opts?.parseLocation ??
(() =>
Expand Down Expand Up @@ -373,18 +403,24 @@ export function createBrowserHistory(opts?: {
destHref: string,
state: any,
) => {
const href = createHref(destHref)
// A formatter changes the URL space and must receive the original input.
// Otherwise parseHref below already produces the browser destination.
const href = opts?.createHref ? createHref(destHref) : undefined
const hasPendingAction = !!next

if (!hasPendingAction) {
rollbackLocation = currentLocation
}

// Update the location in memory
// Keep the optimistic location in the router's logical URL space.
currentLocation = parseHref(destHref, state)

// Keep track of the next location we need to flush to the URL
next = [href, state, next?.[2 /* is push */] || isPush]
next = [
href ?? currentLocation.href,
state,
next?.[2 /* is push */] || isPush,
]

if (!hasPendingAction) {
// Schedule an update to the browser history
Expand Down Expand Up @@ -535,6 +571,10 @@ export function createBrowserHistory(opts?: {
notifyOnIndexChange: false,
})

history._ignoreNextBeforeUnload = () => {
ignoreNextBeforeUnload = true
}

win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true })
win.addEventListener(popStateEvent, onPushPopEvent)

Expand Down Expand Up @@ -639,30 +679,11 @@ export function createMemoryHistory(
})
}

/**
* Sanitize a path to prevent open redirect vulnerabilities.
* Removes control characters and collapses leading double slashes.
*/
function sanitizePath(path: string): string {
// Remove ASCII control characters (0x00-0x1F) and DEL (0x7F)
// These include CR (\r = 0x0D), LF (\n = 0x0A), and other potentially dangerous characters
// eslint-disable-next-line no-control-regex
let sanitized = path.replace(/[\x00-\x1f\x7f]/g, '')

// Prevent open redirect via protocol-relative URLs (e.g. "//evil.com")
// Collapse leading double slashes to a single slash
if (sanitized.startsWith('//')) {
sanitized = '/' + sanitized.replace(/^\/+/, '')
}

return sanitized
}

export function parseHref(
href: string,
state: ParsedHistoryState | undefined,
): HistoryLocation {
const sanitizedHref = sanitizePath(href)
const sanitizedHref = normalizeHref(href)
const hashIndex = sanitizedHref.indexOf('#')
const searchIndex = sanitizedHref.indexOf('?')

Expand Down
58 changes: 57 additions & 1 deletion packages/history/tests/createBrowserHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('createBrowserHistory', () => {
history.destroy()
})

test.each(['/a\tb?q=a\nb#/\\section\r'])(
test.each(['/a\x00b?q=a\x01b#/\\section\x7f', '/a\tb?q=a\nb#/\\section\r'])(
'preserves the browser interpretation of %j',
(href) => {
const originalHref = window.location.href
Expand Down Expand Up @@ -150,6 +150,36 @@ describe('createBrowserHistory', () => {
history.destroy()
})

test.each([
'//evil.com/path',
'///evil.com/path',
'/\\evil.com/path',
'/\\\\evil.com/path',
'/\\/evil.com/path',
'\\/evil.com/path',
'\\\\evil.com/path',
' /\\evil.com/path',
'\x01//evil.com/path',
'/\t/evil.com/path',
])('sanitizes %j before calling the native History API', async (href) => {
const { history, pushState } = createBrowserHistoryHarness()

history.push(href)
await Promise.resolve()

expect(pushState).toHaveBeenCalledOnce()
const pushedHref = pushState.mock.calls[0]![2]
const serializedUrl = new URL(pushedHref, 'https://victim.example')
expect(serializedUrl.origin).toBe('https://victim.example')
expect(serializedUrl.pathname).toBe(
href === '\x01//evil.com/path' ? '/%01//evil.com/path' : '/evil.com/path',
)
expect(
new URL(history.location.href, 'https://victim.example').origin,
).toBe('https://victim.example')
history.destroy()
})

test('does not exempt a normal traversal from beforeunload blockers', () => {
const { history, window } = createBrowserHistoryHarness()
history.block({ blockerFn: vi.fn(), enableBeforeUnload: true })
Expand Down Expand Up @@ -208,6 +238,32 @@ describe('createBrowserHistory', () => {
history.destroy()
})

test('normalizes the final result of a custom createHref', async () => {
const { history, pushState, replaceState } = createBrowserHistoryHarness(
() => ' \t/\\evil.example/path',
)

expect(history.createHref('/safe')).toBe('/evil.example/path')

history.push('/safe')
await Promise.resolve()
history.replace('/safe')
await Promise.resolve()

expect(pushState).toHaveBeenCalledWith(
expect.anything(),
'',
'/evil.example/path',
)
expect(replaceState).toHaveBeenCalledWith(
expect.anything(),
'',
'/evil.example/path',
)
expect(history.location.href).toBe('/safe')
history.destroy()
})

test('keeps the optimistic location in the logical URL space', async () => {
const { history, pushState } = createBrowserHistoryHarness(
() => '/mapped/../browser-destination',
Expand Down
36 changes: 35 additions & 1 deletion packages/history/tests/createHashHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest'
import { createHashHistory } from '../src'

describe('createHashHistory', () => {
test.each(['/route\x01'])(
test.each(['\x00/route', '/route\x01', '/route?q=a\x1fb#c\x7fd'])(
'keeps logical control data stable after reading the browser URL again: %j',
(href) => {
const originalHref = window.location.href
Expand All @@ -25,6 +25,40 @@ describe('createHashHistory', () => {
},
)

test('normalizes the final browser href without changing the logical hash location', async () => {
const pushState = vi.fn()
const window = {
location: {
pathname: '//nested/path',
search: '',
hash: '',
},
history: {
state: { __TSR_index: 0, __TSR_key: 'initial' },
length: 1,
pushState,
replaceState: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
go: vi.fn(),
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}
const history = createHashHistory({ window })

history.push('/logical')
await Promise.resolve()

expect(pushState).toHaveBeenCalledWith(
expect.anything(),
'',
'/nested/path#/logical',
)
expect(history.location.href).toBe('/logical')
history.destroy()
})

describe('parseLocation', () => {
describe.each([
['/', { pathname: '/', search: '' }, 'neither search params nor hash'],
Expand Down
52 changes: 50 additions & 2 deletions packages/history/tests/parseHref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { parseHref } from '../src'
const baseUrl = new URL('https://victim.example/base')

describe('parseHref', () => {
test.each(['\t', '\n', '\r'])(
test.each(['\x00', '\x01', '\x1f', '\x7f', '\t', '\n', '\r'])(
'preserves browser path, query, and fragment interpretation for %j',
(character) => {
const href = `/a${character}b?q=a${character}b#a${character}b`
Expand Down Expand Up @@ -60,16 +60,24 @@ describe('parseHref', () => {
test.each([
'//evil.com/path',
'///evil.com/path',
'/\\evil.com/path',
'/\\\\evil.com/path',
'/\\/evil.com/path',
'\\/evil.com/path',
'\\\\evil.com/path',
' /\\evil.com/path',
'\x01//evil.com/path',
'/\t/evil.com/path',
])('keeps authority-like path %j on the current origin', (href) => {
])('keeps protocol-relative input %j on the current origin', (href) => {
const parsed = parseHref(href, undefined)
const url = new URL(parsed.href, 'https://victim.example')

expect(url.origin).toBe('https://victim.example')
})

test.each([
'h\x00ttps://evil.example',
'java\x00script:alert(1)',
'/\x00/evil.example',
'\x7f//evil.example',
'/%5c/evil.example',
Expand All @@ -89,6 +97,46 @@ describe('parseHref', () => {
expect(new URL(parsed.href, baseUrl).origin).toBe(baseUrl.origin)
})

test('matches WHATWG parsing of protocol-relative prefixes', () => {
const alphabet = [
'/',
'\\',
'\t',
'\n',
'\r',
'\x00',
'\x0b',
' ',
'\x7f',
]

for (let length = 0; length <= 4; length++) {
const count = alphabet.length ** length
for (let value = 0; value < count; value++) {
let input = ''
let cursor = value
for (let index = 0; index < length; index++) {
input += alphabet[cursor % alphabet.length]
cursor = Math.floor(cursor / alphabet.length)
}
input += 'evil.example/path'

try {
new URL(input, baseUrl)
} catch {
continue
}

const parsed = parseHref(input, undefined)

expect(
new URL(parsed.href, baseUrl).origin,
JSON.stringify(input),
).toBe(baseUrl.origin)
}
}
})

test('normal paths remain unchanged', () => {
const parsed = parseHref('/users/profile?id=1#section', undefined)
expect(parsed.href).toBe('/users/profile?id=1#section')
Expand Down
Loading
Loading