Skip to content
35 changes: 35 additions & 0 deletions docs/router/framework/react/api/router/RouterOptionsType.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,41 @@ The `RouterOptions` type accepts an object with the following properties and met
- When `true`, disables the global catch boundary that normally wraps all route matches. This allows unhandled errors to bubble up to top-level error handlers in the browser.
- Useful for testing tools, error reporting services, and debugging scenarios.

### `protocolAllowlist` property

- Type: `Array<string>`
- Optional
- Defaults to `DEFAULT_PROTOCOL_ALLOWLIST` which includes:
- Web navigation: `http:`, `https:`
- Common browser-safe actions: `mailto:`, `tel:`
- An array of URL protocols that are allowed in links, redirects, and navigation. Absolute URLs with protocols not in this list are rejected to prevent security vulnerabilities like XSS attacks.
- This check is applied across router navigation APIs, including:
- `<Link to="...">`
- `navigate({ to: ... })` and `navigate({ href: ... })`
- `redirect({ to: ... })` and `redirect({ href: ... })`
- Protocol entries must match `URL.protocol` format (lowercase with a trailing `:`), for example `blob:` or `data:`. If you configure `protocolAllowlist: ['blob']` (without `:`), links using `blob:` will still be blocked.

**Example**

```tsx
import {
createRouter,
DEFAULT_PROTOCOL_ALLOWLIST,
} from '@tanstack/react-router'

// Use a custom allowlist (replaces the default)
const router = createRouter({
routeTree,
protocolAllowlist: ['https:', 'mailto:'],
})

// Or extend the default allowlist
const router = createRouter({
routeTree,
protocolAllowlist: [...DEFAULT_PROTOCOL_ALLOWLIST, 'ftp:'],
})
```

### `defaultViewTransition` property

- Type: `boolean | ViewTransitionOptions`
Expand Down
7 changes: 6 additions & 1 deletion packages/react-router/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,12 @@ export { useMatch } from './useMatch'
export { useLoaderDeps } from './useLoaderDeps'
export { useLoaderData } from './useLoaderData'

export { redirect, isRedirect, createRouterConfig } from '@tanstack/router-core'
export {
redirect,
isRedirect,
createRouterConfig,
DEFAULT_PROTOCOL_ALLOWLIST,
} from '@tanstack/router-core'

export {
RouteApi,
Expand Down
14 changes: 7 additions & 7 deletions packages/react-router/src/link.tsx
Copy link
Copy Markdown
Contributor

@hiendv hiendv Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test case for <Link />?

Something like this

describe('weird links', () => {
  test('should work', async () => {
    const rootRoute = createRootRoute()
    const inputs = [
      'x-safari-https://example.com',
      'googlechromes://example.com',
      'intent://example.com#Intent;scheme=https;end',
    ]
    const indexRoute = createRoute({
      getParentRoute: () => rootRoute,
      path: '/',
      component: () => {
        return inputs.map((input) => <Link to={input} key={input} />)
      },
    })

    const router = createRouter({
      routeTree: rootRoute.addChildren([indexRoute]),
      history,
    })

    render(<RouterProvider router={router} />)

    const links = await screen.findAllByRole('link')
    const hrefs = links.map((el) => el.getAttribute('href'))
    expect(hrefs).toBe(inputs)
  })
})

Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export function useLinkProps<
) {
try {
new URL(to)
if (isDangerousProtocol(to)) {
if (isDangerousProtocol(to, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${to}`)
}
Expand Down Expand Up @@ -170,7 +170,7 @@ export function useLinkProps<

const externalLink = (() => {
if (hrefOption?.external) {
if (isDangerousProtocol(hrefOption.href)) {
if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Blocked Link with dangerous protocol: ${hrefOption.href}`,
Expand All @@ -187,7 +187,7 @@ export function useLinkProps<
if (typeof to === 'string' && to.indexOf(':') > -1) {
try {
new URL(to)
if (isDangerousProtocol(to)) {
if (isDangerousProtocol(to, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${to}`)
}
Expand Down Expand Up @@ -438,7 +438,7 @@ export function useLinkProps<
const externalLink = React.useMemo(() => {
if (hrefOption?.external) {
// Block dangerous protocols for external links
if (isDangerousProtocol(hrefOption.href)) {
if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Blocked Link with dangerous protocol: ${hrefOption.href}`,
Expand All @@ -453,8 +453,8 @@ export function useLinkProps<
if (typeof to !== 'string' || to.indexOf(':') === -1) return undefined
try {
new URL(to as any)
// Block dangerous protocols like javascript:, data:, vbscript:
if (isDangerousProtocol(to)) {
// Block dangerous protocols like javascript:, blob:, data:
if (isDangerousProtocol(to, router.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${to}`)
}
Expand All @@ -463,7 +463,7 @@ export function useLinkProps<
return to
} catch {}
return undefined
}, [to, hrefOption])
}, [to, hrefOption, router.protocolAllowlist])

// eslint-disable-next-line react-hooks/rules-of-hooks
const isActive = useRouterState({
Expand Down
63 changes: 63 additions & 0 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6613,3 +6613,66 @@ describe('encoded and unicode paths', () => {
},
)
})

describe('protocolAllowlist', () => {
const rootRoute = createRootRoute()
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => (
<>
<Link to="x-safari-https://example.com" />
<Link
to="intent://example.com#Intent;scheme=https;end"
reloadDocument
/>
</>
),
})

let consoleWarn = vi.fn()
beforeEach(() => {
consoleWarn = vi.fn()
vi.spyOn(console, 'warn').mockImplementation(consoleWarn)
})
afterEach(() => {
vi.restoreAllMocks()
})

it('should work like normal links when protocolAllowlist is set', async () => {
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
history,
protocolAllowlist: ['x-safari-https:', 'intent:'],
})
render(<RouterProvider router={router} />)
const links = await screen.findAllByRole('link')
expect(links[0]).toHaveAttribute('href', 'x-safari-https://example.com')
expect(links[1]).toHaveAttribute(
'href',
'intent://example.com#Intent;scheme=https;end',
)
expect(consoleWarn).not.toHaveBeenCalled()
})

it('should fallback to relative links when protocol is not in allowlist', async () => {
const router = createRouter({
routeTree: rootRoute.addChildren([indexRoute]),
history,
protocolAllowlist: [],
})
render(<RouterProvider router={router} />)
const links = await screen.findAllByRole('link')
expect(links[0]).toHaveAttribute('href', '/x-safari-https:/example.com')
expect(links[1]).toHaveAttribute(
'href',
'/intent:/example.com#Intent;scheme=https;end',
)
expect(consoleWarn).toHaveBeenCalledWith(
'Blocked Link with dangerous protocol: x-safari-https://example.com',
)
expect(consoleWarn).toHaveBeenCalledWith(
'Blocked Link with dangerous protocol: intent://example.com#Intent;scheme=https;end',
)
})
})
1 change: 1 addition & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export {
createControlledPromise,
isModuleNotFoundError,
decodePath,
DEFAULT_PROTOCOL_ALLOWLIST,
escapeHtml,
isDangerousProtocol,
buildDevStylesUrl,
Expand Down
12 changes: 0 additions & 12 deletions packages/router-core/src/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { SAFE_URL_PROTOCOLS, isDangerousProtocol } from './utils'
import type { NavigateOptions } from './link'
import type { AnyRouter, RegisteredRouter } from './router'
import type { ParsedLocation } from './location'
Expand Down Expand Up @@ -124,17 +123,6 @@ export function redirect<
): Redirect<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> {
opts.statusCode = opts.statusCode || opts.code || 307

// Block dangerous protocols in redirect href
if (
!opts._builtLocation &&
typeof opts.href === 'string' &&
isDangerousProtocol(opts.href)
) {
throw new Error(
`Redirect blocked: unsafe protocol in href "${opts.href}". Only ${SAFE_URL_PROTOCOLS.join(', ')} protocols are allowed.`,
)
}

Comment on lines -127 to -137
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to check here, since a redirect() is always handled by something else that will check, right?

if (
!opts._builtLocation &&
!opts.reloadDocument &&
Expand Down
30 changes: 28 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createBrowserHistory, parseHref } from '@tanstack/history'
import { isServer } from '@tanstack/router-core/isServer'
import { batch } from './utils/batch'
import {
DEFAULT_PROTOCOL_ALLOWLIST,
createControlledPromise,
decodePath,
deepEqual,
Expand Down Expand Up @@ -470,6 +471,15 @@ export interface RouterOptions<
*/
disableGlobalCatchBoundary?: boolean

/**
* An array of URL protocols to allow in links, redirects, and navigation.
* Absolute URLs with protocols not in this list will be rejected.
*
* @default DEFAULT_PROTOCOL_ALLOWLIST (http:, https:, mailto:, tel:)
* @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#protocolallowlist-property)
*/
protocolAllowlist?: Array<string>

serializationAdapters?: ReadonlyArray<AnySerializationAdapter>
/**
* Configures how the router will rewrite the location between the actual href and the internal href of the router.
Expand Down Expand Up @@ -961,6 +971,7 @@ export class RouterCore<
resolvePathCache!: LRUCache<string, string>
isServer!: boolean
pathParamsDecoder?: (encoded: string) => string
protocolAllowlist!: Set<string>

/**
* @deprecated Use the `createRouter` function instead
Expand All @@ -984,6 +995,8 @@ export class RouterCore<
notFoundMode: options.notFoundMode ?? 'fuzzy',
stringifySearch: options.stringifySearch ?? defaultStringifySearch,
parseSearch: options.parseSearch ?? defaultParseSearch,
protocolAllowlist:
options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST,
})

if (typeof document !== 'undefined') {
Expand Down Expand Up @@ -1029,6 +1042,8 @@ export class RouterCore<

this.isServer = this.options.isServer ?? typeof document === 'undefined'

this.protocolAllowlist = new Set(this.options.protocolAllowlist)

Comment on lines +1045 to +1046
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize allowlist entries to avoid false blocks for custom protocols.

If callers pass x-safari-https (no colon) or uppercase variants, the URL parser will produce x-safari-https: in lowercase and the current Set won’t match. Normalizing once here prevents surprising blocks for valid custom schemes.

✅ Suggested normalization
-    this.protocolAllowlist = new Set(this.options.protocolAllowlist)
+    this.protocolAllowlist = new Set(
+      this.options.protocolAllowlist.map((p) => {
+        const normalized = p.toLowerCase()
+        return normalized.endsWith(':') ? normalized : `${normalized}:`
+      }),
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
this.protocolAllowlist = new Set(this.options.protocolAllowlist)
this.protocolAllowlist = new Set(
this.options.protocolAllowlist.map((p) => {
const normalized = p.toLowerCase()
return normalized.endsWith(':') ? normalized : `${normalized}:`
}),
)
🤖 Prompt for AI Agents
In `@packages/router-core/src/router.ts` around lines 1045 - 1046, The allowlist
initialization uses this.options.protocolAllowlist directly, causing mismatches
because URL.protocol returns lowercase values with a trailing colon; update the
assignment of this.protocolAllowlist so it normalizes each entry from
this.options.protocolAllowlist to lowercase and ensures a trailing ':' (e.g.,
map entries with entry = entry.toLowerCase() and append ':' if missing) before
creating the Set, so checks against URL.protocol will match custom schemes like
'x-safari-https:' consistently.

if (this.options.pathParamsAllowedCharacters)
this.pathParamsDecoder = compileDecodeCharMap(
this.options.pathParamsAllowedCharacters,
Expand Down Expand Up @@ -2248,9 +2263,9 @@ export class RouterCore<
// otherwise use href directly (which may already include basepath)
const reloadHref = !hrefIsUrl && publicHref ? publicHref : href

// Block dangerous protocols like javascript:, data:, vbscript:
// Block dangerous protocols like javascript:, blob:, data:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have to change this?

// These could execute arbitrary code if passed to window.location
if (isDangerousProtocol(reloadHref)) {
if (isDangerousProtocol(reloadHref, this.protocolAllowlist)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Blocked navigation to dangerous protocol: ${reloadHref}`,
Expand Down Expand Up @@ -2669,6 +2684,17 @@ export class RouterCore<
}
}

if (
redirect.options.href &&
!redirect.options._builtLocation &&
// Check for dangerous protocols before processing the redirect
isDangerousProtocol(redirect.options.href, this.protocolAllowlist)
) {
throw new Error(
`Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.`,
)
}

if (!redirect.headers.get('Location')) {
redirect.headers.set('Location', redirect.options.href)
}
Expand Down
28 changes: 20 additions & 8 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,14 +536,22 @@ function decodeSegment(segment: string): string {
}

/**
* List of URL protocols that are safe for navigation.
* Only these protocols are allowed in redirects and navigation.
* Default list of URL protocols to allow in links, redirects, and navigation.
* Any absolute URL protocol not in this list is treated as dangerous by default.
*/
export const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:']
export const DEFAULT_PROTOCOL_ALLOWLIST = [
// Standard web navigation
'http:',
'https:',

// Common browser-safe actions
'mailto:',
'tel:',
]

/**
* Check if a URL string uses a protocol that is not in the safe list.
* Returns true for dangerous protocols like javascript:, data:, vbscript:, etc.
* Check if a URL string uses a protocol that is not in the allowlist.
* Returns true for blocked protocols like javascript:, blob:, data:, etc.
*
* The URL constructor correctly normalizes:
* - Mixed case (JavaScript: → javascript:)
Expand All @@ -553,16 +561,20 @@ export const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:']
* For relative URLs (no protocol), returns false (safe).
*
* @param url - The URL string to check
* @returns true if the URL uses a dangerous (non-whitelisted) protocol
* @param allowlist - Set of protocols to allow
* @returns true if the URL uses a protocol that is not allowed
*/
export function isDangerousProtocol(url: string): boolean {
export function isDangerousProtocol(
url: string,
allowlist: Set<string>,
): boolean {
if (!url) return false

try {
// Use the URL constructor - it correctly normalizes protocols
// per WHATWG URL spec, handling all bypass attempts automatically
const parsed = new URL(url)
return !SAFE_URL_PROTOCOLS.includes(parsed.protocol)
return !allowlist.has(parsed.protocol)
} catch {
// URL constructor throws for relative URLs (no protocol)
// These are safe - they can't execute scripts
Expand Down
Loading
Loading