Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
16 changes: 16 additions & 0 deletions packages/react-router/src/HeadContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react'
import { escapeHtml } from '@tanstack/router-core'
import { Asset } from './Asset'
import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
Expand Down Expand Up @@ -34,6 +35,21 @@ export const useTags = () => {
children: m.title,
}
}
} else if ('script:ld+json' in m) {
// Handle JSON-LD structured data
// Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
try {
const json = JSON.stringify(m['script:ld+json'])
resultMeta.push({
tag: 'script',
attrs: {
type: 'application/ld+json',
},
children: escapeHtml(json),
})
} catch {
// Skip invalid JSON-LD objects
}
} else {
const attribute = m.name ?? m.property
if (attribute) {
Expand Down
17 changes: 17 additions & 0 deletions packages/react-router/src/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
deepEqual,
exactPathTest,
functionalUpdate,
isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
} from '@tanstack/router-core'
Expand Down Expand Up @@ -144,10 +145,26 @@ export function useLinkProps<

const externalLink = React.useMemo(() => {
if (hrefOption?.external) {
// Block dangerous protocols for external links
if (isDangerousProtocol(hrefOption.href)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Blocked Link with dangerous protocol: ${hrefOption.href}`,
)
}
return undefined
}
return hrefOption.href
}
try {
new URL(to as any)
// Block dangerous protocols like javascript:, data:, vbscript:
if (isDangerousProtocol(to as string)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(`Blocked Link with dangerous protocol: ${to}`)
}
return undefined
}
return to
} catch {}
return undefined
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router/src/scroll-restoration.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
defaultGetScrollRestorationKey,
escapeHtml,
restoreScroll,
storageKey,
} from '@tanstack/router-core'
Expand Down Expand Up @@ -37,7 +38,7 @@ export function ScrollRestoration() {

return (
<ScriptOnce
children={`(${restoreScroll.toString()})(${JSON.stringify(restoreScrollOptions)})`}
children={`(${restoreScroll.toString()})(${escapeHtml(JSON.stringify(restoreScrollOptions))})`}
/>
)
}
2 changes: 2 additions & 0 deletions packages/router-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ export {
createControlledPromise,
isModuleNotFoundError,
decodePath,
escapeHtml,
isDangerousProtocol,
} from './utils'
export type {
NoInfer,
Expand Down
8 changes: 8 additions & 0 deletions packages/router-core/src/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SAFE_URL_PROTOCOLS, isDangerousProtocol } from './utils'
import type { NavigateOptions } from './link'
import type { AnyRouter, RegisteredRouter } from './router'

Expand Down Expand Up @@ -82,6 +83,13 @@ export function redirect<
): Redirect<TRouter, TFrom, TTo, TMaskFrom, TMaskTo> {
opts.statusCode = opts.statusCode || opts.code || 307

// Block dangerous protocols in redirect href
if (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.`,
)
}

if (!opts.reloadDocument && typeof opts.href === 'string') {
try {
new URL(opts.href)
Expand Down
29 changes: 28 additions & 1 deletion packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deepEqual,
findLast,
functionalUpdate,
isDangerousProtocol,
last,
replaceEqualDeep,
} from './utils'
Expand Down Expand Up @@ -2048,6 +2049,17 @@ export class RouterCore<

const reloadHref = !hrefIsUrl && publicHref ? publicHref : href

// Block dangerous protocols like javascript:, data:, vbscript:
// These could execute arbitrary code if passed to window.location
if (isDangerousProtocol(reloadHref)) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Blocked navigation to dangerous protocol: ${reloadHref}`,
)
}
return Promise.resolve()
}

// Check blockers for external URLs unless ignoreBlocker is true
if (!rest.ignoreBlocker) {
// Cast to access internal getBlockers method
Expand Down Expand Up @@ -2436,15 +2448,30 @@ export class RouterCore<
}

resolveRedirect = (redirect: AnyRedirect): AnyRedirect => {
const locationHeader = redirect.headers.get('Location')

if (!redirect.options.href) {
const location = this.buildLocation(redirect.options)
const href = this.getParsedLocationHref(location)
redirect.options.href = location.href
redirect.options.href = href
redirect.headers.set('Location', href)
} else if (locationHeader) {
try {
const url = new URL(locationHeader)
if (this.origin && url.origin === this.origin) {
const href = url.pathname + url.search + url.hash
redirect.options.href = href
redirect.headers.set('Location', href)
}
} catch {
// ignore invalid URLs
}
}

if (!redirect.headers.get('Location')) {
redirect.headers.set('Location', redirect.options.href)
}

return redirect
}

Expand Down
21 changes: 14 additions & 7 deletions packages/router-core/src/ssr/ssr-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,14 +328,21 @@ export function attachRouterServerSsrUtils({
}
}

/**
* Get the origin for the request.
*
* SECURITY: We intentionally do NOT trust the Origin header for determining
* the router's origin. The Origin header can be spoofed by attackers, which
* could lead to SSRF-like vulnerabilities where redirects are constructed
* using a malicious origin (CVE-2024-34351).
*
* Instead, we derive the origin from request.url, which is typically set by
* the server infrastructure (not client-controlled headers).
*
* For applications behind proxies that need to trust forwarded headers,
* use the router's `origin` option to explicitly configure a trusted origin.
*/
export function getOrigin(request: Request) {
const originHeader = request.headers.get('Origin')
if (originHeader) {
try {
new URL(originHeader)
return originHeader
} catch {}
}
try {
return new URL(request.url).origin
} catch {}
Expand Down
58 changes: 58 additions & 0 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,64 @@ function decodeSegment(segment: string): string {
return sanitizePathSegment(decoded)
}

/**
* List of URL protocols that are safe for navigation.
* Only these protocols are allowed in redirects and navigation.
*/
export const SAFE_URL_PROTOCOLS = ['http:', 'https:', '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.
*
* The URL constructor correctly normalizes:
* - Mixed case (JavaScript: → javascript:)
* - Whitespace/control characters (java\nscript: → javascript:)
* - Leading whitespace
*
* 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
*/
export function isDangerousProtocol(url: 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)
} catch {
// URL constructor throws for relative URLs (no protocol)
// These are safe - they can't execute scripts
return false
}
}

// This utility is based on https://github.com/zertosh/htmlescape
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
const HTML_ESCAPE_LOOKUP: { [match: string]: string } = {
'&': '\\u0026',
'>': '\\u003e',
'<': '\\u003c',
'\u2028': '\\u2028',
'\u2029': '\\u2029',
}

const HTML_ESCAPE_REGEX = /[&><\u2028\u2029]/g

/**
* Escape HTML special characters in a string to prevent XSS attacks
* when embedding strings in script tags during SSR.
*
* This is essential for preventing XSS vulnerabilities when user-controlled
* content is embedded in inline scripts.
*/
export function escapeHtml(str: string): string {
return str.replace(HTML_ESCAPE_REGEX, (match) => HTML_ESCAPE_LOOKUP[match]!)
}

export function decodePath(path: string, decodeIgnore?: Array<string>): string {
if (!path) return path
const re = decodeIgnore
Expand Down
Loading
Loading