diff --git a/packages/react-router/src/HeadContent.tsx b/packages/react-router/src/HeadContent.tsx
index b86d706cd0..3a30c521dc 100644
--- a/packages/react-router/src/HeadContent.tsx
+++ b/packages/react-router/src/HeadContent.tsx
@@ -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'
@@ -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) {
diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx
index 365464bf01..125c5b20c7 100644
--- a/packages/react-router/src/link.tsx
+++ b/packages/react-router/src/link.tsx
@@ -4,6 +4,7 @@ import {
deepEqual,
exactPathTest,
functionalUpdate,
+ isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
} from '@tanstack/router-core'
@@ -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
diff --git a/packages/react-router/src/scroll-restoration.tsx b/packages/react-router/src/scroll-restoration.tsx
index 3527a2db37..8430f8182e 100644
--- a/packages/react-router/src/scroll-restoration.tsx
+++ b/packages/react-router/src/scroll-restoration.tsx
@@ -1,5 +1,6 @@
import {
defaultGetScrollRestorationKey,
+ escapeHtml,
restoreScroll,
storageKey,
} from '@tanstack/router-core'
@@ -37,7 +38,7 @@ export function ScrollRestoration() {
return (
)
}
diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts
index 8df74f9d3f..c3c79db98d 100644
--- a/packages/router-core/src/index.ts
+++ b/packages/router-core/src/index.ts
@@ -273,6 +273,8 @@ export {
createControlledPromise,
isModuleNotFoundError,
decodePath,
+ escapeHtml,
+ isDangerousProtocol,
} from './utils'
export type {
NoInfer,
diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts
index d2093a0eb6..a68cdc8c3b 100644
--- a/packages/router-core/src/redirect.ts
+++ b/packages/router-core/src/redirect.ts
@@ -1,3 +1,4 @@
+import { SAFE_URL_PROTOCOLS, isDangerousProtocol } from './utils'
import type { NavigateOptions } from './link'
import type { AnyRouter, RegisteredRouter } from './router'
@@ -82,6 +83,13 @@ export function redirect<
): Redirect {
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)
diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts
index 1e837bd426..91d3101373 100644
--- a/packages/router-core/src/router.ts
+++ b/packages/router-core/src/router.ts
@@ -6,6 +6,7 @@ import {
deepEqual,
findLast,
functionalUpdate,
+ isDangerousProtocol,
last,
replaceEqualDeep,
} from './utils'
@@ -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
@@ -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
}
diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts
index 248cf2980b..254d2a0f1e 100644
--- a/packages/router-core/src/ssr/ssr-server.ts
+++ b/packages/router-core/src/ssr/ssr-server.ts
@@ -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 {}
diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts
index 49db799a94..2fad743f5c 100644
--- a/packages/router-core/src/utils.ts
+++ b/packages/router-core/src/utils.ts
@@ -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 {
if (!path) return path
const re = decodeIgnore
diff --git a/packages/router-core/tests/dangerous-protocols.test.ts b/packages/router-core/tests/dangerous-protocols.test.ts
new file mode 100644
index 0000000000..54540eb91d
--- /dev/null
+++ b/packages/router-core/tests/dangerous-protocols.test.ts
@@ -0,0 +1,267 @@
+import { describe, expect, it } from 'vitest'
+import { isDangerousProtocol } from '../src/utils'
+import { redirect } from '../src/redirect'
+
+describe('isDangerousProtocol', () => {
+ describe('dangerous protocols (not whitelisted)', () => {
+ it('should detect javascript: protocol', () => {
+ expect(isDangerousProtocol('javascript:alert(1)')).toBe(true)
+ })
+
+ it('should detect javascript: with newlines', () => {
+ expect(isDangerousProtocol('java\nscript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('java\rscript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('java\tscript:alert(1)')).toBe(true)
+ })
+
+ it('should detect javascript: with mixed case', () => {
+ expect(isDangerousProtocol('JavaScript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('JAVASCRIPT:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('jAvAsCrIpT:alert(1)')).toBe(true)
+ })
+
+ it('should detect javascript: with leading whitespace', () => {
+ expect(isDangerousProtocol(' javascript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('\tjavascript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('\njavascript:alert(1)')).toBe(true)
+ })
+
+ it('should detect data: protocol', () => {
+ expect(
+ isDangerousProtocol('data:text/html,'),
+ ).toBe(true)
+ })
+
+ it('should detect vbscript: protocol', () => {
+ expect(isDangerousProtocol('vbscript:msgbox(1)')).toBe(true)
+ })
+
+ it('should detect file: protocol', () => {
+ expect(isDangerousProtocol('file:///etc/passwd')).toBe(true)
+ })
+
+ it('should detect unknown protocols', () => {
+ expect(isDangerousProtocol('custom:something')).toBe(true)
+ expect(isDangerousProtocol('foo:bar')).toBe(true)
+ })
+ })
+
+ describe('safe protocols (whitelisted)', () => {
+ it('should allow http: protocol', () => {
+ expect(isDangerousProtocol('http://example.com')).toBe(false)
+ })
+
+ it('should allow https: protocol', () => {
+ expect(isDangerousProtocol('https://example.com')).toBe(false)
+ })
+
+ it('should allow mailto: protocol', () => {
+ expect(isDangerousProtocol('mailto:user@example.com')).toBe(false)
+ })
+
+ it('should allow tel: protocol', () => {
+ expect(isDangerousProtocol('tel:+1234567890')).toBe(false)
+ })
+ })
+
+ describe('relative URLs (no protocol)', () => {
+ it('should allow relative paths', () => {
+ expect(isDangerousProtocol('/path/to/page')).toBe(false)
+ expect(isDangerousProtocol('./relative')).toBe(false)
+ expect(isDangerousProtocol('../parent')).toBe(false)
+ })
+
+ it('should allow query strings', () => {
+ expect(isDangerousProtocol('?foo=bar')).toBe(false)
+ })
+
+ it('should allow hash fragments', () => {
+ expect(isDangerousProtocol('#section')).toBe(false)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty and null-ish inputs', () => {
+ expect(isDangerousProtocol('')).toBe(false)
+ })
+
+ it('should not be fooled by javascript in pathname', () => {
+ expect(isDangerousProtocol('https://example.com/javascript:foo')).toBe(
+ false,
+ )
+ expect(isDangerousProtocol('/javascript:foo')).toBe(false)
+ })
+
+ it('should not be fooled by colon in query string', () => {
+ expect(isDangerousProtocol('/path?time=12:00')).toBe(false)
+ })
+ })
+
+ describe('additional edge cases', () => {
+ describe('null and undefined inputs', () => {
+ it('should return false for null', () => {
+ expect(isDangerousProtocol(null as unknown as string)).toBe(false)
+ })
+
+ it('should return false for undefined', () => {
+ expect(isDangerousProtocol(undefined as unknown as string)).toBe(false)
+ })
+ })
+
+ describe('URL-encoded schemes', () => {
+ it('should return false for URL-encoded javascript: protocol (URL constructor does not decode protocol)', () => {
+ // %6a%61%76%61%73%63%72%69%70%74 = javascript
+ // The URL constructor treats this as an invalid URL (throws), so it returns false
+ // This is safe because browsers also don't decode percent-encoding in protocols
+ expect(
+ isDangerousProtocol('%6a%61%76%61%73%63%72%69%70%74:alert(1)'),
+ ).toBe(false)
+ })
+
+ it('should return false for partially URL-encoded javascript: protocol', () => {
+ // URL constructor throws for these malformed URLs
+ expect(isDangerousProtocol('%6aavascript:alert(1)')).toBe(false)
+ expect(isDangerousProtocol('j%61vascript:alert(1)')).toBe(false)
+ })
+
+ it('should return false for URL-encoded data: protocol', () => {
+ // %64%61%74%61 = data
+ // URL constructor treats this as invalid
+ expect(
+ isDangerousProtocol(
+ '%64%61%74%61:text/html,',
+ ),
+ ).toBe(false)
+ })
+
+ it('should return false for URL-encoded vbscript: protocol', () => {
+ // %76%62%73%63%72%69%70%74 = vbscript
+ // URL constructor treats this as invalid
+ expect(isDangerousProtocol('%76%62%73%63%72%69%70%74:msgbox(1)')).toBe(
+ false,
+ )
+ })
+
+ it('should return false for URL-encoded safe protocols (URL constructor does not decode)', () => {
+ // %68%74%74%70%73 = https
+ // URL constructor treats this as invalid since percent-encoding in protocol is not decoded
+ expect(isDangerousProtocol('%68%74%74%70%73://example.com')).toBe(false)
+ })
+ })
+
+ describe('protocol-relative URLs', () => {
+ it('should return false for protocol-relative URLs', () => {
+ expect(isDangerousProtocol('//example.com')).toBe(false)
+ })
+
+ it('should return false for protocol-relative URLs with paths', () => {
+ expect(isDangerousProtocol('//example.com/path/to/page')).toBe(false)
+ })
+
+ it('should return false for protocol-relative URLs with query strings', () => {
+ expect(isDangerousProtocol('//example.com?foo=bar')).toBe(false)
+ })
+
+ it('should return false for protocol-relative URLs with hash', () => {
+ expect(isDangerousProtocol('//example.com#section')).toBe(false)
+ })
+ })
+
+ describe('malformed inputs', () => {
+ it('should return false for strings without valid protocol pattern', () => {
+ expect(isDangerousProtocol('not a url at all')).toBe(false)
+ })
+
+ it('should return false for strings with only colons', () => {
+ expect(isDangerousProtocol(':::')).toBe(false)
+ })
+
+ it('should return false for strings starting with numbers', () => {
+ expect(isDangerousProtocol('123:456')).toBe(false)
+ })
+
+ it('should handle strings with non-printable characters', () => {
+ expect(isDangerousProtocol('\x00javascript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('\x01\x02\x03javascript:alert(1)')).toBe(
+ true,
+ )
+ })
+
+ it('should return false for very long benign paths', () => {
+ const longPath = '/' + 'a'.repeat(10000)
+ expect(isDangerousProtocol(longPath)).toBe(false)
+ })
+
+ it('should return false for very long query strings', () => {
+ const longQuery = '/path?' + 'a=b&'.repeat(1000)
+ expect(isDangerousProtocol(longQuery)).toBe(false)
+ })
+
+ it('should detect dangerous protocol even with long payload', () => {
+ const longPayload = 'javascript:' + 'a'.repeat(10000)
+ expect(isDangerousProtocol(longPayload)).toBe(true)
+ })
+
+ it('should handle unicode characters in URLs', () => {
+ expect(isDangerousProtocol('/путь/к/странице')).toBe(false)
+ expect(isDangerousProtocol('https://例え.jp/path')).toBe(false)
+ })
+
+ it('should return false for full-width unicode characters (not recognized as javascript protocol)', () => {
+ // Full-width characters are not normalized by URL constructor
+ // URL constructor throws, so this is treated as safe (relative URL)
+ expect(isDangerousProtocol('javascript:alert(1)')).toBe(false)
+ })
+ })
+
+ describe('whitespace variations', () => {
+ it('should detect javascript: with various whitespace combinations', () => {
+ expect(isDangerousProtocol(' \t\n javascript:alert(1)')).toBe(true)
+ expect(isDangerousProtocol('\r\njavascript:alert(1)')).toBe(true)
+ })
+
+ it('should return false for non-breaking space prefix (URL constructor throws)', () => {
+ // Non-breaking space is not stripped by URL constructor, causes it to throw
+ expect(isDangerousProtocol('\u00A0javascript:alert(1)')).toBe(false)
+ })
+
+ it('should return false for javascript: with embedded null bytes (URL constructor throws)', () => {
+ // Null bytes in the protocol cause URL constructor to throw
+ expect(isDangerousProtocol('java\x00script:alert(1)')).toBe(false)
+ })
+ })
+ })
+})
+
+describe('redirect with dangerous protocols', () => {
+ it('should throw when href uses javascript: protocol', () => {
+ expect(() => redirect({ href: 'javascript:alert(1)' })).toThrow(
+ /unsafe protocol/,
+ )
+ })
+
+ it('should throw when href uses javascript: with bypass attempts', () => {
+ expect(() => redirect({ href: 'java\nscript:alert(1)' })).toThrow(
+ /unsafe protocol/,
+ )
+ expect(() => redirect({ href: 'JavaScript:alert(1)' })).toThrow(
+ /unsafe protocol/,
+ )
+ })
+
+ it('should throw when href uses data: protocol', () => {
+ expect(() =>
+ redirect({ href: 'data:text/html,' }),
+ ).toThrow(/unsafe protocol/)
+ })
+
+ it('should allow safe protocols', () => {
+ expect(() => redirect({ href: 'https://example.com' })).not.toThrow()
+ expect(() => redirect({ href: 'http://example.com' })).not.toThrow()
+ expect(() => redirect({ href: 'mailto:user@example.com' })).not.toThrow()
+ })
+
+ it('should allow redirects without href', () => {
+ expect(() => redirect({ to: '/home' })).not.toThrow()
+ })
+})
diff --git a/packages/router-core/tests/getOrigin.test.ts b/packages/router-core/tests/getOrigin.test.ts
new file mode 100644
index 0000000000..30047e35a8
--- /dev/null
+++ b/packages/router-core/tests/getOrigin.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, test } from 'vitest'
+import { getOrigin } from '../src/ssr/ssr-server'
+
+describe('getOrigin security', () => {
+ test('should not trust spoofed Origin header', () => {
+ // An attacker could send a malicious Origin header to try to manipulate
+ // the router's origin, which could affect redirect handling (CVE-2024-34351)
+ const maliciousRequest = new Request('https://legitimate.com/api/action', {
+ headers: {
+ Origin: 'https://evil.com',
+ },
+ })
+
+ const origin = getOrigin(maliciousRequest)
+
+ // The origin should come from request.url, not the spoofed Origin header
+ // This prevents SSRF-like attacks where an attacker tries to manipulate
+ // how redirects are processed by providing a malicious Origin header
+ expect(origin).toBe('https://legitimate.com')
+ expect(origin).not.toBe('https://evil.com')
+ })
+
+ test('should derive origin from request URL', () => {
+ const request = new Request('https://myapp.com/page')
+ const origin = getOrigin(request)
+ expect(origin).toBe('https://myapp.com')
+ })
+
+ test('should handle localhost requests', () => {
+ const request = new Request('http://localhost:3000/api')
+ const origin = getOrigin(request)
+ expect(origin).toBe('http://localhost:3000')
+ })
+
+ test('should return fallback for invalid request URL', () => {
+ // This should be rare, but handle gracefully
+ const request = {
+ url: 'not-a-valid-url',
+ headers: new Headers(),
+ } as unknown as Request
+
+ const origin = getOrigin(request)
+ expect(origin).toBe('http://localhost')
+ })
+
+ test('should ignore Origin header even if request URL parse fails', () => {
+ // Even if the request URL is somehow invalid, we should not fall back
+ // to trusting the Origin header
+ const request = {
+ url: 'invalid-url',
+ headers: new Headers({
+ Origin: 'https://evil.com',
+ }),
+ } as unknown as Request
+
+ const origin = getOrigin(request)
+
+ // Should fall back to localhost, not use the potentially malicious Origin
+ expect(origin).toBe('http://localhost')
+ expect(origin).not.toBe('https://evil.com')
+ })
+})
diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts
index d65d37cf74..3a3d807800 100644
--- a/packages/router-core/tests/load.test.ts
+++ b/packages/router-core/tests/load.test.ts
@@ -13,6 +13,39 @@ type AnyRouteOptions = RootRouteOptions
type BeforeLoad = NonNullable
type Loader = NonNullable
+describe('redirect resolution', () => {
+ test('resolveRedirect normalizes same-origin Location to path-only', async () => {
+ const rootRoute = new BaseRootRoute({})
+ const fooRoute = new BaseRoute({
+ getParentRoute: () => rootRoute,
+ path: '/foo',
+ })
+
+ const routeTree = rootRoute.addChildren([fooRoute])
+
+ const router = new RouterCore({
+ routeTree,
+ history: createMemoryHistory({
+ initialEntries: ['https://example.com/foo'],
+ }),
+ origin: 'https://example.com',
+ })
+
+ // This redirect already includes an absolute Location header (external-ish),
+ // but still represents an internal navigation.
+ const unresolved = redirect({
+ to: '/foo',
+ headers: { Location: 'https://example.com/foo' },
+ })
+
+ const resolved = router.resolveRedirect(unresolved)
+
+ // Expect Location and stored href to be path-only (no origin).
+ expect(resolved.headers.get('Location')).toBe('/foo')
+ expect(resolved.options.href).toBe('/foo')
+ })
+})
+
describe('beforeLoad skip or exec', () => {
const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => {
const rootRoute = new BaseRootRoute({})
diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts
index b9f55f6f24..a4c79a9ca5 100644
--- a/packages/router-core/tests/utils.test.ts
+++ b/packages/router-core/tests/utils.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
decodePath,
deepEqual,
+ escapeHtml,
isPlainArray,
replaceEqualDeep,
} from '../src/utils'
@@ -664,3 +665,61 @@ describe('decodePath', () => {
})
})
})
+
+describe('escapeHtml', () => {
+ it('should escape less-than sign', () => {
+ expect(escapeHtml('<')).toBe('\\u003c')
+ })
+
+ it('should escape greater-than sign', () => {
+ expect(escapeHtml('>')).toBe('\\u003e')
+ })
+
+ it('should escape ampersand', () => {
+ expect(escapeHtml('&')).toBe('\\u0026')
+ })
+
+ it('should escape line separator (U+2028)', () => {
+ expect(escapeHtml('\u2028')).toBe('\\u2028')
+ })
+
+ it('should escape paragraph separator (U+2029)', () => {
+ expect(escapeHtml('\u2029')).toBe('\\u2029')
+ })
+
+ it('should escape multiple characters', () => {
+ expect(escapeHtml('')).toBe(
+ '\\u003cscript\\u003ealert("XSS")\\u003c/script\\u003e',
+ )
+ })
+
+ it('should handle script tag injection attempt in JSON', () => {
+ const maliciousKey = ''
+ const json = JSON.stringify({ key: maliciousKey })
+ const escaped = escapeHtml(json)
+
+ // The escaped version should not contain literal < or > characters
+ expect(escaped).not.toContain('<')
+ expect(escaped).not.toContain('>')
+
+ // The escaped version should still be valid JSON when evaluated
+ // (the escape sequences are valid in JavaScript strings)
+ expect(escaped).toContain('\\u003c')
+ expect(escaped).toContain('\\u003e')
+ })
+
+ it('should return strings without special characters unchanged', () => {
+ const safe = 'hello world 123'
+ expect(escapeHtml(safe)).toBe(safe)
+ })
+
+ it('should handle empty string', () => {
+ expect(escapeHtml('')).toBe('')
+ })
+
+ it('should handle mixed content', () => {
+ expect(escapeHtml('ac&d\u2028e\u2029f')).toBe(
+ 'a\\u003cb\\u003ec\\u0026d\\u2028e\\u2029f',
+ )
+ })
+})
diff --git a/packages/solid-router/src/HeadContent.tsx b/packages/solid-router/src/HeadContent.tsx
index 103be65c46..3232697676 100644
--- a/packages/solid-router/src/HeadContent.tsx
+++ b/packages/solid-router/src/HeadContent.tsx
@@ -1,6 +1,7 @@
import * as Solid from 'solid-js'
import { MetaProvider } from '@solidjs/meta'
import { For } from 'solid-js'
+import { escapeHtml } from '@tanstack/router-core'
import { Asset } from './Asset'
import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
@@ -33,6 +34,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 innerHTML
+ 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) {
diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx
index dc9a6c478b..133101f794 100644
--- a/packages/solid-router/src/link.tsx
+++ b/packages/solid-router/src/link.tsx
@@ -6,6 +6,7 @@ import {
deepEqual,
exactPathTest,
functionalUpdate,
+ isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
} from '@tanstack/router-core'
@@ -157,10 +158,24 @@ export function useLinkProps<
const externalLink = Solid.createMemo(() => {
const _href = hrefOption()
if (_href?.external) {
+ // Block dangerous protocols for external links
+ if (isDangerousProtocol(_href.href)) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(`Blocked Link with dangerous protocol: ${_href.href}`)
+ }
+ return undefined
+ }
return _href.href
}
try {
new URL(_options().to as any)
+ // Block dangerous protocols like javascript:, data:, vbscript:
+ if (isDangerousProtocol(_options().to as string)) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(`Blocked Link with dangerous protocol: ${_options().to}`)
+ }
+ return undefined
+ }
return _options().to
} catch {}
return undefined
diff --git a/packages/solid-router/src/scroll-restoration.tsx b/packages/solid-router/src/scroll-restoration.tsx
index 3527a2db37..8430f8182e 100644
--- a/packages/solid-router/src/scroll-restoration.tsx
+++ b/packages/solid-router/src/scroll-restoration.tsx
@@ -1,5 +1,6 @@
import {
defaultGetScrollRestorationKey,
+ escapeHtml,
restoreScroll,
storageKey,
} from '@tanstack/router-core'
@@ -37,7 +38,7 @@ export function ScrollRestoration() {
return (
)
}
diff --git a/packages/start-plugin-core/src/post-server-build.ts b/packages/start-plugin-core/src/post-server-build.ts
index 109b5a680c..478b2ef210 100644
--- a/packages/start-plugin-core/src/post-server-build.ts
+++ b/packages/start-plugin-core/src/post-server-build.ts
@@ -34,6 +34,9 @@ export async function postServerBuild({
}
const maskUrl = new URL(startConfig.spa.maskPath, 'http://localhost')
+ if (maskUrl.origin !== 'http://localhost') {
+ throw new Error('spa.maskPath must be a path (no protocol/host)')
+ }
startConfig.pages.push({
path: maskUrl.toString().replace('http://localhost', ''),
diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts
index 38b9223e18..3986873c66 100644
--- a/packages/start-plugin-core/src/prerender.ts
+++ b/packages/start-plugin-core/src/prerender.ts
@@ -40,6 +40,15 @@ export async function prerender({
startConfig.pages = pages
}
+ const routerBasePath = joinURL('/', startConfig.router.basepath ?? '')
+ const routerBaseUrl = new URL(routerBasePath, 'http://localhost')
+
+ // Enforce that prerender page paths are relative/path-based (no protocol/host)
+ startConfig.pages = validateAndNormalizePrerenderPages(
+ startConfig.pages,
+ routerBaseUrl,
+ )
+
const serverEnv = builder.environments[VITE_ENVIRONMENT_NAMES.server]
if (!serverEnv) {
@@ -126,6 +135,13 @@ export async function prerender({
const queue = new Queue({ concurrency })
const routerBasePath = joinURL('/', startConfig.router.basepath ?? '')
+ // Normalize discovered pages and enforce path-only entries
+ const routerBaseUrl = new URL(routerBasePath, 'http://localhost')
+ startConfig.pages = validateAndNormalizePrerenderPages(
+ startConfig.pages,
+ routerBaseUrl,
+ )
+
startConfig.pages.forEach((page) => addCrawlPageTask(page))
await queue.start()
@@ -294,3 +310,38 @@ function getResolvedUrl(previewServer: PreviewServer): URL {
return new URL(baseUrl)
}
+
+/**
+ * Validates and normalizes prerender page paths to ensure they are relative
+ * (no protocol/host) and returns normalized Page objects with cleaned paths.
+ * Preserves unicode characters by decoding the pathname after URL validation.
+ */
+function validateAndNormalizePrerenderPages(
+ pages: Array,
+ routerBaseUrl: URL,
+): Array {
+ return pages.map((page) => {
+ let url: URL
+ try {
+ url = new URL(page.path, routerBaseUrl)
+ } catch (err) {
+ throw new Error(`prerender page path must be relative: ${page.path}`, {
+ cause: err,
+ })
+ }
+
+ if (url.origin !== 'http://localhost') {
+ throw new Error(`prerender page path must be relative: ${page.path}`)
+ }
+
+ // Decode the pathname to preserve unicode characters (e.g., /대한민국)
+ // The URL constructor encodes non-ASCII characters, but we want to keep
+ // the original unicode form for filesystem paths
+ const decodedPathname = decodeURIComponent(url.pathname)
+
+ return {
+ ...page,
+ path: decodedPathname + url.search + url.hash,
+ }
+ })
+}
diff --git a/packages/start-plugin-core/tests/post-server-build.test.ts b/packages/start-plugin-core/tests/post-server-build.test.ts
new file mode 100644
index 0000000000..6e4db4289d
--- /dev/null
+++ b/packages/start-plugin-core/tests/post-server-build.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from 'vitest'
+import { VITE_ENVIRONMENT_NAMES } from '../src/constants'
+
+vi.mock('@tanstack/start-server-core', () => ({
+ HEADERS: {
+ TSS_SHELL: 'x-tss-shell',
+ },
+}))
+
+vi.mock('../src/prerender', () => ({
+ prerender: vi.fn(async () => {}),
+}))
+
+vi.mock('../src/build-sitemap', () => ({
+ buildSitemap: vi.fn(),
+}))
+
+describe('postServerBuild', () => {
+ const builder = {
+ environments: {
+ [VITE_ENVIRONMENT_NAMES.client]: {
+ config: { build: { outDir: '/client' } },
+ },
+ [VITE_ENVIRONMENT_NAMES.server]: {
+ config: { build: { outDir: '/server' } },
+ },
+ },
+ config: {
+ build: { outDir: '/root' },
+ },
+ } as any
+
+ it('rejects absolute SPA maskPath URLs to avoid external prerendering', async () => {
+ // Import after mocks are set up
+ const { postServerBuild } = await import('../src/post-server-build')
+
+ const startConfig = {
+ spa: {
+ enabled: true,
+ maskPath: 'https://attacker.test/collect',
+ prerender: {},
+ },
+ pages: [],
+ router: { basepath: '' },
+ serverFns: { base: '' },
+ prerender: { enabled: true },
+ sitemap: { enabled: false },
+ } as any
+
+ await expect(postServerBuild({ builder, startConfig })).rejects.toThrow(
+ /maskPath/i,
+ )
+ })
+})
diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts
new file mode 100644
index 0000000000..4a0551e8c3
--- /dev/null
+++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, it, vi } from 'vitest'
+import { prerender } from '../src/prerender'
+import { VITE_ENVIRONMENT_NAMES } from '../src/constants'
+
+vi.mock('../src/utils', async () => {
+ const actual = await vi.importActual('../src/utils')
+ return {
+ ...actual,
+ createLogger: () => ({ info: () => {}, warn: () => {}, error: () => {} }),
+ }
+})
+
+vi.mock('../src/prerender', async () => {
+ const actual = await vi.importActual('../src/prerender')
+ return {
+ ...actual,
+ }
+})
+
+// Mock vite to prevent actual server from starting
+vi.mock('vite', () => ({
+ preview: vi.fn().mockResolvedValue({
+ resolvedUrls: { local: ['http://localhost:5173/'] },
+ close: vi.fn().mockResolvedValue(undefined),
+ }),
+}))
+
+// Mock fs to prevent actual file system operations
+vi.mock('node:fs', async () => {
+ const actual = await vi.importActual('node:fs')
+ return {
+ ...actual,
+ promises: {
+ ...actual.promises,
+ mkdir: vi.fn().mockResolvedValue(undefined),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ },
+ }
+})
+
+const builder = {
+ environments: {
+ [VITE_ENVIRONMENT_NAMES.client]: {
+ config: { build: { outDir: '/client' } },
+ },
+ [VITE_ENVIRONMENT_NAMES.server]: {
+ config: { build: { outDir: '/server' } },
+ },
+ },
+} as any
+
+const fetchMock = vi.fn(
+ async () =>
+ new Response('', {
+ status: 200,
+ headers: { 'content-type': 'text/html' },
+ }),
+)
+
+vi.stubGlobal('fetch', fetchMock)
+
+function resetFetch() {
+ fetchMock.mockClear()
+}
+
+function makeStartConfig(pagePath: string) {
+ return {
+ prerender: { enabled: true, autoStaticPathsDiscovery: false },
+ pages: [{ path: pagePath }],
+ router: { basepath: '' },
+ spa: {
+ enabled: false,
+ prerender: {
+ outputPath: '/_shell',
+ crawlLinks: false,
+ retryCount: 0,
+ enabled: true,
+ },
+ },
+ } as any
+}
+
+describe('prerender pages validation', () => {
+ it('rejects absolute external page paths to avoid SSRF', async () => {
+ resetFetch()
+ const startConfig = makeStartConfig('https://attacker.test/leak')
+
+ await expect(prerender({ startConfig, builder })).rejects.toThrow(
+ /prerender page path must be relative/i,
+ )
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
+ it('allows relative paths', async () => {
+ resetFetch()
+ const startConfig = makeStartConfig('/about')
+
+ await expect(prerender({ startConfig, builder })).resolves.not.toThrow()
+ })
+})
diff --git a/packages/vue-router/src/HeadContent.tsx b/packages/vue-router/src/HeadContent.tsx
index 3aa7fc36a9..1baeef6a1f 100644
--- a/packages/vue-router/src/HeadContent.tsx
+++ b/packages/vue-router/src/HeadContent.tsx
@@ -1,5 +1,6 @@
import * as Vue from 'vue'
+import { escapeHtml } from '@tanstack/router-core'
import { Asset } from './Asset'
import { useRouter } from './useRouter'
import { useRouterState } from './useRouterState'
@@ -29,6 +30,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 innerHTML
+ 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) {
diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx
index 998aecef2a..222df589d4 100644
--- a/packages/vue-router/src/link.tsx
+++ b/packages/vue-router/src/link.tsx
@@ -2,6 +2,7 @@ import * as Vue from 'vue'
import {
deepEqual,
exactPathTest,
+ isDangerousProtocol,
preloadWarning,
removeTrailingSlash,
} from '@tanstack/router-core'
@@ -250,6 +251,39 @@ export function useLinkProps<
}
if (type.value === 'external') {
+ // Block dangerous protocols like javascript:, data:, vbscript:
+ if (isDangerousProtocol(options.to as string)) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(`Blocked Link with dangerous protocol: ${options.to}`)
+ }
+ // Return props without href to prevent navigation
+ const safeProps: Record = {
+ ...getPropsSafeToSpread(),
+ ref,
+ // No href attribute - blocks the dangerous protocol
+ target: options.target,
+ disabled: options.disabled,
+ style: options.style,
+ class: options.class,
+ onClick: options.onClick,
+ onFocus: options.onFocus,
+ onMouseEnter: options.onMouseEnter,
+ onMouseLeave: options.onMouseLeave,
+ onMouseOver: options.onMouseOver,
+ onMouseOut: options.onMouseOut,
+ onTouchStart: options.onTouchStart,
+ }
+
+ // Remove undefined values
+ Object.keys(safeProps).forEach((key) => {
+ if (safeProps[key] === undefined) {
+ delete safeProps[key]
+ }
+ })
+
+ return safeProps as LinkHTMLAttributes
+ }
+
// External links just have simple props
const externalProps: Record = {
...getPropsSafeToSpread(),
diff --git a/packages/vue-router/src/scroll-restoration.tsx b/packages/vue-router/src/scroll-restoration.tsx
index 9ce45a8b82..fc0c00da2f 100644
--- a/packages/vue-router/src/scroll-restoration.tsx
+++ b/packages/vue-router/src/scroll-restoration.tsx
@@ -1,6 +1,7 @@
import * as Vue from 'vue'
import {
defaultGetScrollRestorationKey,
+ escapeHtml,
restoreScroll,
storageKey,
} from '@tanstack/router-core'
@@ -65,7 +66,7 @@ export const ScrollRestoration = Vue.defineComponent({
if (router.isServer) {
return (
)
}