Skip to content

Commit e5fd28b

Browse files
fix: hardening (#6337)
1 parent 48519ec commit e5fd28b

22 files changed

+859
-11
lines changed

packages/react-router/src/HeadContent.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as React from 'react'
2+
import { escapeHtml } from '@tanstack/router-core'
23
import { Asset } from './Asset'
34
import { useRouter } from './useRouter'
45
import { useRouterState } from './useRouterState'
@@ -34,6 +35,21 @@ export const useTags = () => {
3435
children: m.title,
3536
}
3637
}
38+
} else if ('script:ld+json' in m) {
39+
// Handle JSON-LD structured data
40+
// Content is HTML-escaped to prevent XSS when injected via dangerouslySetInnerHTML
41+
try {
42+
const json = JSON.stringify(m['script:ld+json'])
43+
resultMeta.push({
44+
tag: 'script',
45+
attrs: {
46+
type: 'application/ld+json',
47+
},
48+
children: escapeHtml(json),
49+
})
50+
} catch {
51+
// Skip invalid JSON-LD objects
52+
}
3753
} else {
3854
const attribute = m.name ?? m.property
3955
if (attribute) {

packages/react-router/src/link.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
deepEqual,
55
exactPathTest,
66
functionalUpdate,
7+
isDangerousProtocol,
78
preloadWarning,
89
removeTrailingSlash,
910
} from '@tanstack/router-core'
@@ -144,10 +145,26 @@ export function useLinkProps<
144145

145146
const externalLink = React.useMemo(() => {
146147
if (hrefOption?.external) {
148+
// Block dangerous protocols for external links
149+
if (isDangerousProtocol(hrefOption.href)) {
150+
if (process.env.NODE_ENV !== 'production') {
151+
console.warn(
152+
`Blocked Link with dangerous protocol: ${hrefOption.href}`,
153+
)
154+
}
155+
return undefined
156+
}
147157
return hrefOption.href
148158
}
149159
try {
150160
new URL(to as any)
161+
// Block dangerous protocols like javascript:, data:, vbscript:
162+
if (isDangerousProtocol(to as string)) {
163+
if (process.env.NODE_ENV !== 'production') {
164+
console.warn(`Blocked Link with dangerous protocol: ${to}`)
165+
}
166+
return undefined
167+
}
151168
return to
152169
} catch {}
153170
return undefined

packages/react-router/src/scroll-restoration.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
defaultGetScrollRestorationKey,
3+
escapeHtml,
34
restoreScroll,
45
storageKey,
56
} from '@tanstack/router-core'
@@ -37,7 +38,7 @@ export function ScrollRestoration() {
3738

3839
return (
3940
<ScriptOnce
40-
children={`(${restoreScroll.toString()})(${JSON.stringify(restoreScrollOptions)})`}
41+
children={`(${restoreScroll.toString()})(${escapeHtml(JSON.stringify(restoreScrollOptions))})`}
4142
/>
4243
)
4344
}

packages/router-core/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,8 @@ export {
273273
createControlledPromise,
274274
isModuleNotFoundError,
275275
decodePath,
276+
escapeHtml,
277+
isDangerousProtocol,
276278
} from './utils'
277279
export type {
278280
NoInfer,

packages/router-core/src/redirect.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SAFE_URL_PROTOCOLS, isDangerousProtocol } from './utils'
12
import type { NavigateOptions } from './link'
23
import type { AnyRouter, RegisteredRouter } from './router'
34

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

86+
// Block dangerous protocols in redirect href
87+
if (typeof opts.href === 'string' && isDangerousProtocol(opts.href)) {
88+
throw new Error(
89+
`Redirect blocked: unsafe protocol in href "${opts.href}". Only ${SAFE_URL_PROTOCOLS.join(', ')} protocols are allowed.`,
90+
)
91+
}
92+
8593
if (!opts.reloadDocument && typeof opts.href === 'string') {
8694
try {
8795
new URL(opts.href)

packages/router-core/src/router.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
deepEqual,
77
findLast,
88
functionalUpdate,
9+
isDangerousProtocol,
910
last,
1011
replaceEqualDeep,
1112
} from './utils'
@@ -2061,6 +2062,17 @@ export class RouterCore<
20612062
// otherwise use href directly (which may already include basepath)
20622063
const reloadHref = !hrefIsUrl && publicHref ? publicHref : href
20632064

2065+
// Block dangerous protocols like javascript:, data:, vbscript:
2066+
// These could execute arbitrary code if passed to window.location
2067+
if (isDangerousProtocol(reloadHref)) {
2068+
if (process.env.NODE_ENV !== 'production') {
2069+
console.warn(
2070+
`Blocked navigation to dangerous protocol: ${reloadHref}`,
2071+
)
2072+
}
2073+
return Promise.resolve()
2074+
}
2075+
20642076
// Check blockers for external URLs unless ignoreBlocker is true
20652077
if (!rest.ignoreBlocker) {
20662078
// Cast to access internal getBlockers method
@@ -2449,15 +2461,30 @@ export class RouterCore<
24492461
}
24502462

24512463
resolveRedirect = (redirect: AnyRedirect): AnyRedirect => {
2464+
const locationHeader = redirect.headers.get('Location')
2465+
24522466
if (!redirect.options.href) {
24532467
const location = this.buildLocation(redirect.options)
24542468
const href = this.getParsedLocationHref(location)
2455-
redirect.options.href = location.href
2469+
redirect.options.href = href
24562470
redirect.headers.set('Location', href)
2471+
} else if (locationHeader) {
2472+
try {
2473+
const url = new URL(locationHeader)
2474+
if (this.origin && url.origin === this.origin) {
2475+
const href = url.pathname + url.search + url.hash
2476+
redirect.options.href = href
2477+
redirect.headers.set('Location', href)
2478+
}
2479+
} catch {
2480+
// ignore invalid URLs
2481+
}
24572482
}
2483+
24582484
if (!redirect.headers.get('Location')) {
24592485
redirect.headers.set('Location', redirect.options.href)
24602486
}
2487+
24612488
return redirect
24622489
}
24632490

packages/router-core/src/ssr/ssr-server.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -328,14 +328,21 @@ export function attachRouterServerSsrUtils({
328328
}
329329
}
330330

331+
/**
332+
* Get the origin for the request.
333+
*
334+
* SECURITY: We intentionally do NOT trust the Origin header for determining
335+
* the router's origin. The Origin header can be spoofed by attackers, which
336+
* could lead to SSRF-like vulnerabilities where redirects are constructed
337+
* using a malicious origin (CVE-2024-34351).
338+
*
339+
* Instead, we derive the origin from request.url, which is typically set by
340+
* the server infrastructure (not client-controlled headers).
341+
*
342+
* For applications behind proxies that need to trust forwarded headers,
343+
* use the router's `origin` option to explicitly configure a trusted origin.
344+
*/
331345
export function getOrigin(request: Request) {
332-
const originHeader = request.headers.get('Origin')
333-
if (originHeader) {
334-
try {
335-
new URL(originHeader)
336-
return originHeader
337-
} catch {}
338-
}
339346
try {
340347
return new URL(request.url).origin
341348
} catch {}

packages/router-core/src/utils.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,64 @@ function decodeSegment(segment: string): string {
518518
return sanitizePathSegment(decoded)
519519
}
520520

521+
/**
522+
* List of URL protocols that are safe for navigation.
523+
* Only these protocols are allowed in redirects and navigation.
524+
*/
525+
export const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:']
526+
527+
/**
528+
* Check if a URL string uses a protocol that is not in the safe list.
529+
* Returns true for dangerous protocols like javascript:, data:, vbscript:, etc.
530+
*
531+
* The URL constructor correctly normalizes:
532+
* - Mixed case (JavaScript: → javascript:)
533+
* - Whitespace/control characters (java\nscript: → javascript:)
534+
* - Leading whitespace
535+
*
536+
* For relative URLs (no protocol), returns false (safe).
537+
*
538+
* @param url - The URL string to check
539+
* @returns true if the URL uses a dangerous (non-whitelisted) protocol
540+
*/
541+
export function isDangerousProtocol(url: string): boolean {
542+
if (!url) return false
543+
544+
try {
545+
// Use the URL constructor - it correctly normalizes protocols
546+
// per WHATWG URL spec, handling all bypass attempts automatically
547+
const parsed = new URL(url)
548+
return !SAFE_URL_PROTOCOLS.includes(parsed.protocol)
549+
} catch {
550+
// URL constructor throws for relative URLs (no protocol)
551+
// These are safe - they can't execute scripts
552+
return false
553+
}
554+
}
555+
556+
// This utility is based on https://github.com/zertosh/htmlescape
557+
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
558+
const HTML_ESCAPE_LOOKUP: { [match: string]: string } = {
559+
'&': '\\u0026',
560+
'>': '\\u003e',
561+
'<': '\\u003c',
562+
'\u2028': '\\u2028',
563+
'\u2029': '\\u2029',
564+
}
565+
566+
const HTML_ESCAPE_REGEX = /[&><\u2028\u2029]/g
567+
568+
/**
569+
* Escape HTML special characters in a string to prevent XSS attacks
570+
* when embedding strings in script tags during SSR.
571+
*
572+
* This is essential for preventing XSS vulnerabilities when user-controlled
573+
* content is embedded in inline scripts.
574+
*/
575+
export function escapeHtml(str: string): string {
576+
return str.replace(HTML_ESCAPE_REGEX, (match) => HTML_ESCAPE_LOOKUP[match]!)
577+
}
578+
521579
export function decodePath(path: string, decodeIgnore?: Array<string>): string {
522580
if (!path) return path
523581
const re = decodeIgnore

0 commit comments

Comments
 (0)