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 ( ) }