diff --git a/docs/router/framework/react/api/router/RouterOptionsType.md b/docs/router/framework/react/api/router/RouterOptionsType.md index 0bcf9f9108b..4a6a9839743 100644 --- a/docs/router/framework/react/api/router/RouterOptionsType.md +++ b/docs/router/framework/react/api/router/RouterOptionsType.md @@ -144,18 +144,14 @@ The `RouterOptions` type accepts an object with the following properties and met - When `true`, disables the global catch boundary that normally wraps all route matches. This allows unhandled errors to bubble up to top-level error handlers in the browser. - Useful for testing tools, error reporting services, and debugging scenarios. -### `protocolBlocklist` property +### `protocolAllowlist` property - Type: `Array` - Optional -- Defaults to `DEFAULT_PROTOCOL_BLOCKLIST` which includes: - - Script execution: `javascript:`, `vbscript:` - - Local file access: `file:` - - Data embedding: `blob:`, `data:` - - Browser internals: `about:` - - Platform-specific: `ms-appx:`, `ms-appx-web:`, `ms-browser-extension:`, `chrome-extension:`, `moz-extension:` - - Archive/resource: `jar:`, `view-source:`, `resource:`, `wyciwyg:` -- An array of URL protocols to block in links, redirects, and navigation. URLs with these protocols will be rejected to prevent security vulnerabilities like XSS attacks. +- Defaults to `DEFAULT_PROTOCOL_ALLOWLIST` which includes: + - Web navigation: `http:`, `https:` + - Common browser-safe actions: `mailto:`, `tel:`, `sms:` +- An array of URL protocols that are allowed in links, redirects, and navigation. Absolute URLs with protocols not in this list are rejected to prevent security vulnerabilities like XSS attacks. - The router creates a `Set` from this array internally for efficient lookup. **Example** @@ -163,19 +159,19 @@ The `RouterOptions` type accepts an object with the following properties and met ```tsx import { createRouter, - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, } from '@tanstack/react-router' -// Use a custom blocklist (replaces the default) +// Use a custom allowlist (replaces the default) const router = createRouter({ routeTree, - protocolBlocklist: ['javascript:', 'data:'], + protocolAllowlist: ['https:', 'mailto:'], }) -// Or extend the default blocklist +// Or extend the default allowlist const router = createRouter({ routeTree, - protocolBlocklist: [...DEFAULT_PROTOCOL_BLOCKLIST, 'ftp:', 'gopher:'], + protocolAllowlist: [...DEFAULT_PROTOCOL_ALLOWLIST, 'ftp:'], }) ``` diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index 6d4187799f9..455b186cebb 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -246,7 +246,7 @@ export { redirect, isRedirect, createRouterConfig, - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, } from '@tanstack/router-core' export { diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index 91ded36a506..60088cd1cb1 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -118,7 +118,7 @@ export function useLinkProps< ) { try { new URL(to) - if (isDangerousProtocol(to, router.protocolBlocklist)) { + if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } @@ -170,7 +170,7 @@ export function useLinkProps< const externalLink = (() => { if (hrefOption?.external) { - if (isDangerousProtocol(hrefOption.href, router.protocolBlocklist)) { + if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn( `Blocked Link with dangerous protocol: ${hrefOption.href}`, @@ -187,7 +187,7 @@ export function useLinkProps< if (typeof to === 'string' && to.indexOf(':') > -1) { try { new URL(to) - if (isDangerousProtocol(to, router.protocolBlocklist)) { + if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } @@ -438,7 +438,7 @@ export function useLinkProps< const externalLink = React.useMemo(() => { if (hrefOption?.external) { // Block dangerous protocols for external links - if (isDangerousProtocol(hrefOption.href, router.protocolBlocklist)) { + if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn( `Blocked Link with dangerous protocol: ${hrefOption.href}`, @@ -454,7 +454,7 @@ export function useLinkProps< try { new URL(to as any) // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to, router.protocolBlocklist)) { + if (isDangerousProtocol(to, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } @@ -463,7 +463,7 @@ export function useLinkProps< return to } catch {} return undefined - }, [to, hrefOption, router.protocolBlocklist]) + }, [to, hrefOption, router.protocolAllowlist]) // eslint-disable-next-line react-hooks/rules-of-hooks const isActive = useRouterState({ diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 59905a5224e..bb82dc5a509 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -273,7 +273,7 @@ export { createControlledPromise, isModuleNotFoundError, decodePath, - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, escapeHtml, isDangerousProtocol, buildDevStylesUrl, diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index fbc1a5ef768..d92e4089e16 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -3,7 +3,7 @@ import { createBrowserHistory, parseHref } from '@tanstack/history' import { isServer } from '@tanstack/router-core/isServer' import { batch } from './utils/batch' import { - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, createControlledPromise, decodePath, deepEqual, @@ -472,13 +472,13 @@ export interface RouterOptions< disableGlobalCatchBoundary?: boolean /** - * An array of URL protocols to block in links, redirects, and navigation. - * URLs with these protocols will be rejected to prevent security vulnerabilities. + * An array of URL protocols to allow in links, redirects, and navigation. + * Absolute URLs with protocols not in this list will be rejected. * - * @default DEFAULT_PROTOCOL_BLOCKLIST (includes javascript:, vbscript:, file:, blob:, data:, about:, and browser extension protocols) - * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#protocolblocklist-property) + * @default DEFAULT_PROTOCOL_ALLOWLIST (http:, https:, mailto:, tel:, sms:) + * @link [API Docs](https://tanstack.com/router/latest/docs/framework/react/api/router/RouterOptionsType#protocolallowlist-property) */ - protocolBlocklist?: Array + protocolAllowlist?: Array serializationAdapters?: ReadonlyArray /** @@ -971,7 +971,7 @@ export class RouterCore< resolvePathCache!: LRUCache isServer!: boolean pathParamsDecoder?: (encoded: string) => string - protocolBlocklist!: Set + protocolAllowlist!: Set /** * @deprecated Use the `createRouter` function instead @@ -995,8 +995,8 @@ export class RouterCore< notFoundMode: options.notFoundMode ?? 'fuzzy', stringifySearch: options.stringifySearch ?? defaultStringifySearch, parseSearch: options.parseSearch ?? defaultParseSearch, - protocolBlocklist: - options.protocolBlocklist ?? DEFAULT_PROTOCOL_BLOCKLIST, + protocolAllowlist: + options.protocolAllowlist ?? DEFAULT_PROTOCOL_ALLOWLIST, }) if (typeof document !== 'undefined') { @@ -1042,7 +1042,7 @@ export class RouterCore< this.isServer = this.options.isServer ?? typeof document === 'undefined' - this.protocolBlocklist = new Set(this.options.protocolBlocklist) + this.protocolAllowlist = new Set(this.options.protocolAllowlist) if (this.options.pathParamsAllowedCharacters) this.pathParamsDecoder = compileDecodeCharMap( @@ -2258,7 +2258,7 @@ export class RouterCore< // Block dangerous protocols like javascript:, blob:, data: // These could execute arbitrary code if passed to window.location - if (isDangerousProtocol(reloadHref, this.protocolBlocklist)) { + if (isDangerousProtocol(reloadHref, this.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn( `Blocked navigation to dangerous protocol: ${reloadHref}`, @@ -2681,10 +2681,10 @@ export class RouterCore< redirect.options.href && !redirect.options._builtLocation && // Check for dangerous protocols before processing the redirect - isDangerousProtocol(redirect.options.href, this.protocolBlocklist) + isDangerousProtocol(redirect.options.href, this.protocolAllowlist) ) { throw new Error( - `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Blocked protocols: ${Array.from(this.protocolBlocklist).join(', ')}.`, + `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.`, ) } diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index 8195966c1ae..9fe1fadc459 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -536,41 +536,22 @@ function decodeSegment(segment: string): string { } /** - * Default list of URL protocols to block in links, redirects, and navigation. - * These protocols can be used to execute arbitrary code, access local files, - * or interact with browser internals in ways that could be exploited. + * Default list of URL protocols to allow in links, redirects, and navigation. + * Any absolute URL protocol not in this list is treated as dangerous by default. */ -export const DEFAULT_PROTOCOL_BLOCKLIST = [ - // Script execution protocols - can run arbitrary code - 'javascript:', // Executes JavaScript in the current context (XSS vector) - 'vbscript:', // Executes VBScript in IE/legacy browsers - - // Local file access - can read sensitive files from the user's system - 'file:', // Access to local filesystem (e.g., file:///etc/passwd) - - // Data embedding protocols - can be used for XSS or data exfiltration - 'blob:', // References blob URLs, can bypass CSP in some cases - 'data:', // Inline data URLs, commonly used for XSS attacks - - // Browser internal protocols - can access browser configuration/internals - 'about:', // Browser internals (about:blank is safe, but about:config in Firefox could be targeted) - - // Platform-specific protocols - can access app resources or extensions - 'ms-appx:', // Windows UWP app local resources - 'ms-appx-web:', // Windows UWP web app resources - 'ms-browser-extension:', // Windows browser extension protocol - 'chrome-extension:', // Chrome extension protocol (could trigger extension actions) - 'moz-extension:', // Firefox extension protocol - - // Archive/resource protocols - potential path traversal or information disclosure - 'jar:', // Java archive protocol (path traversal attacks in some contexts) - 'view-source:', // Information disclosure (reveals page source code) - 'resource:', // Firefox internal resources - 'wyciwyg:', // Firefox "what you cache is what you get" protocol +export const DEFAULT_PROTOCOL_ALLOWLIST = [ + // Standard web navigation + 'http:', + 'https:', + + // Common browser-safe actions + 'mailto:', + 'tel:', + 'sms:', ] /** - * Check if a URL string uses a protocol that is in the blocklist. + * Check if a URL string uses a protocol that is not in the allowlist. * Returns true for blocked protocols like javascript:, blob:, data:, etc. * * The URL constructor correctly normalizes: @@ -581,12 +562,12 @@ export const DEFAULT_PROTOCOL_BLOCKLIST = [ * For relative URLs (no protocol), returns false (safe). * * @param url - The URL string to check - * @param blocklist - Set of protocols to block - * @returns true if the URL uses a blocked protocol + * @param allowlist - Set of protocols to allow + * @returns true if the URL uses a protocol that is not allowed */ export function isDangerousProtocol( url: string, - blocklist: Set, + allowlist: Set, ): boolean { if (!url) return false @@ -594,7 +575,7 @@ export function isDangerousProtocol( // Use the URL constructor - it correctly normalizes protocols // per WHATWG URL spec, handling all bypass attempts automatically const parsed = new URL(url) - return blocklist.has(parsed.protocol) + return !allowlist.has(parsed.protocol) } catch { // URL constructor throws for relative URLs (no protocol) // These are safe - they can't execute scripts diff --git a/packages/router-core/tests/dangerous-protocols.test.ts b/packages/router-core/tests/dangerous-protocols.test.ts index fdb472bd625..df22c47ba03 100644 --- a/packages/router-core/tests/dangerous-protocols.test.ts +++ b/packages/router-core/tests/dangerous-protocols.test.ts @@ -1,467 +1,211 @@ import { describe, expect, it } from 'vitest' -import { isDangerousProtocol, DEFAULT_PROTOCOL_BLOCKLIST } from '../src/utils' +import { DEFAULT_PROTOCOL_ALLOWLIST, isDangerousProtocol } from '../src/utils' import { redirect } from '../src/redirect' -// Create a Set from the default blocklist for testing -const defaultBlocklistSet = new Set(DEFAULT_PROTOCOL_BLOCKLIST) +const defaultAllowlistSet = new Set(DEFAULT_PROTOCOL_ALLOWLIST) describe('isDangerousProtocol', () => { - describe('blocked protocols (in default blocklist)', () => { + describe('blocked protocols (not in default allowlist)', () => { it('should detect javascript: protocol', () => { expect( - isDangerousProtocol('javascript:alert(1)', defaultBlocklistSet), + isDangerousProtocol('javascript:alert(1)', defaultAllowlistSet), ).toBe(true) }) - it('should detect javascript: with newlines', () => { + it('should detect javascript: with mixed case and whitespace', () => { expect( - isDangerousProtocol('java\nscript:alert(1)', defaultBlocklistSet), + isDangerousProtocol('JavaScript:alert(1)', defaultAllowlistSet), ).toBe(true) expect( - isDangerousProtocol('java\rscript:alert(1)', defaultBlocklistSet), + isDangerousProtocol(' \t\n javascript:alert(1)', defaultAllowlistSet), ).toBe(true) expect( - isDangerousProtocol('java\tscript:alert(1)', defaultBlocklistSet), + isDangerousProtocol('java\nscript:alert(1)', defaultAllowlistSet), ).toBe(true) }) - it('should detect javascript: with mixed case', () => { - expect( - isDangerousProtocol('JavaScript:alert(1)', defaultBlocklistSet), - ).toBe(true) - expect( - isDangerousProtocol('JAVASCRIPT:alert(1)', defaultBlocklistSet), - ).toBe(true) - expect( - isDangerousProtocol('jAvAsCrIpT:alert(1)', defaultBlocklistSet), - ).toBe(true) - }) - - it('should detect javascript: with leading whitespace', () => { - expect( - isDangerousProtocol(' javascript:alert(1)', defaultBlocklistSet), - ).toBe(true) - expect( - isDangerousProtocol('\tjavascript:alert(1)', defaultBlocklistSet), - ).toBe(true) - expect( - isDangerousProtocol('\njavascript:alert(1)', defaultBlocklistSet), - ).toBe(true) - }) - - it('should detect data: protocol', () => { + it('should detect known unsafe schemes', () => { expect( isDangerousProtocol( 'data:text/html,', - defaultBlocklistSet, + defaultAllowlistSet, ), ).toBe(true) - }) - - it('should detect blob: protocol', () => { expect( isDangerousProtocol( 'blob:https://example.com/some-uuid', - defaultBlocklistSet, + defaultAllowlistSet, ), ).toBe(true) - }) - - it('should detect vbscript: protocol', () => { expect( - isDangerousProtocol('vbscript:msgbox(1)', defaultBlocklistSet), + isDangerousProtocol('vbscript:msgbox(1)', defaultAllowlistSet), ).toBe(true) - }) - - it('should detect file: protocol', () => { expect( - isDangerousProtocol('file:///etc/passwd', defaultBlocklistSet), + isDangerousProtocol('file:///etc/passwd', defaultAllowlistSet), ).toBe(true) + expect(isDangerousProtocol('about:blank', defaultAllowlistSet)).toBe(true) }) - it('should detect about: protocol', () => { - expect(isDangerousProtocol('about:blank', defaultBlocklistSet)).toBe(true) - }) - - it('should detect chrome-extension: protocol', () => { - expect( - isDangerousProtocol( - 'chrome-extension://abc/page.html', - defaultBlocklistSet, - ), - ).toBe(true) - }) - - it('should detect moz-extension: protocol', () => { - expect( - isDangerousProtocol( - 'moz-extension://abc/page.html', - defaultBlocklistSet, - ), - ).toBe(true) - }) - - it('should detect ms-browser-extension: protocol', () => { - expect( - isDangerousProtocol( - 'ms-browser-extension://something', - defaultBlocklistSet, - ), - ).toBe(true) - }) - - it('should detect view-source: protocol', () => { - expect( - isDangerousProtocol( - 'view-source:https://example.com', - defaultBlocklistSet, - ), - ).toBe(true) + it('should block custom protocols by default', () => { + expect(isDangerousProtocol('custom:something', defaultAllowlistSet)).toBe( + true, + ) + expect(isDangerousProtocol('foo:bar', defaultAllowlistSet)).toBe(true) }) }) - describe('allowed protocols (not in default blocklist)', () => { - it('should allow http: protocol', () => { + describe('allowed protocols (in default allowlist)', () => { + it('should allow http and https', () => { expect( - isDangerousProtocol('http://example.com', defaultBlocklistSet), + isDangerousProtocol('http://example.com', defaultAllowlistSet), ).toBe(false) - }) - - it('should allow https: protocol', () => { expect( - isDangerousProtocol('https://example.com', defaultBlocklistSet), + isDangerousProtocol('https://example.com', defaultAllowlistSet), ).toBe(false) }) - it('should allow mailto: protocol', () => { + it('should allow mailto, tel and sms', () => { expect( - isDangerousProtocol('mailto:user@example.com', defaultBlocklistSet), + isDangerousProtocol('mailto:user@example.com', defaultAllowlistSet), ).toBe(false) - }) - - it('should allow tel: protocol', () => { - expect(isDangerousProtocol('tel:+1234567890', defaultBlocklistSet)).toBe( + expect(isDangerousProtocol('tel:+1234567890', defaultAllowlistSet)).toBe( false, ) - }) - - it('should allow custom protocols (not in default blocklist)', () => { - expect(isDangerousProtocol('custom:something', defaultBlocklistSet)).toBe( + expect(isDangerousProtocol('sms:+1234567890', defaultAllowlistSet)).toBe( false, ) - expect(isDangerousProtocol('foo:bar', defaultBlocklistSet)).toBe(false) }) }) describe('relative URLs (no protocol)', () => { - it('should allow relative paths', () => { - expect(isDangerousProtocol('/path/to/page', defaultBlocklistSet)).toBe( + it('should allow relative paths, query strings and hash fragments', () => { + expect(isDangerousProtocol('/path/to/page', defaultAllowlistSet)).toBe( false, ) - expect(isDangerousProtocol('./relative', defaultBlocklistSet)).toBe(false) - expect(isDangerousProtocol('../parent', defaultBlocklistSet)).toBe(false) - }) - - it('should allow query strings', () => { - expect(isDangerousProtocol('?foo=bar', defaultBlocklistSet)).toBe(false) - }) - - it('should allow hash fragments', () => { - expect(isDangerousProtocol('#section', defaultBlocklistSet)).toBe(false) + expect(isDangerousProtocol('./relative', defaultAllowlistSet)).toBe(false) + expect(isDangerousProtocol('../parent', defaultAllowlistSet)).toBe(false) + expect(isDangerousProtocol('?foo=bar', defaultAllowlistSet)).toBe(false) + expect(isDangerousProtocol('#section', defaultAllowlistSet)).toBe(false) }) }) describe('edge cases', () => { it('should handle empty and null-ish inputs', () => { - expect(isDangerousProtocol('', defaultBlocklistSet)).toBe(false) + expect(isDangerousProtocol('', defaultAllowlistSet)).toBe(false) + expect( + isDangerousProtocol(null as unknown as string, defaultAllowlistSet), + ).toBe(false) + expect( + isDangerousProtocol( + undefined as unknown as string, + defaultAllowlistSet, + ), + ).toBe(false) }) - it('should not be fooled by javascript in pathname', () => { + it('should not be fooled by javascript in pathname or query', () => { expect( isDangerousProtocol( 'https://example.com/javascript:foo', - defaultBlocklistSet, + defaultAllowlistSet, ), ).toBe(false) - expect(isDangerousProtocol('/javascript:foo', defaultBlocklistSet)).toBe( + expect(isDangerousProtocol('/javascript:foo', defaultAllowlistSet)).toBe( false, ) - }) - - it('should not be fooled by colon in query string', () => { - expect(isDangerousProtocol('/path?time=12:00', defaultBlocklistSet)).toBe( + expect(isDangerousProtocol('/path?time=12:00', defaultAllowlistSet)).toBe( false, ) }) - }) - - describe('additional edge cases', () => { - describe('null and undefined inputs', () => { - it('should return false for null', () => { - expect( - isDangerousProtocol(null as unknown as string, defaultBlocklistSet), - ).toBe(false) - }) - - it('should return false for undefined', () => { - expect( - isDangerousProtocol( - undefined as unknown as string, - defaultBlocklistSet, - ), - ).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)', - defaultBlocklistSet, - ), - ).toBe(false) - }) - - it('should return false for partially URL-encoded javascript: protocol', () => { - // URL constructor throws for these malformed URLs - expect( - isDangerousProtocol('%6aavascript:alert(1)', defaultBlocklistSet), - ).toBe(false) - expect( - isDangerousProtocol('j%61vascript:alert(1)', defaultBlocklistSet), - ).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,', - defaultBlocklistSet, - ), - ).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)', - defaultBlocklistSet, - ), - ).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', - defaultBlocklistSet, - ), - ).toBe(false) - }) - }) - describe('protocol-relative URLs', () => { - it('should return false for protocol-relative URLs', () => { - expect(isDangerousProtocol('//example.com', defaultBlocklistSet)).toBe( - false, - ) - }) - - it('should return false for protocol-relative URLs with paths', () => { - expect( - isDangerousProtocol( - '//example.com/path/to/page', - defaultBlocklistSet, - ), - ).toBe(false) - }) - - it('should return false for protocol-relative URLs with query strings', () => { - expect( - isDangerousProtocol('//example.com?foo=bar', defaultBlocklistSet), - ).toBe(false) - }) - - it('should return false for protocol-relative URLs with hash', () => { - expect( - isDangerousProtocol('//example.com#section', defaultBlocklistSet), - ).toBe(false) - }) - }) - - describe('malformed inputs', () => { - it('should return false for strings without valid protocol pattern', () => { - expect( - isDangerousProtocol('not a url at all', defaultBlocklistSet), - ).toBe(false) - }) - - it('should return false for strings with only colons', () => { - expect(isDangerousProtocol(':::', defaultBlocklistSet)).toBe(false) - }) - - it('should return false for strings starting with numbers', () => { - expect(isDangerousProtocol('123:456', defaultBlocklistSet)).toBe(false) - }) - - it('should handle strings with non-printable characters', () => { - expect( - isDangerousProtocol('\x00javascript:alert(1)', defaultBlocklistSet), - ).toBe(true) - expect( - isDangerousProtocol( - '\x01\x02\x03javascript:alert(1)', - defaultBlocklistSet, - ), - ).toBe(true) - }) - - it('should return false for very long benign paths', () => { - const longPath = '/' + 'a'.repeat(10000) - expect(isDangerousProtocol(longPath, defaultBlocklistSet)).toBe(false) - }) - - it('should return false for very long query strings', () => { - const longQuery = '/path?' + 'a=b&'.repeat(1000) - expect(isDangerousProtocol(longQuery, defaultBlocklistSet)).toBe(false) - }) - - it('should detect dangerous protocol even with long payload', () => { - const longPayload = 'javascript:' + 'a'.repeat(10000) - expect(isDangerousProtocol(longPayload, defaultBlocklistSet)).toBe(true) - }) - - it('should handle unicode characters in URLs', () => { - expect( - isDangerousProtocol('/путь/к/странице', defaultBlocklistSet), - ).toBe(false) - expect( - isDangerousProtocol('https://例え.jp/path', defaultBlocklistSet), - ).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)', - defaultBlocklistSet, - ), - ).toBe(false) - }) + it('should return false for malformed/encoded scheme strings that URL rejects', () => { + expect( + isDangerousProtocol( + '%6a%61%76%61%73%63%72%69%70%74:alert(1)', + defaultAllowlistSet, + ), + ).toBe(false) + expect(isDangerousProtocol(':::', defaultAllowlistSet)).toBe(false) + expect(isDangerousProtocol('123:456', defaultAllowlistSet)).toBe(false) + expect(isDangerousProtocol('//example.com', defaultAllowlistSet)).toBe( + false, + ) }) - describe('whitespace variations', () => { - it('should detect javascript: with various whitespace combinations', () => { - expect( - isDangerousProtocol( - ' \t\n javascript:alert(1)', - defaultBlocklistSet, - ), - ).toBe(true) - expect( - isDangerousProtocol('\r\njavascript:alert(1)', defaultBlocklistSet), - ).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)', defaultBlocklistSet), - ).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)', defaultBlocklistSet), - ).toBe(false) - }) + it('should detect dangerous protocol with leading control characters', () => { + expect( + isDangerousProtocol('\x00javascript:alert(1)', defaultAllowlistSet), + ).toBe(true) + expect( + isDangerousProtocol( + '\x01\x02\x03javascript:alert(1)', + defaultAllowlistSet, + ), + ).toBe(true) }) }) - describe('custom blocklist', () => { - it('should use custom blocklist when provided', () => { - const customBlocklist = new Set(['ftp:', 'ssh:']) - // Should block ftp: and ssh: - expect(isDangerousProtocol('ftp://example.com', customBlocklist)).toBe( - true, + describe('custom allowlist', () => { + it('should use custom allowlist when provided', () => { + const customAllowlist = new Set(['ftp:', 'ssh:']) + + expect(isDangerousProtocol('ftp://example.com', customAllowlist)).toBe( + false, ) - expect(isDangerousProtocol('ssh://example.com', customBlocklist)).toBe( + expect(isDangerousProtocol('ssh://example.com', customAllowlist)).toBe( + false, + ) + expect(isDangerousProtocol('javascript:alert(1)', customAllowlist)).toBe( true, ) - // Should allow javascript: since it's not in the custom blocklist - expect(isDangerousProtocol('javascript:alert(1)', customBlocklist)).toBe( - false, + expect(isDangerousProtocol('https://example.com', customAllowlist)).toBe( + true, ) }) - it('should allow empty blocklist', () => { - const emptyBlocklist = new Set() - expect(isDangerousProtocol('javascript:alert(1)', emptyBlocklist)).toBe( - false, + it('should block absolute URLs with an empty allowlist', () => { + const emptyAllowlist = new Set() + expect(isDangerousProtocol('javascript:alert(1)', emptyAllowlist)).toBe( + true, ) - expect(isDangerousProtocol('data:text/html,test', emptyBlocklist)).toBe( - false, + expect(isDangerousProtocol('data:text/html,test', emptyAllowlist)).toBe( + true, + ) + expect(isDangerousProtocol('https://example.com', emptyAllowlist)).toBe( + true, ) }) - it('should allow extending the default blocklist', () => { - const extendedBlocklist = new Set([ - ...DEFAULT_PROTOCOL_BLOCKLIST, + it('should allow extending the default allowlist', () => { + const extendedAllowlist = new Set([ + ...DEFAULT_PROTOCOL_ALLOWLIST, 'ftp:', 'gopher:', ]) + expect( - isDangerousProtocol('javascript:alert(1)', extendedBlocklist), + isDangerousProtocol('javascript:alert(1)', extendedAllowlist), ).toBe(true) - expect(isDangerousProtocol('ftp://example.com', extendedBlocklist)).toBe( - true, + expect(isDangerousProtocol('ftp://example.com', extendedAllowlist)).toBe( + false, ) expect( - isDangerousProtocol('gopher://example.com', extendedBlocklist), - ).toBe(true) + isDangerousProtocol('gopher://example.com', extendedAllowlist), + ).toBe(false) expect( - isDangerousProtocol('https://example.com', extendedBlocklist), + isDangerousProtocol('https://example.com', extendedAllowlist), ).toBe(false) }) }) - describe('DEFAULT_PROTOCOL_BLOCKLIST', () => { + describe('DEFAULT_PROTOCOL_ALLOWLIST', () => { it('should contain the expected default protocols', () => { - expect(DEFAULT_PROTOCOL_BLOCKLIST).toEqual([ - // Script execution protocols - 'javascript:', - 'vbscript:', - // Local file access - 'file:', - // Data embedding protocols - 'blob:', - 'data:', - // Browser internal protocols - 'about:', - // Platform-specific protocols - 'ms-appx:', - 'ms-appx-web:', - 'ms-browser-extension:', - 'chrome-extension:', - 'moz-extension:', - // Archive/resource protocols - 'jar:', - 'view-source:', - 'resource:', - 'wyciwyg:', + expect(DEFAULT_PROTOCOL_ALLOWLIST).toEqual([ + 'http:', + 'https:', + 'mailto:', + 'tel:', + 'sms:', ]) }) }) @@ -469,7 +213,6 @@ describe('isDangerousProtocol', () => { describe('redirect creation (no protocol validation)', () => { it('should allow creating redirect with javascript: protocol', () => { - // redirect() no longer validates protocols - that happens in resolveRedirect expect(() => redirect({ href: 'javascript:alert(1)' })).not.toThrow() }) diff --git a/packages/solid-router/src/index.tsx b/packages/solid-router/src/index.tsx index 1e665192a3b..14e45f04ea0 100644 --- a/packages/solid-router/src/index.tsx +++ b/packages/solid-router/src/index.tsx @@ -253,7 +253,7 @@ export { useLoaderData } from './useLoaderData' export { redirect, isRedirect, - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, } from '@tanstack/router-core' export { diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index b52b05bb6bd..4f08b6b8f0e 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -157,7 +157,7 @@ export function useLinkProps< const _href = hrefOption() if (_href?.external) { // Block dangerous protocols for external links - if (isDangerousProtocol(_href.href, router.protocolBlocklist)) { + if (isDangerousProtocol(_href.href, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${_href.href}`) } @@ -174,7 +174,7 @@ export function useLinkProps< try { new URL(to as any) // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to as string, router.protocolBlocklist)) { + if (isDangerousProtocol(to as string, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } diff --git a/packages/vue-router/src/index.tsx b/packages/vue-router/src/index.tsx index acb27608155..ebeda741509 100644 --- a/packages/vue-router/src/index.tsx +++ b/packages/vue-router/src/index.tsx @@ -246,7 +246,7 @@ export { redirect, isRedirect, createRouterConfig, - DEFAULT_PROTOCOL_BLOCKLIST, + DEFAULT_PROTOCOL_ALLOWLIST, } from '@tanstack/router-core' export { diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 63a6512296c..d6ed9b1ca16 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -252,7 +252,7 @@ export function useLinkProps< if (type.value === 'external') { // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(options.to as string, router.protocolBlocklist)) { + if (isDangerousProtocol(options.to as string, router.protocolAllowlist)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${options.to}`) }