Skip to content
Merged
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
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
37 changes: 35 additions & 2 deletions e2e/react-router/basic-file-based/src/routes/history-blocking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ function HistoryBlocking() {
const { step } = Route.useSearch()
const [draft, setDraft] = React.useState('')
const [ignoreBlocker, setIgnoreBlocker] = React.useState(false)
const { status, reset } = useBlocker({
const [documentHref, setDocumentHref] = React.useState('/')
const [navigationError, setNavigationError] = React.useState('')
const { status, reset, proceed } = useBlocker({
shouldBlockFn: () => draft.length > 0,
enableBeforeUnload: draft.length > 0,
withResolver: true,
Expand All @@ -45,7 +47,38 @@ function HistoryBlocking() {
</label>
<p>{draft ? 'Unsaved changes' : 'No changes'}</p>
<p>Blocker status: {status}</p>
{status === 'blocked' && <button onClick={reset}>Stay here</button>}
{status === 'blocked' && (
<>
<button onClick={reset}>Stay here</button>
<button onClick={proceed}>Continue navigation</button>
</>
)}
<label>
Document destination
<input
value={documentHref}
onChange={(event) => setDocumentHref(event.target.value)}
/>
</label>
{[false, true].map((replace) => (
<button
key={String(replace)}
onClick={() => {
setNavigationError('')
void router
.navigate({
href: documentHref,
reloadDocument: true,
replace,
ignoreBlocker,
})
.catch((error) => setNavigationError(String(error)))
}}
>
{replace ? 'Replace document' : 'Navigate document'}
</button>
))}
{navigationError && <p role="status">{navigationError}</p>}
<Link to="/history-blocking" search={{ step: step + 1 }}>
Add history entry
</Link>
Expand Down
67 changes: 67 additions & 0 deletions e2e/react-router/basic-file-based/tests/history-blocking.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,70 @@ for (const ignoreBlocker of [false, true]) {
}
})
}

for (const action of ['Navigate document', 'Replace document']) {
test(`${action}: invalid URLs preserve the next unload warning`, async ({
page,
}) => {
await page.goto('/history-blocking')
await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft')
await page.getByLabel('Ignore blockers').check()
await page.getByLabel('Document destination').fill('https://[')
const dialogs = dismissUnloadDialogs(page)

await page.getByRole('button', { name: action, exact: true }).click()

await expect(page.getByRole('status')).toBeVisible()
expect(dialogs).toEqual([])
await page.getByRole('link', { name: 'Leave document' }).click()

await expect.poll(() => dialogs).toEqual(['beforeunload'])
await expect(page.getByLabel('Draft', { exact: true })).toHaveValue(
'Unsaved draft',
)
})

test(`${action}: the current fragment preserves the next unload warning`, async ({
page,
}) => {
await page.goto('/history-blocking?step=0#same')
await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft')
await page.getByLabel('Ignore blockers').check()
await page.getByLabel('Document destination').fill('#same')
const dialogs = dismissUnloadDialogs(page)

await page.getByRole('button', { name: action, exact: true }).click()

await expect(page).toHaveURL('/history-blocking?step=0#same')
expect(dialogs).toEqual([])
await page.getByRole('link', { name: 'Leave document' }).click()

await expect.poll(() => dialogs).toEqual(['beforeunload'])
await expect(page.getByLabel('Draft', { exact: true })).toHaveValue(
'Unsaved draft',
)
})

for (const ignoreBlocker of [false, true]) {
test(`${action}: ${ignoreBlocker ? 'skipped' : 'accepted'} blockers need no native confirmation`, async ({
page,
}) => {
await page.goto('/history-blocking')
await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft')
if (ignoreBlocker) {
await page.getByLabel('Ignore blockers').check()
}
const dialogs = dismissUnloadDialogs(page)

await page.getByRole('button', { name: action, exact: true }).click()
if (!ignoreBlocker) {
await page
.getByRole('button', { name: 'Continue navigation', exact: true })
.click()
}

await expect(page).toHaveURL('/')
expect(dialogs).toEqual([])
})
}
}
80 changes: 56 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?: (href: string) => 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,21 @@ export function createBrowserHistory(opts?: {
notifyOnIndexChange: false,
})

history._ignoreNextBeforeUnload = (href) => {
ignoreNextBeforeUnload = false
try {
href = new URL(href, win.document.baseURI).href
// External handlers and same-document fragments may emit neither
// beforeunload nor popstate, leaving an exemption for a later departure.
ignoreNextBeforeUnload =
/^https?:/.test(href) &&
(!href.includes('#') ||
href.split('#')[0] !== win.location.href.split('#')[0])
} catch {
// Invalid URLs cannot unload the document.
}
}

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

Expand Down Expand Up @@ -639,30 +690,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
Loading