From 7769c5902cc244206adb2a553c2a5388d45b3570 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 9 Sep 2026 20:02:59 +0200 Subject: [PATCH 1/3] fix: harden URL handling across Router and Start --- .changeset/gentle-nights-bet.md | 19 + .prettierignore | 5 +- docs/router/api/router/RouterOptionsType.md | 7 + packages/history/src/index.ts | 69 +- .../tests/createBrowserHistory.test.ts | 58 +- .../history/tests/createHashHistory.test.ts | 36 +- packages/history/tests/parseHref.test.ts | 52 +- packages/react-router/src/link.tsx | 346 +- .../react-router/tests/link-events.test.tsx | 2 +- .../tests/link-href-safety.test.tsx | 6 + .../tests/link-state-props.test.tsx | 302 +- packages/react-router/tests/link.test.tsx | 372 +- packages/router-core/src/index.ts | 2 +- packages/router-core/src/load-client.ts | 48 +- packages/router-core/src/redirect.ts | 18 +- packages/router-core/src/router.ts | 145 +- packages/router-core/src/ssr/ssr-server.ts | 9 +- packages/router-core/src/utils.ts | 119 +- .../tests/dangerous-protocols.test.ts | 456 +- .../tests/fixtures/wpt-url/LICENSE.md | 11 + .../tests/fixtures/wpt-url/README.md | 46 + .../wpt-url/urltestdata-javascript-only.json | 18 + .../tests/fixtures/wpt-url/urltestdata.json | 10643 ++++++++++++++++ .../tests/history-normalization.test.ts | 61 +- packages/router-core/tests/load.test.ts | 217 + .../public-preload-lane-contract.test.ts | 34 + .../tests/redirect-resolution.test.ts | 75 +- .../tests/redirect-target-error.test.ts | 96 +- .../router-core/tests/url-standard.test.ts | 96 + packages/router-core/tests/utils.test.ts | 72 +- packages/solid-router/src/link.tsx | 180 +- .../solid-router/tests/link-href-cases.ts | 9 + packages/solid-router/tests/link.test.tsx | 262 + .../solid-router/tests/server/link.test.tsx | 74 + packages/start-plugin-core/src/prerender.ts | 112 +- .../start-plugin-core/src/vite/prerender.ts | 6 + .../tests/prerender-ssrf.test.ts | 108 +- .../tests/prerender-vite.test.ts | 125 + .../src/createStartHandler.ts | 65 +- .../tests/createStartHandler.test.ts | 274 +- packages/vue-router/src/link.tsx | 143 +- .../tests/link-href-safety.test.tsx | 14 +- .../tests/link-location-transition.test.tsx | 87 + packages/vue-router/tests/link.test.tsx | 170 +- 44 files changed, 14386 insertions(+), 683 deletions(-) create mode 100644 .changeset/gentle-nights-bet.md create mode 100644 packages/router-core/tests/fixtures/wpt-url/LICENSE.md create mode 100644 packages/router-core/tests/fixtures/wpt-url/README.md create mode 100644 packages/router-core/tests/fixtures/wpt-url/urltestdata-javascript-only.json create mode 100644 packages/router-core/tests/fixtures/wpt-url/urltestdata.json create mode 100644 packages/router-core/tests/url-standard.test.ts create mode 100644 packages/start-plugin-core/tests/prerender-vite.test.ts create mode 100644 packages/vue-router/tests/link-location-transition.test.tsx diff --git a/.changeset/gentle-nights-bet.md b/.changeset/gentle-nights-bet.md new file mode 100644 index 00000000000..d2185a62473 --- /dev/null +++ b/.changeset/gentle-nights-bet.md @@ -0,0 +1,19 @@ +--- +'@tanstack/history': patch +'@tanstack/router-core': patch +'@tanstack/react-router': patch +'@tanstack/solid-router': patch +'@tanstack/vue-router': patch +'@tanstack/start-plugin-core': patch +'@tanstack/start-server-core': patch +--- + +Validate navigation and redirect destinations, keep ambiguous relative URLs on the current origin, and constrain prerender requests and output paths. Prevent redirect headers from appearing in serialized server function response bodies. + +Preserve native form HTTP redirects, route error handling and masks for document redirects, and per-navigation destinations for shared loader redirects. Avoid redundant origin parsing and reduce link styling and server-rendering work. Configured origins must already be normalized. + +Keep blocked-link inactive props consistent during React hydration, honor explicit redirect Location headers before checking route options, and refresh Vue link state when destinations become internal. Reuse the protocol-relative URL check while parsing redirect schemes once. + +Reduce React link bundle size by sharing pathname comparisons, state-prop selection, and element creation. + +Share normalized pathname comparisons in Solid and Vue links to reduce bundle size. diff --git a/.prettierignore b/.prettierignore index 65991e19640..de501452adc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,4 +17,7 @@ node_modules /.nx/workspace-data **/src/routeTree.gen.ts packages/router-plugin/tests/**/test-files/** -.nx/self-healing \ No newline at end of file +.nx/self-healing + +# Preserve the pinned upstream URL fixtures byte for byte. +packages/router-core/tests/fixtures/wpt-url/urltestdata*.json diff --git a/docs/router/api/router/RouterOptionsType.md b/docs/router/api/router/RouterOptionsType.md index 7a52e622413..3556f4ab240 100644 --- a/docs/router/api/router/RouterOptionsType.md +++ b/docs/router/api/router/RouterOptionsType.md @@ -223,6 +223,13 @@ const router = createRouter({ - Defaults to `/` - The basepath for the entire router. This is useful for mounting a router instance at a subpath. +### `origin` property + +- Type: `string` +- Optional +- The origin used to resolve URLs. Defaults to the browser origin, or `http://localhost` on the server and in browsers with an opaque origin. +- Pass a normalized origin, such as `https://example.com` or `http://localhost:3000`, without a path or trailing slash. The router uses this value as provided; if you have a full URL, normalize it with `new URL(url).origin` before passing it to the router. + ### `rewrite` property - Type: `LocationRewrite` diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 8a246634bc0..513e694af48 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -38,6 +38,7 @@ export interface RouterHistory { notify: (action: SubscriberHistoryAction) => void _getBlockers: () => Array _ignoreSubscribers?: boolean + _ignoreNextBeforeUnload?: () => void } export interface HistoryLocation extends ParsedPath { @@ -97,6 +98,34 @@ const stateIndexKey = '__TSR_index' const popStateEvent = 'popstate' const beforeUnloadEvent = 'beforeunload' +/** + * Turn protocol-relative inputs such as "//evil.example" into paths + * such as "/evil.example", keeping navigation on the current origin. + * + * For HTTP(S) URLs, WHATWG parsing ignores leading C0 controls and spaces, + * removes tabs/newlines, and treats backslashes as slashes, so inputs like + * "/\evil.example" also need normalization. This only allocates when those + * rules would make the input protocol-relative. + */ +// eslint-disable-next-line no-control-regex +const protocolRelativePrefix = /^[\x00-\x20]*(?:[\\/][\t\n\r]*){2,}/ + +export function normalizeProtocolRelative(url: string): string { + const match = protocolRelativePrefix.exec(url) + return match ? '/' + url.slice(match[0].length) : url +} + +function normalizeHref(href: string): string { + // eslint-disable-next-line no-control-regex + if (/[\x00-\x1f\x7f]/.test(href)) { + // eslint-disable-next-line no-control-regex + href = href.replace(/[\x00-\x1f\x7f]/g, (character) => + '\t\n\r'.includes(character) ? '' : encodeURIComponent(character), + ) + } + return normalizeProtocolRelative(href) +} + export function createHistory(opts: { getLocation: () => HistoryLocation getLength: () => number @@ -297,7 +326,8 @@ export function createBrowserHistory(opts?: { const _setBlockers = (newBlockers: Array) => (blockers = newBlockers) - const createHref = opts?.createHref ?? ((path) => path) + const createHref = (path: string) => + normalizeHref(opts?.createHref ? opts.createHref(path) : path) const parseLocation = opts?.parseLocation ?? (() => @@ -373,18 +403,24 @@ export function createBrowserHistory(opts?: { destHref: string, state: any, ) => { - const href = createHref(destHref) + // A formatter changes the URL space and must receive the original input. + // Otherwise parseHref below already produces the browser destination. + const href = opts?.createHref ? createHref(destHref) : undefined const hasPendingAction = !!next if (!hasPendingAction) { rollbackLocation = currentLocation } - // Update the location in memory + // Keep the optimistic location in the router's logical URL space. currentLocation = parseHref(destHref, state) // Keep track of the next location we need to flush to the URL - next = [href, state, next?.[2 /* is push */] || isPush] + next = [ + href ?? currentLocation.href, + state, + next?.[2 /* is push */] || isPush, + ] if (!hasPendingAction) { // Schedule an update to the browser history @@ -535,6 +571,10 @@ export function createBrowserHistory(opts?: { notifyOnIndexChange: false, }) + history._ignoreNextBeforeUnload = () => { + ignoreNextBeforeUnload = true + } + win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true }) win.addEventListener(popStateEvent, onPushPopEvent) @@ -639,30 +679,11 @@ export function createMemoryHistory( }) } -/** - * Sanitize a path to prevent open redirect vulnerabilities. - * Removes control characters and collapses leading double slashes. - */ -function sanitizePath(path: string): string { - // Remove ASCII control characters (0x00-0x1F) and DEL (0x7F) - // These include CR (\r = 0x0D), LF (\n = 0x0A), and other potentially dangerous characters - // eslint-disable-next-line no-control-regex - let sanitized = path.replace(/[\x00-\x1f\x7f]/g, '') - - // Prevent open redirect via protocol-relative URLs (e.g. "//evil.com") - // Collapse leading double slashes to a single slash - if (sanitized.startsWith('//')) { - sanitized = '/' + sanitized.replace(/^\/+/, '') - } - - return sanitized -} - export function parseHref( href: string, state: ParsedHistoryState | undefined, ): HistoryLocation { - const sanitizedHref = sanitizePath(href) + const sanitizedHref = normalizeHref(href) const hashIndex = sanitizedHref.indexOf('#') const searchIndex = sanitizedHref.indexOf('?') diff --git a/packages/history/tests/createBrowserHistory.test.ts b/packages/history/tests/createBrowserHistory.test.ts index 46e7a1faea3..5b24744fb52 100644 --- a/packages/history/tests/createBrowserHistory.test.ts +++ b/packages/history/tests/createBrowserHistory.test.ts @@ -57,7 +57,7 @@ describe('createBrowserHistory', () => { history.destroy() }) - test.each(['/a\tb?q=a\nb#/\\section\r'])( + test.each(['/a\x00b?q=a\x01b#/\\section\x7f', '/a\tb?q=a\nb#/\\section\r'])( 'preserves the browser interpretation of %j', (href) => { const originalHref = window.location.href @@ -150,6 +150,36 @@ describe('createBrowserHistory', () => { history.destroy() }) + test.each([ + '//evil.com/path', + '///evil.com/path', + '/\\evil.com/path', + '/\\\\evil.com/path', + '/\\/evil.com/path', + '\\/evil.com/path', + '\\\\evil.com/path', + ' /\\evil.com/path', + '\x01//evil.com/path', + '/\t/evil.com/path', + ])('sanitizes %j before calling the native History API', async (href) => { + const { history, pushState } = createBrowserHistoryHarness() + + history.push(href) + await Promise.resolve() + + expect(pushState).toHaveBeenCalledOnce() + const pushedHref = pushState.mock.calls[0]![2] + const serializedUrl = new URL(pushedHref, 'https://victim.example') + expect(serializedUrl.origin).toBe('https://victim.example') + expect(serializedUrl.pathname).toBe( + href === '\x01//evil.com/path' ? '/%01//evil.com/path' : '/evil.com/path', + ) + expect( + new URL(history.location.href, 'https://victim.example').origin, + ).toBe('https://victim.example') + history.destroy() + }) + test('does not exempt a normal traversal from beforeunload blockers', () => { const { history, window } = createBrowserHistoryHarness() history.block({ blockerFn: vi.fn(), enableBeforeUnload: true }) @@ -208,6 +238,32 @@ describe('createBrowserHistory', () => { history.destroy() }) + test('normalizes the final result of a custom createHref', async () => { + const { history, pushState, replaceState } = createBrowserHistoryHarness( + () => ' \t/\\evil.example/path', + ) + + expect(history.createHref('/safe')).toBe('/evil.example/path') + + history.push('/safe') + await Promise.resolve() + history.replace('/safe') + await Promise.resolve() + + expect(pushState).toHaveBeenCalledWith( + expect.anything(), + '', + '/evil.example/path', + ) + expect(replaceState).toHaveBeenCalledWith( + expect.anything(), + '', + '/evil.example/path', + ) + expect(history.location.href).toBe('/safe') + history.destroy() + }) + test('keeps the optimistic location in the logical URL space', async () => { const { history, pushState } = createBrowserHistoryHarness( () => '/mapped/../browser-destination', diff --git a/packages/history/tests/createHashHistory.test.ts b/packages/history/tests/createHashHistory.test.ts index e77b13ab87d..621cefe1568 100644 --- a/packages/history/tests/createHashHistory.test.ts +++ b/packages/history/tests/createHashHistory.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test, vi } from 'vitest' import { createHashHistory } from '../src' describe('createHashHistory', () => { - test.each(['/route\x01'])( + test.each(['\x00/route', '/route\x01', '/route?q=a\x1fb#c\x7fd'])( 'keeps logical control data stable after reading the browser URL again: %j', (href) => { const originalHref = window.location.href @@ -25,6 +25,40 @@ describe('createHashHistory', () => { }, ) + test('normalizes the final browser href without changing the logical hash location', async () => { + const pushState = vi.fn() + const window = { + location: { + pathname: '//nested/path', + search: '', + hash: '', + }, + history: { + state: { __TSR_index: 0, __TSR_key: 'initial' }, + length: 1, + pushState, + replaceState: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + go: vi.fn(), + }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + } + const history = createHashHistory({ window }) + + history.push('/logical') + await Promise.resolve() + + expect(pushState).toHaveBeenCalledWith( + expect.anything(), + '', + '/nested/path#/logical', + ) + expect(history.location.href).toBe('/logical') + history.destroy() + }) + describe('parseLocation', () => { describe.each([ ['/', { pathname: '/', search: '' }, 'neither search params nor hash'], diff --git a/packages/history/tests/parseHref.test.ts b/packages/history/tests/parseHref.test.ts index ce39d78013e..e1a95fafa45 100644 --- a/packages/history/tests/parseHref.test.ts +++ b/packages/history/tests/parseHref.test.ts @@ -4,7 +4,7 @@ import { parseHref } from '../src' const baseUrl = new URL('https://victim.example/base') describe('parseHref', () => { - test.each(['\t', '\n', '\r'])( + test.each(['\x00', '\x01', '\x1f', '\x7f', '\t', '\n', '\r'])( 'preserves browser path, query, and fragment interpretation for %j', (character) => { const href = `/a${character}b?q=a${character}b#a${character}b` @@ -60,9 +60,15 @@ describe('parseHref', () => { test.each([ '//evil.com/path', '///evil.com/path', + '/\\evil.com/path', + '/\\\\evil.com/path', + '/\\/evil.com/path', + '\\/evil.com/path', + '\\\\evil.com/path', + ' /\\evil.com/path', '\x01//evil.com/path', '/\t/evil.com/path', - ])('keeps authority-like path %j on the current origin', (href) => { + ])('keeps protocol-relative input %j on the current origin', (href) => { const parsed = parseHref(href, undefined) const url = new URL(parsed.href, 'https://victim.example') @@ -70,6 +76,8 @@ describe('parseHref', () => { }) test.each([ + 'h\x00ttps://evil.example', + 'java\x00script:alert(1)', '/\x00/evil.example', '\x7f//evil.example', '/%5c/evil.example', @@ -89,6 +97,46 @@ describe('parseHref', () => { expect(new URL(parsed.href, baseUrl).origin).toBe(baseUrl.origin) }) + test('matches WHATWG parsing of protocol-relative prefixes', () => { + const alphabet = [ + '/', + '\\', + '\t', + '\n', + '\r', + '\x00', + '\x0b', + ' ', + '\x7f', + ] + + for (let length = 0; length <= 4; length++) { + const count = alphabet.length ** length + for (let value = 0; value < count; value++) { + let input = '' + let cursor = value + for (let index = 0; index < length; index++) { + input += alphabet[cursor % alphabet.length] + cursor = Math.floor(cursor / alphabet.length) + } + input += 'evil.example/path' + + try { + new URL(input, baseUrl) + } catch { + continue + } + + const parsed = parseHref(input, undefined) + + expect( + new URL(parsed.href, baseUrl).origin, + JSON.stringify(input), + ).toBe(baseUrl.origin) + } + } + }) + test('normal paths remain unchanged', () => { const parsed = parseHref('/users/profile?id=1#section', undefined) expect(parsed.href).toBe('/users/profile?id=1#section') diff --git a/packages/react-router/src/link.tsx b/packages/react-router/src/link.tsx index c6652354c6a..049385e043a 100644 --- a/packages/react-router/src/link.tsx +++ b/packages/react-router/src/link.tsx @@ -6,8 +6,8 @@ import { deepEqual, exactPathTest, functionalUpdate, + getUrlScheme, hasKeys, - isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -33,11 +33,9 @@ import type { ValidateLinkOptionsArray, } from './typePrimitives' -type LinkState = [ - href: string | undefined, - externalLink: string | undefined, - isActive: boolean, -] +// Undefined active state marks an external or blocked link. +// Keep that classification with the href instead of parsing it again on render. +type LinkState = [href: string | undefined, isActive?: boolean] // Keep a referentially stable value while the contents are equal. Links // routinely pass inline `params` / `search` object literals, which would @@ -57,35 +55,24 @@ function useValueStable(value: T): T { } function compareLinkState(a: LinkState, b: LinkState) { - return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] + return a[0] === b[0] && a[1] === b[1] } function resolveExternalLink( - hrefOption: { href: string; external?: boolean } | undefined, to: string | undefined, protocolAllowlist: AnyRouter['protocolAllowlist'], -): string | undefined { - if (hrefOption?.external) { - // Block dangerous protocols for external links - if (isDangerousProtocol(hrefOption.href, protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${hrefOption.href}`) - } - return undefined - } - return hrefOption.href +): string | null | undefined { + const scheme = typeof to === 'string' && getUrlScheme(to) + if (!scheme) { + return undefined } - if (!isSafeInternal(to) && isAbsoluteUrl(to)) { - // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to!, protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return undefined + if (!protocolAllowlist.has(scheme)) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${to}`) } - return to + return null } - return undefined + return to } function resolveIsActive( @@ -94,29 +81,22 @@ function resolveIsActive( activeOptions: ActiveOptions | undefined, basepath: string, isHydrated: boolean, - isExternal: boolean, ): boolean { - if (isExternal) { + const currentPath = removeTrailingSlash(location.pathname, basepath) + const nextPath = removeTrailingSlash(next.pathname, basepath) + + // Both modes compare normalized paths; fuzzy matches need a segment boundary. + if ( + activeOptions?.exact + ? currentPath !== nextPath + : !( + currentPath.startsWith(nextPath) && + (currentPath.length === nextPath.length || + currentPath[nextPath.length] === '/') + ) + ) { return false } - if (activeOptions?.exact) { - const testExact = exactPathTest(location.pathname, next.pathname, basepath) - if (!testExact) { - return false - } - } else { - const currentPathSplit = removeTrailingSlash(location.pathname, basepath) - const nextPathSplit = removeTrailingSlash(next.pathname, basepath) - - const pathIsFuzzyEqual = - currentPathSplit.startsWith(nextPathSplit) && - (currentPathSplit.length === nextPathSplit.length || - currentPathSplit[nextPathSplit.length] === '/') - - if (!pathIsFuzzyEqual) { - return false - } - } if (activeOptions?.includeSearch ?? true) { const searchTest = deepEqual(location.search, next.search, { @@ -214,86 +194,32 @@ export function useLinkProps< // The expression must stay inlined in the `if` so bundlers fold the // browser-build constant `isServer = false` and drop this server block. if (isServer ?? router.isServer) { - const safeInternal = isSafeInternal(to) - - // If `to` is obviously an absolute URL, treat as external and avoid - // computing the internal location via `buildLocation`. - if (!safeInternal && isAbsoluteUrl(to)) { - if (isDangerousProtocol(to!, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: undefined, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - } - } + const directExternalLink = resolveExternalLink(to, router.protocolAllowlist) - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: to, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - } - } - - const next = router.buildLocation({ ...options, from: options.from } as any) + // Direct-scheme links need no route resolution. Blocked links still use + // the shared inactive-prop merge so their server and client markup agree. + const next = + directExternalLink === undefined + ? router.buildLocation(options as any) + : undefined // Use publicHref - it contains the correct href for display // When a rewrite changes the origin, publicHref is the full URL // Otherwise it's the origin-stripped path // This avoids constructing URL objects in the hot path - const hrefOptionPublicHref = next.maskedLocation - ? next.maskedLocation.publicHref - : next.publicHref - const hrefOptionExternal = next.maskedLocation - ? next.maskedLocation.external - : next.external - const hrefOption = getHrefOption( - hrefOptionPublicHref, - hrefOptionExternal, - router.history, - disabled, - ) - - const externalLink = (() => { - if (hrefOption?.external) { - if (isDangerousProtocol(hrefOption.href, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn( - `Blocked Link with dangerous protocol: ${hrefOption.href}`, - ) - } - return undefined - } - return hrefOption.href - } + const hrefOption = next + ? getHrefOption(next, router, disabled) + : (directExternalLink ?? undefined) + const linkDisabled = disabled || !hrefOption - if (!safeInternal && isAbsoluteUrl(to)) { - if (isDangerousProtocol(to!, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${to}`) - } - return undefined - } - return to - } - - return undefined - })() + const externalLink = + directExternalLink ?? + (hrefOption && getUrlScheme(hrefOption) ? hrefOption : undefined) const isActive = (() => { - if (externalLink) return false + if (!next || (!disabled && !hrefOption) || externalLink) { + return false + } const currentLocation = router.stores.location.get() @@ -441,13 +367,13 @@ export function useLinkProps< ...propsSafeToSpread, ...resolvedActiveProps, ...resolvedInactiveProps, - href: hrefOption?.href, + href: hrefOption, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - disabled: !!disabled, + disabled: !!linkDisabled, target, ...(resolvedStyle && { style: resolvedStyle }), ...(resolvedClassName && { className: resolvedClassName }), - ...(disabled && STATIC_DISABLED_PROPS), + ...(linkDisabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), } } @@ -498,6 +424,14 @@ export function useLinkProps< // eslint-disable-next-line react-hooks/rules-of-hooks const selectLinkState = React.useCallback( (location: ParsedLocation): LinkState => { + const directExternalLink = resolveExternalLink( + to, + router.protocolAllowlist, + ) + if (directExternalLink !== undefined) { + return [directExternalLink ?? undefined] + } + const next = router.buildLocation({ _fromLocation: location, ..._options, @@ -507,74 +441,37 @@ export function useLinkProps< // When a rewrite changes the origin, publicHref is the full URL // Otherwise it's the origin-stripped path // This avoids constructing URL objects in the hot path - const hrefOption = getHrefOption( - next.maskedLocation ? next.maskedLocation.publicHref : next.publicHref, - next.maskedLocation ? next.maskedLocation.external : next.external, - router.history, - disabled, - ) - - const externalLink = resolveExternalLink( - hrefOption, - to, - router.protocolAllowlist, - ) - + const hrefOption = getHrefOption(next, router, disabled) return [ - hrefOption?.href, - externalLink, - resolveIsActive( - location, - next, - stableActiveOptions, - router.basepath, - isHydrated, - externalLink !== undefined, - ), + hrefOption, + !disabled && (!hrefOption || getUrlScheme(hrefOption)) + ? undefined + : resolveIsActive( + location, + next, + stableActiveOptions, + router.basepath, + isHydrated, + ), ] }, [stableActiveOptions, disabled, isHydrated, _options, router, to], ) // eslint-disable-next-line react-hooks/rules-of-hooks - const [href, externalLink, isActive] = useStore( + const [href, isActive] = useStore( router.stores.location, selectLinkState, compareLinkState, ) - - // Get the active props - const resolvedActiveProps: React.HTMLAttributes = isActive - ? (functionalUpdate(activeProps as any, {}) ?? STATIC_ACTIVE_OBJECT) - : STATIC_EMPTY_OBJECT - - // Get the inactive props - const resolvedInactiveProps: React.HTMLAttributes = - isActive - ? STATIC_EMPTY_OBJECT - : (functionalUpdate(inactiveProps, {}) ?? STATIC_EMPTY_OBJECT) - - const resolvedClassName = [ - className, - resolvedActiveProps.className, - resolvedInactiveProps.className, - ] - .filter(Boolean) - .join(' ') - - const resolvedStyle = (style || - resolvedActiveProps.style || - resolvedInactiveProps.style) && { - ...style, - ...resolvedActiveProps.style, - ...resolvedInactiveProps.style, - } + const externalLink = isActive === undefined ? href : undefined + const linkDisabled = disabled || href === undefined // eslint-disable-next-line react-hooks/rules-of-hooks const hasRenderFetched = React.useRef(false) const preload = - options.reloadDocument || externalLink || disabled + options.reloadDocument || externalLink || linkDisabled ? false : (userPreload ?? router.options.defaultPreload) const preloadDelay = @@ -644,6 +541,39 @@ export function useLinkProps< } }, [doPreload, preload]) + if (externalLink) { + return { + ...propsSafeToSpread, + ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], + href: externalLink, + ...(children && { children }), + ...(target && { target }), + ...(disabled && { disabled }), + ...(style && { style }), + ...(className && { className }), + ...(onClick && { onClick }), + ...(onBlur && { onBlur }), + ...(onFocus && { onFocus }), + ...(onMouseEnter && { onMouseEnter }), + ...(onMouseLeave && { onMouseLeave }), + ...(onTouchStart && { onTouchStart }), + } + } + + // Only one state contributes props, so resolve and merge it once. + const resolvedStateProps: React.HTMLAttributes = + functionalUpdate(isActive ? (activeProps as any) : inactiveProps, {}) ?? + (isActive ? STATIC_ACTIVE_OBJECT : STATIC_EMPTY_OBJECT) + + const resolvedClassName = [className, resolvedStateProps.className] + .filter(Boolean) + .join(' ') + + const resolvedStyle = (style || resolvedStateProps.style) && { + ...style, + ...resolvedStateProps.style, + } + // The click handler const handleClick = (e: React.MouseEvent) => { // Check actual element's target attribute as fallback @@ -653,7 +583,7 @@ export function useLinkProps< const effectiveTarget = target !== undefined ? target : elementTarget if ( - !disabled && + !linkDisabled && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && @@ -675,25 +605,6 @@ export function useLinkProps< } } - if (externalLink) { - return { - ...propsSafeToSpread, - ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], - href: externalLink, - ...(children && { children }), - ...(target && { target }), - ...(disabled && { disabled }), - ...(style && { style }), - ...(className && { className }), - ...(onClick && { onClick }), - ...(onBlur && { onBlur }), - ...(onFocus && { onFocus }), - ...(onMouseEnter && { onMouseEnter }), - ...(onMouseLeave && { onMouseLeave }), - ...(onTouchStart && { onTouchStart }), - } - } - const handleTouchStart = () => { if (preload !== 'intent') return doPreload() @@ -707,8 +618,7 @@ export function useLinkProps< return { ...propsSafeToSpread, - ...resolvedActiveProps, - ...resolvedInactiveProps, + ...resolvedStateProps, href, ref: innerRef as React.ComponentPropsWithRef<'a'>['ref'], onClick: composeHandlers(onClick, handleClick), @@ -717,11 +627,11 @@ export function useLinkProps< onMouseEnter: composeHandlers(onMouseEnter, enqueuePreload), onMouseLeave: composeHandlers(onMouseLeave, handleLeave), onTouchStart: composeHandlers(onTouchStart, handleTouchStart), - disabled: !!disabled, + disabled: !!linkDisabled, target, ...(resolvedStyle && { style: resolvedStyle }), ...(resolvedClassName && { className: resolvedClassName }), - ...(disabled && STATIC_DISABLED_PROPS), + ...(linkDisabled && STATIC_DISABLED_PROPS), ...(isActive && STATIC_ACTIVE_PROPS), } } @@ -753,27 +663,28 @@ export const composeHandlers = ( } function getHrefOption( - publicHref: string, - external: boolean, - history: AnyRouter['history'], + next: ParsedLocation, + router: AnyRouter, disabled: boolean | undefined, ) { - if (disabled) return undefined - // Full URL means rewrite changed the origin - treat as external-like - if (external) { - return { href: publicHref, external: true } + if (disabled) { + return undefined } - return { - href: history.createHref(publicHref) || '/', - external: false, + const location = next.maskedLocation ?? next + // A rewritten external URL must bypass history's relative-path formatting. + const href = location.external + ? location.publicHref + : router.history.createHref(location.publicHref) || '/' + if ( + (location.external || href !== location.publicHref) && + isDangerousProtocol(href, router.protocolAllowlist) + ) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${href}`) + } + return undefined } -} - -function isSafeInternal(to: unknown) { - if (typeof to !== 'string') return false - const zero = to.charCodeAt(0) - if (zero === 47) return to.charCodeAt(1) !== 47 // '/' but not '//' - return zero === 46 // '.', '..', './', '../' + return href } type UseLinkReactProps = TComp extends keyof React.JSX.IntrinsicElements @@ -934,7 +845,8 @@ export function createLink( export const Link: LinkComponent<'a'> = React.forwardRef( (props, ref) => { const { _asChild, ...rest } = props - const { type: _type, ...linkProps } = useLinkProps(rest as any, ref) + // eslint-disable-next-line prefer-const -- The rest binding is reassigned below. + let { type: _type, ...linkProps } = useLinkProps(rest as any, ref) const children = typeof rest.children === 'function' @@ -947,9 +859,9 @@ export const Link: LinkComponent<'a'> = React.forwardRef( // the ReturnType of useLinkProps returns the correct type for a element, not a general component that has a disabled prop // @ts-expect-error const { disabled: _, ...rest } = linkProps - return React.createElement('a', rest, children) + linkProps = rest } - return React.createElement(_asChild, linkProps, children) + return React.createElement(_asChild || 'a', linkProps, children) }, ) as any diff --git a/packages/react-router/tests/link-events.test.tsx b/packages/react-router/tests/link-events.test.tsx index 1bc036b91d8..0095d450a55 100644 --- a/packages/react-router/tests/link-events.test.tsx +++ b/packages/react-router/tests/link-events.test.tsx @@ -18,7 +18,7 @@ import { afterEach(cleanup) -test.each([true])( +test.each([false, true])( 'preserves caller click handlers across destination changes (cancel=%s)', (cancel) => { const router = createRouter({ diff --git a/packages/react-router/tests/link-href-safety.test.tsx b/packages/react-router/tests/link-href-safety.test.tsx index ef554bf42f0..5838d0b8c3d 100644 --- a/packages/react-router/tests/link-href-safety.test.tsx +++ b/packages/react-router/tests/link-href-safety.test.tsx @@ -17,7 +17,13 @@ afterEach(() => { for (const isServer of [true, false]) { test.each<[string, string | null, boolean]>([ + ['//evil.example/path', null, false], + ['/\\evil.example/path', null, false], + ['\\/evil.example/path', null, false], + ['\x01 \t//evil.example/path', null, false], + ['javascript:blocked()', null, false], ['/formatted', '/formatted', true], + ['https://other.example/', 'https://other.example/', false], ] as const)( `validates final history href %j on ${isServer ? 'server' : 'client'}`, (href, expectedHref, active) => { diff --git a/packages/react-router/tests/link-state-props.test.tsx b/packages/react-router/tests/link-state-props.test.tsx index 8c93c2ff7a2..6f205b20206 100644 --- a/packages/react-router/tests/link-state-props.test.tsx +++ b/packages/react-router/tests/link-state-props.test.tsx @@ -1,9 +1,12 @@ import React from 'react' -import { cleanup, render } from '@testing-library/react' -import { afterEach, expect, test } from 'vitest' +import { renderToString } from 'react-dom/server' +import { hydrateRoot } from 'react-dom/client' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, expect, test, vi } from 'vitest' import { Link, RouterContextProvider, + createLink, createMemoryHistory, createRootRoute, createRoute, @@ -12,6 +15,237 @@ import { afterEach(cleanup) +test('blocked custom links keep the validated props and forwarded ref', () => { + const CustomLink = createLink( + React.forwardRef< + HTMLAnchorElement, + React.ComponentProps<'a'> & { disabled?: boolean } + >((props, ref) => ( + + )), + ) + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory(), + }) + const ref = React.createRef() + const unwantedRef = vi.fn() + const search = vi.fn(() => ({})) + const buildLocation = vi.spyOn(router, 'buildLocation') + const inactiveProps = vi.fn(() => ({ + href: 'javascript:override()', + disabled: false, + ref: unwantedRef, + className: 'inactive-state', + title: 'Inactive', + })) + const tree = ( + + + Target + + + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + router.isServer = true + const html = renderToString(tree) + expect(html).toContain('data-disabled="true"') + expect(html).toContain('class="inactive-state"') + expect(html).not.toContain('href=') + router.isServer = false + const anchor = render(tree).getByText('Target') + expect(anchor).toHaveAttribute('data-disabled', 'true') + expect(anchor).not.toHaveAttribute('href') + expect(anchor).toHaveClass('inactive-state') + expect(ref.current).toBe(anchor) + expect(inactiveProps).toHaveBeenCalled() + expect(unwantedRef).not.toHaveBeenCalled() + expect(buildLocation).not.toHaveBeenCalled() + expect(search).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + buildLocation.mockRestore() + } +}) + +test.each([false, true])( + 'blocked links preserve inactive styling from SSR to client (disabled=%s)', + async (disabled) => { + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory(), + }) + const inactiveProps = { + className: 'inactive-state', + style: { color: 'blue' }, + href: 'javascript:inactive()', + title: 'Inactive', + } + const tree = ( + + + {({ isActive }) => String(isActive)} + + + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + router.isServer = true + const serverContainer = document.createElement('div') + serverContainer.innerHTML = renderToString(tree) + const serverLink = serverContainer.querySelector('a')! + router.isServer = false + const clientLink = render(tree).container.querySelector('a')! + expect(serverLink.className).toBe('base inactive-state') + expect(serverLink).toHaveStyle({ + color: 'rgb(0, 0, 255)', + fontWeight: '700', + }) + expect(serverLink).not.toHaveAttribute('href') + expect(serverLink).toHaveAttribute('aria-disabled', 'true') + expect(serverLink).not.toHaveAttribute('aria-current') + expect(serverLink).toHaveAttribute('title', 'Inactive') + expect(serverLink.textContent).toBe('false') + expect( + Object.fromEntries( + Array.from(clientLink.attributes, (attr) => [ + attr.name, + attr.name === 'style' ? clientLink.style.cssText : attr.value, + ]), + ), + ).toEqual( + Object.fromEntries( + Array.from(serverLink.attributes, (attr) => [ + attr.name, + attr.name === 'style' ? serverLink.style.cssText : attr.value, + ]), + ), + ) + const diagnostics = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + const recoverableError = vi.fn() + let hydrated: ReturnType | undefined + try { + await act(() => { + hydrated = hydrateRoot(serverContainer, tree, { + onRecoverableError: recoverableError, + }) + }) + expect(serverContainer.querySelector('a')).toBe(serverLink) + expect(serverLink).not.toHaveAttribute('href') + expect(serverLink).toHaveClass('inactive-state') + expect(diagnostics).not.toHaveBeenCalled() + expect(recoverableError).not.toHaveBeenCalled() + } finally { + await act(() => hydrated?.unmount()) + diagnostics.mockRestore() + } + } finally { + warn.mockRestore() + } + }, +) + +test('external destinations skip state props across mounted link transitions', async () => { + const root = createRootRoute() + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/active' }), + createRoute({ getParentRoute: () => root, path: '/other' }), + ]), + history: createMemoryHistory({ initialEntries: ['/active'] }), + rewrite: { + output: ({ url }) => + url.pathname === '/rewritten' + ? new URL('https://other.example/rewritten') + : url, + }, + }) + await router.load() + const activeProps = vi.fn(() => ({ + className: 'active-state', + style: { color: 'red' }, + href: 'javascript:active()', + })) + const inactiveProps = vi.fn(() => ({ + className: 'inactive-state', + style: { color: 'blue' }, + href: 'javascript:inactive()', + })) + const content = (to: string) => ( + + + Target + + + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const view = render(content('/active')) + for (const [to, href, state] of [ + ['/active', '/active', 'active'], + [ + 'https://other.example/direct', + 'https://other.example/direct', + 'external', + ], + ['/rewritten', 'https://other.example/rewritten', 'external'], + ['javascript:blocked()', null, 'inactive'], + ['/other', '/other', 'inactive'], + ['/active', '/active', 'active'], + ] as const) { + activeProps.mockClear() + inactiveProps.mockClear() + view.rerender(content(to)) + const anchor = view.getByText('Target') + expect(anchor.getAttribute('href')).toBe(href) + expect(anchor.className).toBe( + state === 'external' ? 'base' : `base ${state}-state`, + ) + expect(anchor).toHaveStyle({ + color: + state === 'active' + ? 'rgb(255, 0, 0)' + : state === 'inactive' + ? 'rgb(0, 0, 255)' + : 'rgb(0, 0, 0)', + fontWeight: '700', + }) + expect(anchor.getAttribute('aria-current')).toBe( + state === 'active' ? 'page' : null, + ) + expect(anchor.getAttribute('aria-disabled')).toBe( + href === null ? 'true' : null, + ) + if (state === 'external') { + expect(activeProps).not.toHaveBeenCalled() + expect(inactiveProps).not.toHaveBeenCalled() + } + } + } finally { + warn.mockRestore() + } +}) + test.each([false, true])( 'active links preserve styling while disabled changes (masked=%s)', async (masked) => { @@ -55,3 +289,67 @@ test.each([false, true])( } }, ) + +test('functional state props preserve styling and href precedence across transitions', async () => { + const root = createRootRoute() + const router = createRouter({ + routeTree: root.addChildren([ + createRoute({ getParentRoute: () => root, path: '/active' }), + createRoute({ getParentRoute: () => root, path: '/other' }), + ]), + history: createMemoryHistory({ initialEntries: ['/active'] }), + }) + await router.load() + const activeProps = vi.fn(() => ({ + className: 'active-state', + style: { color: 'red' }, + href: 'javascript:active()', + })) + const inactiveProps = vi.fn(() => ({ + className: 'inactive-state', + style: { color: 'blue' }, + href: 'javascript:inactive()', + })) + let version = 0 + const content = (to = '/active') => ( + + + Target + + + ) + const view = render(content()) + for (const pathname of ['/active', '/other', '/active']) { + await act(() => router.navigate({ to: pathname })) + activeProps.mockClear() + inactiveProps.mockClear() + view.rerender(content()) + const active = pathname === '/active' + expect(active ? activeProps : inactiveProps).toHaveBeenCalled() + expect(active ? inactiveProps : activeProps).not.toHaveBeenCalled() + const anchor = view.getByText('Target') + expect(anchor).toHaveAttribute('href', '/active') + expect(anchor.className).toBe( + `base ${active ? 'active-state' : 'inactive-state'}`, + ) + expect(anchor).toHaveStyle({ + color: active ? 'rgb(255, 0, 0)' : 'rgb(0, 0, 255)', + fontWeight: '700', + }) + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + view.rerender(content('javascript:blocked()')) + expect(view.getByText('Target')).not.toHaveAttribute('href') + expect(view.getByText('Target').className).toBe('base inactive-state') + } finally { + warn.mockRestore() + } +}) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index 6a4abfe53fc..7afe01957d1 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -7815,8 +7815,12 @@ describe('protocolAllowlist', () => { path: '/', component: () => ( <> - + @@ -7849,18 +7853,18 @@ describe('protocolAllowlist', () => { expect(consoleWarn).not.toHaveBeenCalled() }) - it('should fallback to relative links when protocol is not in allowlist', async () => { + it('should block links when protocol is not in allowlist', async () => { const router = createRouter({ routeTree: rootRoute.addChildren([indexRoute]), history, protocolAllowlist: [], }) render() - const links = await screen.findAllByRole('link') - expect(links[0]).toHaveAttribute('href', '/x-safari-https:/example.com') - expect(links[1]).toHaveAttribute( + expect( + await screen.findByTestId('custom-protocol-link'), + ).not.toHaveAttribute('href') + expect(screen.getByTestId('intent-protocol-link')).not.toHaveAttribute( 'href', - '/intent:/example.com#Intent;scheme=https;end', ) expect(consoleWarn).toHaveBeenCalledWith( 'Blocked Link with dangerous protocol: x-safari-https://example.com', @@ -7872,6 +7876,114 @@ describe('protocolAllowlist', () => { }) describe('masked and custom history hrefs', () => { + test.each([false, true])( + 'updates a mounted link across destination types (rewrite: %s)', + async (rewrite) => { + const nextLoader = vi.fn(() => 'next page') + const rootRoute = createRootRoute({ + component: function ChangingDestination() { + const [phase, setPhase] = React.useState(0) + const to = + phase === 0 + ? '/target' + : phase === 3 || rewrite + ? '/next' + : phase === 1 + ? 'https://other.example/next' + : 'javascript:alert(1)' + return ( + <> + + + {({ isActive }) => String(isActive)} + + + + ) + }, + }) + const targetRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/target', + component: () =>

target page

, + }) + const nextRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/next', + loader: nextLoader, + component: () =>

next page

, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([targetRoute, nextRoute]), + history: createMemoryHistory({ initialEntries: ['/target'] }), + rewrite: rewrite + ? { + output: ({ url }) => + url.hash === '#external' + ? new URL('https://other.example/next') + : url.hash === '#blocked' + ? new URL('javascript:alert(1)') + : url, + } + : undefined, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + render() + const link = await screen.findByTestId('changing-link') + expect(link).toHaveTextContent('true') + expect(link).toHaveAttribute('aria-current', 'page') + + const clickWasIntercepted = () => { + let intercepted = false + // Observe the router's handling, then cancel native document navigation. + document.addEventListener( + 'click', + (event) => { + intercepted = event.defaultPrevented + event.preventDefault() + }, + { once: true }, + ) + fireEvent.click(link) + return intercepted + } + for (const href of ['https://other.example/next', null]) { + fireEvent.click(screen.getByText('Change destination')) + await waitFor(() => expect(link.getAttribute('href')).toBe(href)) + expect(link).toHaveTextContent('false') + expect(link).not.toHaveAttribute('aria-current') + expect(clickWasIntercepted()).toBe(false) + fireEvent.mouseOver(link) + await act(() => sleep(10)) + expect(nextLoader).not.toHaveBeenCalled() + expect(screen.getByText('target page')).toBeInTheDocument() + fireEvent.mouseLeave(link) + } + fireEvent.click(screen.getByText('Change destination')) + await waitFor(() => expect(link).toHaveAttribute('href', '/next')) + fireEvent.mouseOver(link) + await waitFor(() => expect(nextLoader).toHaveBeenCalledTimes(1)) + expect(clickWasIntercepted()).toBe(true) + expect(await screen.findByText('next page')).toBeInTheDocument() + await waitFor(() => expect(link).toHaveTextContent('true')) + expect(link).toHaveAttribute('aria-current', 'page') + }, + ) + test.each([ { output: '/mask', href: '/formatted/mask', formatted: true }, { @@ -7879,6 +7991,7 @@ describe('masked and custom history hrefs', () => { href: 'https://other.example/mask', formatted: false, }, + { output: 'javascript:alert(1)', href: null, formatted: false }, ])( 'validates the final masked href $output on server and client', async ({ output, href, formatted }) => { @@ -7959,6 +8072,191 @@ describe('masked and custom history hrefs', () => { }, ) + test('keeps direct-scheme links safe and inactive across SSR and client', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + + {({ isActive }) => String(isActive)} + + + {({ isActive }) => String(isActive)} + + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await router.load() + + router.isServer = true + const serverContainer = document.createElement('div') + serverContainer.innerHTML = renderToString( + , + ) + + router.isServer = false + const { container: clientContainer } = render( + , + ) + + const getLinkState = (container: ParentNode, testId: string) => { + const link = container.querySelector(`[data-testid="${testId}"]`)! + return { + href: link.getAttribute('href'), + active: link.getAttribute('data-status'), + ariaCurrent: link.getAttribute('aria-current'), + role: link.getAttribute('role'), + ariaDisabled: link.getAttribute('aria-disabled'), + text: link.textContent, + } + } + + const serverStates = { + allowed: getLinkState(serverContainer, 'allowed-link'), + dangerous: getLinkState(serverContainer, 'dangerous-link'), + } + const clientStates = { + allowed: getLinkState(clientContainer, 'allowed-link'), + dangerous: getLinkState(clientContainer, 'dangerous-link'), + } + + expect(clientStates).toEqual(serverStates) + expect(clientStates.allowed).toMatchObject({ + href: 'https://example.com', + active: null, + ariaCurrent: null, + role: null, + ariaDisabled: null, + text: 'false', + }) + expect(clientStates.dangerous).toMatchObject({ + href: null, + active: null, + ariaCurrent: null, + role: 'link', + ariaDisabled: 'true', + text: 'false', + }) + }) + + test('renders every internal Link on the router origin', async () => { + const inputs = [ + '//evil.example', + '/\\evil.example', + '\\/evil.example', + ' \t/\\evil.example', + ] + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + {inputs.map((to, index) => ( + + ))} + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + for (let index = 0; index < inputs.length; index++) { + const link = await screen.findByTestId(`unsafe-link-${index}`) + const href = link.getAttribute('href') + expect(href).not.toBeNull() + expect(new URL(href!, window.location.href).origin).toBe( + window.location.origin, + ) + } + }) + + test('blocks a dangerous final href produced by an output rewrite', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + rewrite: { + output: ({ url }) => + url.pathname === '/safe' ? new URL('javascript:alert(1)') : url, + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + const link = await screen.findByTestId('rewritten-link') + + expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('role', 'link') + expect(link).toHaveAttribute('aria-disabled', 'true') + expect(fireEvent.click(link)).toBe(true) + }) + + test('normalizes protocol-relative paths from output rewrites during SSR', () => { + const origin = 'https://victim.example' + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory({ initialEntries: ['/'] }), + origin, + isServer: true, + rewrite: { + output: ({ url }) => + url.pathname === '/safe' + ? new URL(`${origin}//evil.example/path`) + : url, + }, + }) + + const html = renderToString( + + Safe + , + ) + + expect(html).toContain('href="/evil.example/path"') + expect(html).not.toContain('href="//evil.example/path"') + }) + + test('blocks a dangerous final href produced by custom history', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'javascript:alert(1)', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + const link = await screen.findByTestId('custom-history-link') + + expect(link).not.toHaveAttribute('href') + expect(fireEvent.click(link)).toBe(true) + customHistory.destroy() + }) + test('does not transform a direct HTTPS link through custom history', async () => { const customHistory = createBrowserHistory({ createHref: () => 'https://other.example/', @@ -7985,6 +8283,68 @@ describe('masked and custom history hrefs', () => { customHistory.destroy() } }) + + test('does not intercept an external href produced by custom history', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'https://other.example/path', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + render() + const link = await screen.findByTestId('custom-history-link') + + expect(link).toHaveAttribute('href', 'https://other.example/path') + expect(fireEvent.click(link)).toBe(true) + customHistory.destroy() + }) + + test('blocked links stay inactive and cannot regain an href', async () => { + const rootRoute = createRootRoute() + const safeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/safe', + component: () => ( + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([safeRoute]), + history: createMemoryHistory({ initialEntries: ['/safe'] }), + rewrite: { + output: ({ url }) => + url.hash === '#blocked' ? new URL('javascript:alert(1)') : url, + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + const link = await screen.findByTestId('rewritten-link') + + expect(link).not.toHaveAttribute('href') + expect(link).not.toHaveAttribute('aria-current') + expect(link).not.toHaveAttribute('data-active') + expect(link).toHaveAttribute('data-inactive', 'true') + expect(fireEvent.click(link)).toBe(true) + }) }) describe('link re-render bail-out', () => { diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index 19b2cc7b602..4d7fe106f66 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -319,8 +319,8 @@ export { isModuleNotFoundError, DEFAULT_PROTOCOL_ALLOWLIST, escapeHtml, + getUrlScheme, isDangerousProtocol, - isAbsoluteUrl, buildDevStylesUrl, } from './utils' export type { diff --git a/packages/router-core/src/load-client.ts b/packages/router-core/src/load-client.ts index 8cd8af603fe..1994eb2c371 100644 --- a/packages/router-core/src/load-client.ts +++ b/packages/router-core/src/load-client.ts @@ -1071,27 +1071,39 @@ function materializeRedirect( while (outcome[0 /* kind */] === REDIRECTED) { const redirect = outcome[1 /* redirect */] const redirectOptions = redirect.options - if ( - redirectOptions.reloadDocument - ? options[3 /* preload */] - : options[1 /* redirects */] >= 20 - ) { - return outcome - } try { - if (redirectOptions.href && redirectOptions.reloadDocument) { + if (redirectOptions.href || redirect.headers.has('Location')) { router.resolveRedirect(redirect) + if (redirectOptions.reloadDocument) { + return outcome + } + } + if ( + redirectOptions.reloadDocument + ? options[3 /* preload */] + : options[1 /* redirects */] >= 20 + ) { return outcome } - return [ - REDIRECTED, - redirect, - router.buildLocation({ - ...redirectOptions, - _fromLocation: lane[0 /* location */], - _includeValidateSearch: true, - }), - ] + const location = router.buildLocation({ + ...redirectOptions, + _fromLocation: lane[0 /* location */], + _includeValidateSearch: true, + }) + const publicLocation = location.maskedLocation ?? location + if (publicLocation.external) { + // Loader outcomes can be shared by lanes with different search/params. + // Keep the resolved destination local to this lane. + const resolved = redirect.clone() as AnyRedirect + resolved.options = { ...redirectOptions } + resolved.headers.set('Location', publicLocation.publicHref) + router.resolveRedirect(resolved) + // A two-item outcome marks a terminal redirect for preloads. + return options[3 /* preload */] + ? [REDIRECTED, resolved] + : [REDIRECTED, resolved, publicLocation] + } + return [REDIRECTED, redirect, location] } catch (cause) { outcome = failed ? [ERROR, cause] : normalizeError(route, cause) failed = true @@ -1703,7 +1715,7 @@ function followRedirect( } if (options.reloadDocument) { return router.navigate({ - href: location.publicHref, + href: (location.maskedLocation ?? location).publicHref, reloadDocument: true, replace: true, ignoreBlocker: true, diff --git a/packages/router-core/src/redirect.ts b/packages/router-core/src/redirect.ts index a9c869b8f75..dc6551d1747 100644 --- a/packages/router-core/src/redirect.ts +++ b/packages/router-core/src/redirect.ts @@ -1,4 +1,3 @@ -import { isAbsoluteUrl } from './utils' import type { NavigateOptions } from './link' import type { AnyRouter, RegisteredRouter } from './router' @@ -88,11 +87,11 @@ export interface RedirectFnRoute { * * Use from route `loader`/`beforeLoad` or server functions to trigger a * navigation. If `throw: true` is set, the redirect is thrown instead of - * returned. When an absolute `href` is supplied and `reloadDocument` is not - * set, a full-document navigation is inferred. + * returned. External `href` values are classified as full-document + * navigations when the router resolves the redirect. * * @param opts Options for the redirect. Common fields: - * - `href`: absolute URL for external redirects; infers `reloadDocument`. + * - `href`: absolute URL for external redirects. * - `statusCode`: HTTP status code to use (defaults to 307). * - `headers`: additional headers to include on the Response. * - Standard navigation options like `to`, `params`, `search`, `replace`, @@ -111,14 +110,6 @@ export function redirect< ): Redirect { opts.statusCode = opts.statusCode || opts.code || 307 - if ( - !opts.reloadDocument && - typeof opts.href === 'string' && - isAbsoluteUrl(opts.href) - ) { - opts.reloadDocument = true - } - const headers = new Headers(opts.headers) if (opts.href && headers.get('Location') === null) { headers.set('Location', opts.href) @@ -139,13 +130,11 @@ export function redirect< return response as Redirect } -/** Check whether a value is a TanStack Router redirect Response. */ /** Check whether a value is a TanStack Router redirect Response. */ export function isRedirect(obj: any): obj is AnyRedirect { return obj instanceof Response && !!(obj as any).options } -/** True if value is a redirect with a resolved `href` location. */ /** True if value is a redirect with a resolved `href` location. */ export function isResolvedRedirect( obj: any, @@ -153,7 +142,6 @@ export function isResolvedRedirect( return isRedirect(obj) && !!obj.options.href } -/** Parse a serialized redirect object back into a redirect Response. */ /** Parse a serialized redirect object back into a redirect Response. */ export function parseRedirect(obj: any) { if (obj !== null && typeof obj === 'object' && obj.isSerializedRedirect) { diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 4c11b2499e1..9de2af86152 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1,4 +1,8 @@ -import { createBrowserHistory, parseHref } from '@tanstack/history' +import { + createBrowserHistory, + normalizeProtocolRelative, + parseHref, +} from '@tanstack/history' import { isServer, loadServerRoute } from '@tanstack/router-core/isServer' import { DEFAULT_PROTOCOL_ALLOWLIST, @@ -7,11 +11,12 @@ import { encodePathLikeUrl, findLast, functionalUpdate, + getUrlScheme, hasKeys, - isAbsoluteUrl, isDangerousProtocol, last, nullReplaceEqualDeep, + protocolRelativePrefixRegex, replaceEqualDeep, } from './utils' import { @@ -120,6 +125,19 @@ import type { } from './ssr/serializer/transformer' import type { GetStoreConfig, RouterStores } from './stores' +function isExternalUrl(url: URL, origin: string) { + return ( + (url.protocol !== 'http:' && url.protocol !== 'https:') || + url.origin !== origin || + !!url.username || + !!url.password + ) +} + +function getUrlPath(url: URL) { + return url.pathname + url.search + url.hash +} + export type ControllablePromise = Promise & { resolve: (value: T) => void reject: (value?: any) => void @@ -514,6 +532,11 @@ export interface RouterOptions< * This is useful for shifting data from the origin to the path (for things like subdomain routing), or other advanced use cases. */ rewrite?: LocationRewrite + /** + * The origin used to resolve URLs. Pass a normalized origin, such as + * `https://example.com` or `http://localhost:3000`, without a path or trailing slash. + * Defaults to the browser origin, or `http://localhost` on the server. + */ origin?: string ssr?: { nonce?: string @@ -1123,7 +1146,7 @@ export class RouterCore< > history!: TRouterHistory rewrite?: LocationRewrite - origin?: string + origin!: string latestLocation!: ParsedLocation> _pendingLocation?: ParsedLocation> basepath!: string @@ -1231,7 +1254,7 @@ export class RouterCore< } } - this.origin = this.options.origin + this.origin = this.options.origin! if (!this.origin) { if ( !(isServer ?? this.isServer) && @@ -1431,14 +1454,14 @@ export class RouterCore< return { href: pathname + searchStr + hash, publicHref: pathname + searchStr + hash, - pathname: decodePath(pathname).path, + pathname: decodePath(pathname), external: false, searchStr, search: nullReplaceEqualDeep( previousLocation?.search, parsedSearch, ) as any, - hash: decodePath(hash.slice(1)).path, + hash: decodePath(hash.slice(1)), state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1460,14 +1483,16 @@ export class RouterCore< return { href: fullPath, publicHref: href, - pathname: decodePath(url.pathname).path, - external: !!this.rewrite && url.origin !== this.origin, + // An input rewrite can expose a path like "//evil.example". + // Normalize it to "/evil.example" to keep it on the current origin. + pathname: decodePath(normalizeProtocolRelative(url.pathname)), + external: !!this.rewrite && isExternalUrl(url, this.origin), searchStr, search: nullReplaceEqualDeep( previousLocation?.search, parsedSearch, ) as any, - hash: decodePath(url.hash.slice(1)).path, + hash: decodePath(url.hash.slice(1)), state: replaceEqualDeep(previousLocation?.state, state), } } @@ -1983,13 +2008,17 @@ export class RouterCore< ? // Keep path params uninterpolated for matchRoute/template matching. nextTo : decodePath( - interpolatePath({ - path: nextTo, - params: nextParams, - decoder: this.pathParamsDecoder, - server: this.isServer, - }).interpolatedPath, - ).path + // A splat can produce a path like "//evil.example". + // Normalize it to "/evil.example" to keep it on the current origin. + normalizeProtocolRelative( + interpolatePath({ + path: nextTo, + params: nextParams, + decoder: this.pathParamsDecoder, + server: this.isServer, + }).interpolatedPath, + ), + ) if ( process.env.NODE_ENV !== 'production' && @@ -2079,16 +2108,18 @@ export class RouterCore< if (this.rewrite) { // With rewrite, we need to construct URL to apply the rewrite const url = new URL(fullPath, this.origin) + const origin = url.origin const rewrittenUrl = executeRewriteOutput(this.rewrite, url) - href = url.href.replace(url.origin, '') + href = getUrlPath(url) // If rewrite changed the origin, publicHref needs full URL // Otherwise just use the path components - if (rewrittenUrl.origin !== this.origin) { + if (isExternalUrl(rewrittenUrl, origin)) { publicHref = rewrittenUrl.href external = true } else { - publicHref = - rewrittenUrl.pathname + rewrittenUrl.search + rewrittenUrl.hash + // A same-origin rewrite can produce a pathname like "//evil.example". + // Normalize it to "/evil.example" so the link stays on this origin. + publicHref = normalizeProtocolRelative(getUrlPath(rewrittenUrl)) } } else { // Fast path: no rewrite, skip URL construction entirely @@ -2255,7 +2286,7 @@ export class RouterCore< viewTransition, ignoreBlocker, ...rest - }: BuildNextOptions & CommitLocationOptions = {}) => { + }: BuildNextOptions & CommitLocationOptions = {}): Promise => { const location = this.buildLocation({ ...(rest as any), _includeValidateSearch: true, @@ -2299,31 +2330,24 @@ export class RouterCore< publicHref, ...rest }) => { - const hrefIsUrl = !!href && isAbsoluteUrl(`${href}`) + const hrefScheme = href ? getUrlScheme(href) : undefined - if (hrefIsUrl && !reloadDocument) { - reloadDocument = true - } - - if (reloadDocument) { + if (hrefScheme || reloadDocument) { // When to is provided, always build a location to get the proper publicHref // (this handles redirects where href might be an internal path from resolveRedirect) // When only href is provided (no to), use it directly as it should already // be a complete path (possibly with basepath) if (to !== undefined || !href) { const location = this.buildLocation({ to, ...rest } as any) - /* - * TODO: Explicit reloads ignore maskedLocation; use the mask's public URL - * consistently with commitLocation in a follow-up. - */ + const publicLocation = location.maskedLocation ?? location // Use publicHref which contains the path (origin-stripped is fine for reload) - href = href ?? location.publicHref - publicHref = publicHref ?? location.publicHref + href ??= publicLocation.publicHref + publicHref ??= publicLocation.publicHref } // Use publicHref when available and href is not a full URL, // otherwise use href directly (which may already include basepath) - const reloadHref = !hrefIsUrl && publicHref ? publicHref : href + const reloadHref = !hrefScheme && publicHref ? publicHref : href return documentNavigation(this, reloadHref, rest) } @@ -2486,41 +2510,43 @@ 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 = location.publicHref || '/' - 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 - } + const options = redirect.options + let href = redirect.headers.get('Location') || options.href + + if (!href) { + const location = this.buildLocation(options) + href = (location.maskedLocation ?? location).publicHref || '/' } + let scheme: string | undefined + // Reject protocol-relative URLs such as "//evil.example", including + // backslash and control-character variants, before checking the protocol. if ( - redirect.options.href && - // Check for dangerous protocols before processing the redirect - isDangerousProtocol(redirect.options.href, this.protocolAllowlist) + protocolRelativePrefixRegex.test(href) || + ((scheme = getUrlScheme(href)) && !this.protocolAllowlist.has(scheme)) ) { throw new Error( process.env.NODE_ENV !== 'production' - ? `Redirect blocked: unsafe protocol in href "${redirect.options.href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.` + ? `Redirect blocked: unsafe protocol in href "${href}". Allowed protocols: ${Array.from(this.protocolAllowlist).join(', ')}.` : 'Redirect blocked: unsafe protocol', ) } - if (!redirect.headers.get('Location')) { - redirect.headers.set('Location', redirect.options.href) + if (scheme === 'http:' || scheme === 'https:') { + const url = new URL(href) + if (url.pathname.startsWith('//')) { + href = url.href + } else if (!isExternalUrl(url, this.origin)) { + href = getUrlPath(url) + scheme = undefined + } } + if (scheme) { + options.reloadDocument = true + } + + options.href = href + redirect.headers.set('Location', href) return redirect } @@ -2683,6 +2709,9 @@ async function documentNavigation( } } + // All blockers have allowed this navigation (or were explicitly skipped). + // Avoid asking for approval again in the native beforeunload handler. + router.history._ignoreNextBeforeUnload?.() if (replace) { window.location.replace(href) } else { diff --git a/packages/router-core/src/ssr/ssr-server.ts b/packages/router-core/src/ssr/ssr-server.ts index e349e987ebc..e7dfd4a364f 100644 --- a/packages/router-core/src/ssr/ssr-server.ts +++ b/packages/router-core/src/ssr/ssr-server.ts @@ -765,8 +765,13 @@ export function getNormalizedURL(url: string | URL, base?: string | URL) { if (typeof url === 'string') url = url.replace('\\', '%5C') const rawUrl = new URL(url, base) - const { path: decodedPathname, handledProtocolRelativeURL } = decodePath( - rawUrl.pathname, + // URL parsing has already handled backslashes and ignored controls. A pathname + // like "//evil.example" would become a protocol-relative URL when rebuilt below. + const handledProtocolRelativeURL = rawUrl.pathname.startsWith('//') + const decodedPathname = decodePath( + handledProtocolRelativeURL + ? rawUrl.pathname.replace(/^\/+/, '/') + : rawUrl.pathname, ) const searchParams = new URLSearchParams(rawUrl.search) const normalizedHref = diff --git a/packages/router-core/src/utils.ts b/packages/router-core/src/utils.ts index aefe3042ed0..9b1b3c411a8 100644 --- a/packages/router-core/src/utils.ts +++ b/packages/router-core/src/utils.ts @@ -2,21 +2,6 @@ import { isServer } from '@tanstack/router-core/isServer' import type { RouteIds } from './routeInfo' import type { AnyRouter } from './router' -export function isAbsoluteUrl(url: string | undefined): boolean { - // Both URL APIs stringify undefined and reject it without a base URL. - if (URL.canParse) { - return URL.canParse(url!) - } - - // Older browsers do not support URL.canParse. - try { - new URL(url!) - return true - } catch { - return false - } -} - export type Awaitable = T | Promise export type NoInfer = [T][T extends any ? 0 : never] export type IsAny = 1 extends 0 & TValue @@ -595,19 +580,51 @@ export const DEFAULT_PROTOCOL_ALLOWLIST = [ ] /** - * Check if a URL string uses a protocol that is not in the allowlist. - * Returns true for blocked protocols like javascript:, blob:, data:, etc. + * Extract the explicit URL scheme, including its colon, using WHATWG + * normalization rules. This does not validate the rest of the URL. + * + * Returning `undefined` means "no explicit scheme", not "safe URL"; + * protocol-relative URLs such as "//evil.example" require a separate check. + */ +export function getUrlScheme(url: string): string | undefined { + if (url[0] === '/') { + return undefined + } + if (!url.includes(':')) { + return undefined + } + // WHATWG strips leading C0/space and TAB/LF/CR within a scheme. + // Match the prefix first so relative paths and URL bodies need no copying. + // eslint-disable-next-line no-control-regex + return /^[\x00-\x20]*([a-z][a-z\d+.\t\n\r-]*:)/i + .exec(url)?.[1] + ?.replace(/[\t\n\r]/g, '') + .toLowerCase() +} + +// Match protocol-relative URLs such as "//evil.example", including backslash +// and control-character variants. Stop at the second separator so validation +// does not scan or normalize the URL body. +// eslint-disable-next-line no-control-regex +export const protocolRelativePrefixRegex = /^[\x00-\x20]*[\\/][\t\n\r]*[\\/]/ + +/** + * Check if a URL string uses a protocol that is not in the allowlist or is + * protocol-relative (e.g. "//evil.example"), which can navigate to another host. + * Returns true for blocked protocols like javascript:, blob:, and data:, as + * well as slash/backslash variants of protocol-relative URLs. * - * The URL constructor correctly normalizes: + * Scheme parsing normalizes: * - Mixed case (JavaScript: → javascript:) * - Whitespace/control characters (java\nscript: → javascript:) * - Leading whitespace * - * For relative URLs (no protocol), returns false (safe). + * For relative URLs without a protocol-relative prefix, returns false. * * @param url - The URL string to check * @param allowlist - Set of protocols to allow - * @returns true if the URL uses a protocol that is not allowed + * @returns true if the URL uses a protocol that is not allowed or can escape + * the current origin through a protocol-relative URL */ export function isDangerousProtocol( url: string, @@ -615,16 +632,14 @@ export function isDangerousProtocol( ): 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 !allowlist.has(parsed.protocol) - } catch { - // URL constructor throws for relative URLs (no protocol) - // These are safe - they can't execute scripts - return false + // Inputs like "/\evil.example" can navigate to another host just like + // "//evil.example", even with leading whitespace or ignored control characters. + if (protocolRelativePrefixRegex.test(url)) { + return true } + + const scheme = getUrlScheme(url) + return scheme ? !allowlist.has(scheme) : false } // This utility is based on https://github.com/zertosh/htmlescape @@ -650,39 +665,27 @@ export function escapeHtml(str: string): string { return str.replace(HTML_ESCAPE_REGEX, (match) => HTML_ESCAPE_LOOKUP[match]!) } +// Decode component data only. Leave protocol-relative URL handling to callers; +// this decoder also receives fragments, where slashes and backslashes are data. export function decodePath(path: string) { - if (!path) return { path, handledProtocolRelativeURL: false } - - // Fast path: most paths are already decoded and safe. - // Only fall back to the slower scan/regex path when we see a '%' (encoded), - // a backslash (explicitly handled), a control character, or a protocol-relative - // prefix which needs collapsing. - // eslint-disable-next-line no-control-regex - if (!/[%\\\x00-\x1f\x7f]/.test(path) && !path.startsWith('//')) { - return { path, handledProtocolRelativeURL: false } + if (!path) { + return path } - - const re = /%25|%5C/gi - let cursor = 0 - let result = '' - let match - while (null !== (match = re.exec(path))) { - result += decodeSegment(path.slice(cursor, match.index)) + match[0] - cursor = re.lastIndex - } - result = result + decodeSegment(cursor ? path.slice(cursor) : path) - - // Prevent open redirect via protocol-relative URLs (e.g. "//evil.com") - // This is defense-in-depth: since control characters are no longer decoded, - // paths like "/%0d/evil.com" can no longer become "//evil.com". But we keep - // this check to guard against other edge cases. - let handledProtocolRelativeURL = false - if (result.startsWith('//')) { - handledProtocolRelativeURL = true - result = '/' + result.replace(/^\/+/, '') + let result = path + // eslint-disable-next-line no-control-regex + if (/[%\\\x00-\x1f\x7f]/.test(path)) { + const re = /%25|%5C/gi + let cursor = 0 + let match + result = '' + while (null !== (match = re.exec(path))) { + result += decodeSegment(path.slice(cursor, match.index)) + match[0] + cursor = re.lastIndex + } + result += decodeSegment(cursor ? path.slice(cursor) : path) } - return { path: result, handledProtocolRelativeURL } + return result } /** diff --git a/packages/router-core/tests/dangerous-protocols.test.ts b/packages/router-core/tests/dangerous-protocols.test.ts index 83f377bbd8e..375c2f8925f 100644 --- a/packages/router-core/tests/dangerous-protocols.test.ts +++ b/packages/router-core/tests/dangerous-protocols.test.ts @@ -1,6 +1,11 @@ +import { URL as NodeURL } from 'node:url' import { createBrowserHistory, createMemoryHistory } from '@tanstack/history' import { afterEach, describe, expect, it, vi } from 'vitest' -import { DEFAULT_PROTOCOL_ALLOWLIST, isDangerousProtocol } from '../src/utils' +import { + DEFAULT_PROTOCOL_ALLOWLIST, + getUrlScheme, + isDangerousProtocol, +} from '../src/utils' import { redirect } from '../src/redirect' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter, loadServerResponse } from './routerTestUtils' @@ -11,6 +16,24 @@ afterEach(() => { vi.unstubAllGlobals() }) +const unsafeRelativeUrls = [ + '//evil.example', + '///evil.example', + '/\\evil.example', + '/\\\\evil.example', + '/\\/evil.example', + '\\/evil.example', + '\\\\evil.example', + ' //evil.example', + ' /\\evil.example', + '\x01//evil.example', + ...['\t', '\n', '\r'].flatMap((control) => + ['/', '\\'].flatMap((first) => + ['/', '\\'].map((second) => first + control + second + 'evil.example'), + ), + ), +] + describe('isDangerousProtocol', () => { describe('blocked protocols (not in default allowlist)', () => { it('should detect javascript: protocol', () => { @@ -132,7 +155,7 @@ describe('isDangerousProtocol', () => { expect(isDangerousProtocol(':::', defaultAllowlistSet)).toBe(false) expect(isDangerousProtocol('123:456', defaultAllowlistSet)).toBe(false) expect(isDangerousProtocol('//example.com', defaultAllowlistSet)).toBe( - false, + true, ) }) @@ -202,6 +225,119 @@ describe('isDangerousProtocol', () => { }) }) + describe.each([ + { name: 'default', allowlist: defaultAllowlistSet }, + { name: 'empty', allowlist: new Set() }, + { + name: 'custom', + allowlist: new Set(['https:', 'custom+1.-:', 'javascript:']), + }, + ])('native URL parsing with the $name allowlist', ({ allowlist }) => { + it.each(['http:', 'https:'])( + 'matches native host resolution around separators with a %s base', + (protocol) => { + const base = new NodeURL(`${protocol}//app.example/current`) + const characters = [ + ...Array.from({ length: 0x80 }, (_, code) => + String.fromCharCode(code), + ), + '\u0085', + '\u00a0', + '\u200b', + '\u2028', + '\u2029', + '\ufeff', + '\uff0f', + '\uff3c', + '\ud800', + '\udfff', + ] + + // These inputs have no explicit scheme. If they supply a host, it is + // different from the base, so native origin changes identify them. + for (const character of characters) { + for (const first of ['/', '\\']) { + for (const second of ['/', '\\']) { + for (const href of [ + character + first + '\t\n\r' + second + 'evil.example/path', + first + character + second + 'evil.example/path', + ]) { + const resolved = new NodeURL(href, base) + expect( + isDangerousProtocol(href, allowlist), + JSON.stringify(href), + ).toBe(resolved.origin !== base.origin) + } + } + } + } + }, + ) + + it('allows encoded separators and URL-like text that stays within the current origin', () => { + const base = new NodeURL('https://app.example/current') + for (const href of [ + '%2f%2fevil.example', + '%5c%5cevil.example', + '/%2f/evil.example', + '/%5c/evil.example', + '/%09/evil.example', + '/.//evil.example', + '/a/..//evil.example', + '?next=//evil.example', + '#javascript:payload', + ]) { + expect(new NodeURL(href, base).origin, JSON.stringify(href)).toBe( + base.origin, + ) + expect(isDangerousProtocol(href, allowlist), JSON.stringify(href)).toBe( + false, + ) + } + }) + + it('blocks protocol-relative URLs even when they resolve to the current host', () => { + const base = new NodeURL('https://app.example/current') + for (const href of ['//app.example/target', '/\\app.example/target']) { + expect(new NodeURL(href, base).origin).toBe(base.origin) + expect(isDangerousProtocol(href, allowlist), JSON.stringify(href)).toBe( + true, + ) + } + }) + + it('checks the normalized explicit scheme against the allowlist', () => { + for (const href of [ + ' \x00H\tT\nT\rPS://other.example/path', + '\x01 \nHTtP://other.example/path', + ' \x1fjava\tsc\nri\rpt:payload', + '\rCuS\tToM+1.-:payload', + 'MaIl\tTo:user@example.com', + 'Te\nL:+1234567890', + ]) { + const scheme = new NodeURL(href).protocol + expect(isDangerousProtocol(href, allowlist), JSON.stringify(href)).toBe( + !allowlist.has(scheme), + ) + } + }) + + it.each([ + ['https://[', 'https:'], + ['javascript://[', 'javascript:'], + ['custom+1.-://[', 'custom+1.-:'], + ['//[', undefined], + ] as const)( + 'applies the protocol policy despite a malformed URL body in %j', + (href, scheme) => { + expect(() => new NodeURL(href, 'https://app.example')).toThrow() + expect(isDangerousProtocol(href, allowlist)).toBe( + scheme === undefined || !allowlist.has(scheme), + ) + }, + ) + }) + describe('DEFAULT_PROTOCOL_ALLOWLIST', () => { it('should contain the expected default protocols', () => { expect(DEFAULT_PROTOCOL_ALLOWLIST).toEqual([ @@ -275,7 +411,7 @@ describe('public navigation and redirect sinks', () => { }, ) - it.each(customProtocols)( + it.each([...customProtocols, ...unsafeRelativeUrls])( 'blocks unsafe document destination %j', async (href) => { const windowLocation = { href: '', replace: vi.fn() } @@ -288,6 +424,10 @@ describe('public navigation and redirect sinks', () => { }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + if (unsafeRelativeUrls.includes(href)) { + expect(new URL(href, router.origin).origin).toBe('https://evil.example') + } + try { for (const replace of [false, true]) { await router.navigate({ href, reloadDocument: true, replace }) @@ -305,6 +445,28 @@ describe('public navigation and redirect sinks', () => { }, ) + it('validates the Location header emitted by a server redirect', async () => { + const rootRoute = new BaseRootRoute() + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => + redirect({ + href: '/safe', + headers: { Location: '/\\evil.example' }, + }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute]), + isServer: true, + }) + + const response = await loadServerResponse(router, '/source') + + expect(response.status).toBe(500) + expect(response.headers.get('Location')).toBeNull() + }) + it('uses a safe explicit Location instead of a stale external href', async () => { const rootRoute = new BaseRootRoute() const sourceRoute = new BaseRoute({ @@ -327,7 +489,147 @@ describe('public navigation and redirect sinks', () => { expect(response.headers.get('Location')).toBe('/safe') }) - it.each([{ blocked: false, ignoreBlocker: false, unsafe: true }])( + it('does not emit a protocol-relative canonical Location', async () => { + const origin = 'https://victim.example' + const rootRoute = new BaseRootRoute() + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ href: `${origin}/..//evil.example/path` }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute]), + origin, + isServer: true, + }) + + const response = await loadServerResponse(router, '/source') + + expect(response.headers.get('Location')).toBe( + `${origin}//evil.example/path`, + ) + }) + + it.each([false, true])( + 'blocks a dangerous output rewrite with mask=%j', + async (masked) => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + const windowLocation = { href: '', replace: vi.fn() } + vi.stubGlobal('window', { location: windowLocation }) + const publicPath = masked ? '/pretty' : '/target' + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history, + origin: 'https://victim.example', + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === publicPath ? new URL('javascript:alert(1)') : url, + }, + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await router.navigate({ + to: '/target', + mask: masked ? { to: '/pretty' } : undefined, + }) + + expect(history.location.href).toBe('/') + expect(windowLocation.href).toBe('') + expect(warn).toHaveBeenCalledWith( + 'Blocked navigation to dangerous protocol: javascript:alert(1)', + ) + }, + ) + + it('document-navigates to a safe external masked rewrite', async () => { + const history = createMemoryHistory({ initialEntries: ['/'] }) + const windowLocation = { href: '', replace: vi.fn() } + vi.stubGlobal('window', { location: windowLocation }) + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history, + origin: 'https://victim.example', + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/pretty' + ? new URL('https://other.example/rewritten') + : url, + }, + }) + + await router.navigate({ to: '/safe', mask: { to: '/pretty' } }) + + expect(history.location.href).toBe('/') + expect(windowLocation.href).toBe('https://other.example/rewritten') + }) + + it('uses a safe mask for explicit full-document navigation', async () => { + const windowLocation = { href: '', replace: vi.fn() } + vi.stubGlobal('window', { location: windowLocation }) + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history: createMemoryHistory({ initialEntries: ['/'] }), + origin: 'https://victim.example', + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/target' + ? new URL('https://other.example/rewritten') + : url, + }, + }) + + await router.navigate({ + to: '/target', + mask: { to: '/pretty' }, + reloadDocument: true, + }) + + expect(windowLocation.href).toBe('/pretty') + }) + + it('provides the raw current location and replace action to document blockers', async () => { + const history = createMemoryHistory({ initialEntries: ['/current?q=0'] }) + const blockerFn = vi.fn(() => true) + history.block({ blockerFn }) + const windowLocation = { href: '', replace: vi.fn() } + vi.stubGlobal('window', { location: windowLocation }) + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history, + origin: 'https://victim.example', + isServer: false, + }) + + await router.navigate({ + href: 'https://other.example/target', + replace: true, + }) + + expect(blockerFn).toHaveBeenCalledWith( + expect.objectContaining({ + currentLocation: expect.objectContaining({ + href: '/current?q=0', + pathname: '/current', + search: '?q=0', + }), + action: 'REPLACE', + }), + ) + expect(windowLocation.replace).not.toHaveBeenCalled() + }) + + it.each([ + { blocked: false, ignoreBlocker: false, unsafe: false }, + { blocked: true, ignoreBlocker: false, unsafe: false }, + { blocked: true, ignoreBlocker: true, unsafe: false }, + { blocked: false, ignoreBlocker: false, unsafe: true }, + ])( 'only exempts an accepted document navigation from beforeunload: %j', async ({ blocked, ignoreBlocker, unsafe }) => { const browserWindow = window @@ -377,6 +679,152 @@ describe('public navigation and redirect sinks', () => { ) }) +describe('getUrlScheme', () => { + // Generated inputs use valid URL bodies, so a native parse failure must mean + // the prefix has no explicit scheme. Compare both accepted and rejected inputs. + function expectNativeScheme(href: string) { + const expected = NodeURL.canParse(href) + ? new NodeURL(href).protocol + : undefined + expect(getUrlScheme(href), JSON.stringify(href)).toBe(expected) + } + + it.each([ + ['', undefined], + ['https', undefined], + ['javascript:', 'javascript:'], + ['a:b:payload', 'a:'], + [' \x00H\tT\nT\rPS://example.com', 'https:'], + ['CuStOm+1.-\t:value', 'custom+1.-:'], + ['https\x01://example.com', undefined], + ['https ://example.com', undefined], + ['123:payload', undefined], + ['%6aavascript:payload', undefined], + ['/path?next=https://example.com', undefined], + ['?next=javascript:payload', undefined], + ['#javascript:payload', undefined], + ['//example.com:443', undefined], + ['\t\n/\\example.com', undefined], + ] as const)('extracts only a scheme at the start of %j', (href, scheme) => { + expect(getUrlScheme(href)).toBe(scheme) + }) + + it.each([ + ['https:', 'https:'], + ['https://[', 'https:'], + ['https://example.com:99999', 'https:'], + ['https::payload', 'https:'], + ['custom://[', 'custom:'], + ])( + 'extracts the scheme despite an invalid URL body in %j', + (href, scheme) => { + expect(() => new NodeURL(href)).toThrow() + expect(getUrlScheme(href)).toBe(scheme) + }, + ) + + it.each(unsafeRelativeUrls)( + 'requires a separate safety check for protocol-relative input %j', + (href) => { + expect(getUrlScheme(href)).toBeUndefined() + expect(isDangerousProtocol(href, defaultAllowlistSet)).toBe(true) + }, + ) + + it.each([ + 'javascript', + 'HTTPS', + 'http', + 'file', + 'mailto', + 'tel', + 'CuStOm+1.-', + ])('matches native URL parsing across ASCII boundaries in %s', (scheme) => { + for (let code = 0; code <= 0x7f; code++) { + // A second ':' can invalidate the body (e.g. "https::payload"). + // Those cases have explicit expectations above. + if (code === 0x3a) { + continue + } + const character = String.fromCharCode(code) + for (let position = 0; position <= scheme.length; position++) { + expectNativeScheme( + scheme.slice(0, position) + + character + + scheme.slice(position) + + ':payload', + ) + } + } + }) + + it('matches native URL parsing for pairs of leading controls and spaces', () => { + for (let first = 0; first <= 0x20; first++) { + for (let second = 0; second <= 0x20; second++) { + expectNativeScheme( + String.fromCharCode(first, second) + 'Ja\tVa\nSc\rRiPt:payload', + ) + } + } + }) + + it('matches native URL parsing for bounded prefix combinations', () => { + const alphabet = [ + '\0', + '\t', + '\n', + '\r', + ' ', + '\x7f', + 'a', + 'Z', + '0', + '+', + '-', + '.', + '_', + '/', + '\\', + '%', + ] + function checkPrefixes(prefix: string, remaining: number) { + expectNativeScheme(prefix + 'x:payload') + if (!remaining) { + return + } + for (const character of alphabet) { + checkPrefixes(prefix + character, remaining - 1) + } + } + checkPrefixes('', 3) + }) + + it.each([ + '\u0085', + '\u00a0', + '\u1680', + '\u2000', + '\u200b', + '\u2028', + '\u2029', + '\u202f', + '\u205f', + '\u3000', + '\ufeff', + '\u017f', + '\u212a', + '\uff28', + '\uff1a', + '\ud800', + '\udfff', + '\u{1f600}', + ])('matches native URL parsing with non-ASCII character %j', (character) => { + expectNativeScheme(character + 'javascript:payload') + expectNativeScheme('java' + character + 'script:payload') + expectNativeScheme('javascript' + character + ':payload') + }) +}) + describe('integration test on Router', () => { const inputs = [ 'x-safari-https://example.com', diff --git a/packages/router-core/tests/fixtures/wpt-url/LICENSE.md b/packages/router-core/tests/fixtures/wpt-url/LICENSE.md new file mode 100644 index 00000000000..39c46d03ac2 --- /dev/null +++ b/packages/router-core/tests/fixtures/wpt-url/LICENSE.md @@ -0,0 +1,11 @@ +# The 3-Clause BSD License + +Copyright © web-platform-tests contributors + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/router-core/tests/fixtures/wpt-url/README.md b/packages/router-core/tests/fixtures/wpt-url/README.md new file mode 100644 index 00000000000..0a1774ef7d2 --- /dev/null +++ b/packages/router-core/tests/fixtures/wpt-url/README.md @@ -0,0 +1,46 @@ +# Pinned Web Platform Tests URL cases + +The two upstream JSON files are complete, unmodified copies from Web Platform Tests revision +[`6cfafc4c0801279aa10df8e85f8799d98b8000ab`](https://github.com/web-platform-tests/wpt/commit/6cfafc4c0801279aa10df8e85f8799d98b8000ab): + +- [`url/resources/urltestdata.json`](https://github.com/web-platform-tests/wpt/blob/6cfafc4c0801279aa10df8e85f8799d98b8000ab/url/resources/urltestdata.json) +- [`url/resources/urltestdata-javascript-only.json`](https://github.com/web-platform-tests/wpt/blob/6cfafc4c0801279aa10df8e85f8799d98b8000ab/url/resources/urltestdata-javascript-only.json) + +Every one of the 893 cases in `urltestdata.json` and the single case in +`urltestdata-javascript-only.json` is tested, including all parsing failures. +The test skips only string comment entries. SHA-256 checks ensure the upstream +files remain byte-for-byte unchanged. The JavaScript-only file includes unpaired +surrogates, so the tests load the upstream files with `JSON.parse`, bypassing the +bundler's JSON transform. + +The upstream [format documentation](https://github.com/web-platform-tests/wpt/blob/6cfafc4c0801279aa10df8e85f8799d98b8000ab/url/README.md) +describes complete URL parsing. These tests exercise Router's prefix helpers +using native `URL` to derive expectations: + +- For the explicit scheme, parse the input through its first colon followed by + a fixed valid body (`//example.com`), without a base. Native `URL` validates and + normalizes the scheme even when the original body is malformed; relative + prefixes fail to parse. Also check WPT's recorded `protocol` when the input has + an explicit scheme and WPT supplies that field. +- For inputs without an explicit scheme, resolve against two HTTPS bases with + different hosts. Matching resulting hostnames identify a protocol-relative + input. Ordinary relative paths inherit the different base hosts. Four literal + inputs (`//`, `///`, `////`, and `//C|/foo/bar`) need explicit expectations + because they have no valid HTTP(S) host. + +The [test](../../url-standard.test.ts) contains the source hashes and those four +exceptions. There are no per-index annotations to maintain. Case indices in test +names are positions in the original arrays, including string comment entries. + +Router's allowlist and blanket protocol-relative rejection are application +policy. The tests apply the default, empty, and custom allowlists. They use +HTTP(S) [relative URL behavior](https://url.spec.whatwg.org/#relative-state) +even when an upstream case has a different base scheme, since the helpers have +no base parameter. Neither production helper calculates an expected result. + +To update the fixtures, copy both complete files from a new upstream commit and +update the SHA-256 hashes in the test and the revision links here. Keep both +files in upstream format and preserve the [license](./LICENSE.md). Run the core +unit and type tests and lint. If a new scheme-less input cannot be parsed against +HTTPS, review it and add an explicit expectation instead of skipping it. +Tests read only committed files and make no network requests. diff --git a/packages/router-core/tests/fixtures/wpt-url/urltestdata-javascript-only.json b/packages/router-core/tests/fixtures/wpt-url/urltestdata-javascript-only.json new file mode 100644 index 00000000000..a3793c1f472 --- /dev/null +++ b/packages/router-core/tests/fixtures/wpt-url/urltestdata-javascript-only.json @@ -0,0 +1,18 @@ +[ + "See ../README.md for a description of the format.", + { + "input": "http://example.com/\uD800\uD801\uDFFE\uDFFF\uFDD0\uFDCF\uFDEF\uFDF0\uFFFE\uFFFF?\uD800\uD801\uDFFE\uDFFF\uFDD0\uFDCF\uFDEF\uFDF0\uFFFE\uFFFF", + "base": null, + "href": "http://example.com/%EF%BF%BD%F0%90%9F%BE%EF%BF%BD%EF%B7%90%EF%B7%8F%EF%B7%AF%EF%B7%B0%EF%BF%BE%EF%BF%BF?%EF%BF%BD%F0%90%9F%BE%EF%BF%BD%EF%B7%90%EF%B7%8F%EF%B7%AF%EF%B7%B0%EF%BF%BE%EF%BF%BF", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%EF%BF%BD%F0%90%9F%BE%EF%BF%BD%EF%B7%90%EF%B7%8F%EF%B7%AF%EF%B7%B0%EF%BF%BE%EF%BF%BF", + "search": "?%EF%BF%BD%F0%90%9F%BE%EF%BF%BD%EF%B7%90%EF%B7%8F%EF%B7%AF%EF%B7%B0%EF%BF%BE%EF%BF%BF", + "hash": "" + } +] diff --git a/packages/router-core/tests/fixtures/wpt-url/urltestdata.json b/packages/router-core/tests/fixtures/wpt-url/urltestdata.json new file mode 100644 index 00000000000..aae7b1f6568 --- /dev/null +++ b/packages/router-core/tests/fixtures/wpt-url/urltestdata.json @@ -0,0 +1,10643 @@ +[ + "See ../README.md for a description of the format.", + { + "input": "http://example\t.\norg", + "base": "http://example.org/foo/bar", + "href": "http://example.org/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://user:pass@foo:21/bar;par?b#c", + "base": "http://example.org/foo/bar", + "href": "http://user:pass@foo:21/bar;par?b#c", + "origin": "http://foo:21", + "protocol": "http:", + "username": "user", + "password": "pass", + "host": "foo:21", + "hostname": "foo", + "port": "21", + "pathname": "/bar;par", + "search": "?b", + "hash": "#c" + }, + { + "input": "https://test:@test", + "base": null, + "href": "https://test@test/", + "origin": "https://test", + "protocol": "https:", + "username": "test", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://:@test", + "base": null, + "href": "https://test/", + "origin": "https://test", + "protocol": "https:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special://test:@test/x", + "base": null, + "href": "non-special://test@test/x", + "origin": "null", + "protocol": "non-special:", + "username": "test", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/x", + "search": "", + "hash": "" + }, + { + "input": "non-special://:@test/x", + "base": null, + "href": "non-special://test/x", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/x", + "search": "", + "hash": "" + }, + { + "input": "http:foo.com", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/foo.com", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/foo.com", + "search": "", + "hash": "" + }, + { + "input": "\t :foo.com \n", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:foo.com", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:foo.com", + "search": "", + "hash": "" + }, + { + "input": " foo.com ", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/foo.com", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/foo.com", + "search": "", + "hash": "" + }, + { + "input": "a:\t foo.com", + "base": "http://example.org/foo/bar", + "href": "a: foo.com", + "origin": "null", + "protocol": "a:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": " foo.com", + "search": "", + "hash": "" + }, + { + "input": "http://f:21/ b ? d # e ", + "base": "http://example.org/foo/bar", + "href": "http://f:21/%20b%20?%20d%20#%20e", + "origin": "http://f:21", + "protocol": "http:", + "username": "", + "password": "", + "host": "f:21", + "hostname": "f", + "port": "21", + "pathname": "/%20b%20", + "search": "?%20d%20", + "hash": "#%20e" + }, + { + "input": "lolscheme:x x#x x", + "base": null, + "href": "lolscheme:x x#x%20x", + "protocol": "lolscheme:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "x x", + "search": "", + "hash": "#x%20x" + }, + { + "input": "http://f:/c", + "base": "http://example.org/foo/bar", + "href": "http://f/c", + "origin": "http://f", + "protocol": "http:", + "username": "", + "password": "", + "host": "f", + "hostname": "f", + "port": "", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "http://f:0/c", + "base": "http://example.org/foo/bar", + "href": "http://f:0/c", + "origin": "http://f:0", + "protocol": "http:", + "username": "", + "password": "", + "host": "f:0", + "hostname": "f", + "port": "0", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "http://f:00000000000000/c", + "base": "http://example.org/foo/bar", + "href": "http://f:0/c", + "origin": "http://f:0", + "protocol": "http:", + "username": "", + "password": "", + "host": "f:0", + "hostname": "f", + "port": "0", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "http://f:00000000000000000000080/c", + "base": "http://example.org/foo/bar", + "href": "http://f/c", + "origin": "http://f", + "protocol": "http:", + "username": "", + "password": "", + "host": "f", + "hostname": "f", + "port": "", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "http://f:b/c", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://f: /c", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://f:\n/c", + "base": "http://example.org/foo/bar", + "href": "http://f/c", + "origin": "http://f", + "protocol": "http:", + "username": "", + "password": "", + "host": "f", + "hostname": "f", + "port": "", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "http://f:fifty-two/c", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://f:999999/c", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "non-special://f:999999/c", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://f: 21 / b ? d # e ", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "" + }, + { + "input": " \t", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "" + }, + { + "input": ":foo.com/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:foo.com/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:foo.com/", + "search": "", + "hash": "" + }, + { + "input": ":foo.com\\", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:foo.com/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:foo.com/", + "search": "", + "hash": "" + }, + { + "input": ":", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:", + "search": "", + "hash": "" + }, + { + "input": ":a", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:a", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:a", + "search": "", + "hash": "" + }, + { + "input": ":/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:/", + "search": "", + "hash": "" + }, + { + "input": ":\\", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:/", + "search": "", + "hash": "" + }, + { + "input": ":#", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:#", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:", + "search": "", + "hash": "" + }, + { + "input": "#", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar#", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "#/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar#/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "#/" + }, + { + "input": "#\\", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar#\\", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "#\\" + }, + { + "input": "#;?", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar#;?", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "#;?" + }, + { + "input": "?", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar?", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": ":23", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:23", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:23", + "search": "", + "hash": "" + }, + { + "input": "/:23", + "base": "http://example.org/foo/bar", + "href": "http://example.org/:23", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/:23", + "search": "", + "hash": "" + }, + { + "input": "\\x", + "base": "http://example.org/foo/bar", + "href": "http://example.org/x", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/x", + "search": "", + "hash": "" + }, + { + "input": "\\\\x\\hello", + "base": "http://example.org/foo/bar", + "href": "http://x/hello", + "origin": "http://x", + "protocol": "http:", + "username": "", + "password": "", + "host": "x", + "hostname": "x", + "port": "", + "pathname": "/hello", + "search": "", + "hash": "" + }, + { + "input": "::", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/::", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/::", + "search": "", + "hash": "" + }, + { + "input": "::23", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/::23", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/::23", + "search": "", + "hash": "" + }, + { + "input": "foo://", + "base": "http://example.org/foo/bar", + "href": "foo://", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "http://a:b@c:29/d", + "base": "http://example.org/foo/bar", + "href": "http://a:b@c:29/d", + "origin": "http://c:29", + "protocol": "http:", + "username": "a", + "password": "b", + "host": "c:29", + "hostname": "c", + "port": "29", + "pathname": "/d", + "search": "", + "hash": "" + }, + { + "input": "http://é@é", + "base": null, + "href": "http://%C3%A9@xn--9ca/", + "origin": "http://xn--9ca", + "protocol": "http:", + "username": "%C3%A9", + "password": "", + "host": "xn--9ca", + "hostname": "xn--9ca", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://é@example.com", + "base": null, + "href": "http://%C3%A9@example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "%C3%A9", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http::@c:29", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/:@c:29", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/:@c:29", + "search": "", + "hash": "" + }, + { + "input": "http://&a:foo(b]c@d:2/", + "base": "http://example.org/foo/bar", + "href": "http://&a:foo(b%5Dc@d:2/", + "origin": "http://d:2", + "protocol": "http:", + "username": "&a", + "password": "foo(b%5Dc", + "host": "d:2", + "hostname": "d", + "port": "2", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://::@c@d:2", + "base": "http://example.org/foo/bar", + "href": "http://:%3A%40c@d:2/", + "origin": "http://d:2", + "protocol": "http:", + "username": "", + "password": "%3A%40c", + "host": "d:2", + "hostname": "d", + "port": "2", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://foo.com:b@d/", + "base": "http://example.org/foo/bar", + "href": "http://foo.com:b@d/", + "origin": "http://d", + "protocol": "http:", + "username": "foo.com", + "password": "b", + "host": "d", + "hostname": "d", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://foo.com/\\@", + "base": "http://example.org/foo/bar", + "href": "http://foo.com//@", + "origin": "http://foo.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.com", + "hostname": "foo.com", + "port": "", + "pathname": "//@", + "search": "", + "hash": "" + }, + { + "input": "http:\\\\foo.com\\", + "base": "http://example.org/foo/bar", + "href": "http://foo.com/", + "origin": "http://foo.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.com", + "hostname": "foo.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:\\\\a\\b:c\\d@foo.com\\", + "base": "http://example.org/foo/bar", + "href": "http://a/b:c/d@foo.com/", + "origin": "http://a", + "protocol": "http:", + "username": "", + "password": "", + "host": "a", + "hostname": "a", + "port": "", + "pathname": "/b:c/d@foo.com/", + "search": "", + "hash": "" + }, + { + "input": "http://a:b@c\\", + "base": null, + "href": "http://a:b@c/", + "origin": "http://c", + "protocol": "http:", + "username": "a", + "password": "b", + "host": "c", + "hostname": "c", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ws://a@b\\c", + "base": null, + "href": "ws://a@b/c", + "origin": "ws://b", + "protocol": "ws:", + "username": "a", + "password": "", + "host": "b", + "hostname": "b", + "port": "", + "pathname": "/c", + "search": "", + "hash": "" + }, + { + "input": "foo:/", + "base": "http://example.org/foo/bar", + "href": "foo:/", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "foo:/bar.com/", + "base": "http://example.org/foo/bar", + "href": "foo:/bar.com/", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/bar.com/", + "search": "", + "hash": "" + }, + { + "input": "foo://///////", + "base": "http://example.org/foo/bar", + "href": "foo://///////", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "///////", + "search": "", + "hash": "" + }, + { + "input": "foo://///////bar.com/", + "base": "http://example.org/foo/bar", + "href": "foo://///////bar.com/", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "///////bar.com/", + "search": "", + "hash": "" + }, + { + "input": "foo:////://///", + "base": "http://example.org/foo/bar", + "href": "foo:////://///", + "origin": "null", + "protocol": "foo:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//://///", + "search": "", + "hash": "" + }, + { + "input": "c:/foo", + "base": "http://example.org/foo/bar", + "href": "c:/foo", + "origin": "null", + "protocol": "c:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/foo", + "search": "", + "hash": "" + }, + { + "input": "//foo/bar", + "base": "http://example.org/foo/bar", + "href": "http://foo/bar", + "origin": "http://foo", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/bar", + "search": "", + "hash": "" + }, + { + "input": "http://foo/path;a??e#f#g", + "base": "http://example.org/foo/bar", + "href": "http://foo/path;a??e#f#g", + "origin": "http://foo", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/path;a", + "search": "??e", + "hash": "#f#g" + }, + { + "input": "http://foo/abcd?efgh?ijkl", + "base": "http://example.org/foo/bar", + "href": "http://foo/abcd?efgh?ijkl", + "origin": "http://foo", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/abcd", + "search": "?efgh?ijkl", + "hash": "" + }, + { + "input": "http://foo/abcd#foo?bar", + "base": "http://example.org/foo/bar", + "href": "http://foo/abcd#foo?bar", + "origin": "http://foo", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/abcd", + "search": "", + "hash": "#foo?bar" + }, + { + "input": "[61:24:74]:98", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/[61:24:74]:98", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/[61:24:74]:98", + "search": "", + "hash": "" + }, + { + "input": "http:[61:27]/:foo", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/[61:27]/:foo", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/[61:27]/:foo", + "search": "", + "hash": "" + }, + { + "input": "http://[1::2]:3:4", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://2001::1", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://2001::1]", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://2001::1]:80", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://[2001::1]", + "base": "http://example.org/foo/bar", + "href": "http://[2001::1]/", + "origin": "http://[2001::1]", + "protocol": "http:", + "username": "", + "password": "", + "host": "[2001::1]", + "hostname": "[2001::1]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://[::127.0.0.1]", + "base": "http://example.org/foo/bar", + "href": "http://[::7f00:1]/", + "origin": "http://[::7f00:1]", + "protocol": "http:", + "username": "", + "password": "", + "host": "[::7f00:1]", + "hostname": "[::7f00:1]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://[::127.0.0.1.]", + "base": "http://example.org/foo/bar", + "failure": true + }, + { + "input": "http://[0:0:0:0:0:0:13.1.68.3]", + "base": "http://example.org/foo/bar", + "href": "http://[::d01:4403]/", + "origin": "http://[::d01:4403]", + "protocol": "http:", + "username": "", + "password": "", + "host": "[::d01:4403]", + "hostname": "[::d01:4403]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://[2001::1]:80", + "base": "http://example.org/foo/bar", + "href": "http://[2001::1]/", + "origin": "http://[2001::1]", + "protocol": "http:", + "username": "", + "password": "", + "host": "[2001::1]", + "hostname": "[2001::1]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/example.com/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/example.com/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "http:/", + "base": "http://example.com/", + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ftp:/example.com/", + "base": "http://example.org/foo/bar", + "href": "ftp://example.com/", + "origin": "ftp://example.com", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https:/example.com/", + "base": "http://example.org/foo/bar", + "href": "https://example.com/", + "origin": "https://example.com", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "madeupscheme:/example.com/", + "base": "http://example.org/foo/bar", + "href": "madeupscheme:/example.com/", + "origin": "null", + "protocol": "madeupscheme:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "file:/example.com/", + "base": "http://example.org/foo/bar", + "href": "file:///example.com/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "file://example:1/", + "base": null, + "failure": true + }, + { + "input": "file://example:test/", + "base": null, + "failure": true + }, + { + "input": "file://example%/", + "base": null, + "failure": true + }, + { + "input": "file://[example]/", + "base": null, + "failure": true + }, + { + "input": "ftps:/example.com/", + "base": "http://example.org/foo/bar", + "href": "ftps:/example.com/", + "origin": "null", + "protocol": "ftps:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "gopher:/example.com/", + "base": "http://example.org/foo/bar", + "href": "gopher:/example.com/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "ws:/example.com/", + "base": "http://example.org/foo/bar", + "href": "ws://example.com/", + "origin": "ws://example.com", + "protocol": "ws:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss:/example.com/", + "base": "http://example.org/foo/bar", + "href": "wss://example.com/", + "origin": "wss://example.com", + "protocol": "wss:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "data:/example.com/", + "base": "http://example.org/foo/bar", + "href": "data:/example.com/", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "javascript:/example.com/", + "base": "http://example.org/foo/bar", + "href": "javascript:/example.com/", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "mailto:/example.com/", + "base": "http://example.org/foo/bar", + "href": "mailto:/example.com/", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "http:example.com/", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/example.com/", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/example.com/", + "search": "", + "hash": "" + }, + { + "input": "ftp:example.com/", + "base": "http://example.org/foo/bar", + "href": "ftp://example.com/", + "origin": "ftp://example.com", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https:example.com/", + "base": "http://example.org/foo/bar", + "href": "https://example.com/", + "origin": "https://example.com", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "madeupscheme:example.com/", + "base": "http://example.org/foo/bar", + "href": "madeupscheme:example.com/", + "origin": "null", + "protocol": "madeupscheme:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "ftps:example.com/", + "base": "http://example.org/foo/bar", + "href": "ftps:example.com/", + "origin": "null", + "protocol": "ftps:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "gopher:example.com/", + "base": "http://example.org/foo/bar", + "href": "gopher:example.com/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "ws:example.com/", + "base": "http://example.org/foo/bar", + "href": "ws://example.com/", + "origin": "ws://example.com", + "protocol": "ws:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss:example.com/", + "base": "http://example.org/foo/bar", + "href": "wss://example.com/", + "origin": "wss://example.com", + "protocol": "wss:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "data:example.com/", + "base": "http://example.org/foo/bar", + "href": "data:example.com/", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "javascript:example.com/", + "base": "http://example.org/foo/bar", + "href": "javascript:example.com/", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "mailto:example.com/", + "base": "http://example.org/foo/bar", + "href": "mailto:example.com/", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "/a/b/c", + "base": "http://example.org/foo/bar", + "href": "http://example.org/a/b/c", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/a/b/c", + "search": "", + "hash": "" + }, + { + "input": "/a/ /c", + "base": "http://example.org/foo/bar", + "href": "http://example.org/a/%20/c", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/a/%20/c", + "search": "", + "hash": "" + }, + { + "input": "/a%2fc", + "base": "http://example.org/foo/bar", + "href": "http://example.org/a%2fc", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/a%2fc", + "search": "", + "hash": "" + }, + { + "input": "/a/%2f/c", + "base": "http://example.org/foo/bar", + "href": "http://example.org/a/%2f/c", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/a/%2f/c", + "search": "", + "hash": "" + }, + { + "input": "#β", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar#%CE%B2", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "#%CE%B2" + }, + { + "input": "data:text/html,test#test", + "base": "http://example.org/foo/bar", + "href": "data:text/html,test#test", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "text/html,test", + "search": "", + "hash": "#test" + }, + { + "input": "tel:1234567890", + "base": "http://example.org/foo/bar", + "href": "tel:1234567890", + "origin": "null", + "protocol": "tel:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "1234567890", + "search": "", + "hash": "" + }, + "# Based on https://felixfbecker.github.io/whatwg-url-custom-host-repro/", + { + "input": "ssh://example.com/foo/bar.git", + "base": "http://example.org/", + "href": "ssh://example.com/foo/bar.git", + "origin": "null", + "protocol": "ssh:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/bar.git", + "search": "", + "hash": "" + }, + "# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/file.html", + { + "input": "file:c:\\foo\\bar.html", + "base": "file:///tmp/mock/path", + "href": "file:///c:/foo/bar.html", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/c:/foo/bar.html", + "search": "", + "hash": "" + }, + { + "input": " File:c|////foo\\bar.html", + "base": "file:///tmp/mock/path", + "href": "file:///c:////foo/bar.html", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/c:////foo/bar.html", + "search": "", + "hash": "" + }, + { + "input": "C|/foo/bar", + "base": "file:///tmp/mock/path", + "href": "file:///C:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "/C|\\foo\\bar", + "base": "file:///tmp/mock/path", + "href": "file:///C:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "//C|/foo/bar", + "base": "file:///tmp/mock/path", + "href": "file:///C:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "//server/file", + "base": "file:///tmp/mock/path", + "href": "file://server/file", + "protocol": "file:", + "username": "", + "password": "", + "host": "server", + "hostname": "server", + "port": "", + "pathname": "/file", + "search": "", + "hash": "" + }, + { + "input": "\\\\server\\file", + "base": "file:///tmp/mock/path", + "href": "file://server/file", + "protocol": "file:", + "username": "", + "password": "", + "host": "server", + "hostname": "server", + "port": "", + "pathname": "/file", + "search": "", + "hash": "" + }, + { + "input": "/\\server/file", + "base": "file:///tmp/mock/path", + "href": "file://server/file", + "protocol": "file:", + "username": "", + "password": "", + "host": "server", + "hostname": "server", + "port": "", + "pathname": "/file", + "search": "", + "hash": "" + }, + { + "input": "file:///foo/bar.txt", + "base": "file:///tmp/mock/path", + "href": "file:///foo/bar.txt", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/foo/bar.txt", + "search": "", + "hash": "" + }, + { + "input": "file:///home/me", + "base": "file:///tmp/mock/path", + "href": "file:///home/me", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/home/me", + "search": "", + "hash": "" + }, + { + "input": "//", + "base": "file:///tmp/mock/path", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///", + "base": "file:///tmp/mock/path", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///test", + "base": "file:///tmp/mock/path", + "href": "file:///test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "file://test", + "base": "file:///tmp/mock/path", + "href": "file://test/", + "protocol": "file:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file://localhost", + "base": "file:///tmp/mock/path", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file://localhost/", + "base": "file:///tmp/mock/path", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file://localhost/test", + "base": "file:///tmp/mock/path", + "href": "file:///test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "test", + "base": "file:///tmp/mock/path", + "href": "file:///tmp/mock/test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/tmp/mock/test", + "search": "", + "hash": "" + }, + { + "input": "file:test", + "base": "file:///tmp/mock/path", + "href": "file:///tmp/mock/test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/tmp/mock/test", + "search": "", + "hash": "" + }, + { + "input": "file:///w|m", + "base": null, + "href": "file:///w|m", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/w|m", + "search": "", + "hash": "" + }, + { + "input": "file:///w||m", + "base": null, + "href": "file:///w||m", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/w||m", + "search": "", + "hash": "" + }, + { + "input": "file:///w|/m", + "base": null, + "href": "file:///w:/m", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/w:/m", + "search": "", + "hash": "" + }, + { + "input": "file:C|/m/", + "base": null, + "href": "file:///C:/m/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/m/", + "search": "", + "hash": "" + }, + { + "input": "file:C||/m/", + "base": null, + "href": "file:///C||/m/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C||/m/", + "search": "", + "hash": "" + }, + "# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/script-tests/path.js", + { + "input": "http://example.com/././foo", + "base": null, + "href": "http://example.com/foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/./.foo", + "base": null, + "href": "http://example.com/.foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/.foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/.", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/./", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar/..", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar/../", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/..bar", + "base": null, + "href": "http://example.com/foo/..bar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/..bar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar/../ton", + "base": null, + "href": "http://example.com/foo/ton", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/ton", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar/../ton/../../a", + "base": null, + "href": "http://example.com/a", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/../../..", + "base": null, + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/../../../ton", + "base": null, + "href": "http://example.com/ton", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/ton", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/%2e", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/%2e%2", + "base": null, + "href": "http://example.com/foo/%2e%2", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/%2e%2", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/%2e./%2e%2e/.%2e/%2e.bar", + "base": null, + "href": "http://example.com/%2e.bar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%2e.bar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com////../..", + "base": null, + "href": "http://example.com//", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar//../..", + "base": null, + "href": "http://example.com/foo/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo/bar//..", + "base": null, + "href": "http://example.com/foo/bar/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo/bar/", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo", + "base": null, + "href": "http://example.com/foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/%20foo", + "base": null, + "href": "http://example.com/%20foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%20foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%", + "base": null, + "href": "http://example.com/foo%", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%2", + "base": null, + "href": "http://example.com/foo%2", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%2", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%2zbar", + "base": null, + "href": "http://example.com/foo%2zbar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%2zbar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%2©zbar", + "base": null, + "href": "http://example.com/foo%2%C3%82%C2%A9zbar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%2%C3%82%C2%A9zbar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%41%7a", + "base": null, + "href": "http://example.com/foo%41%7a", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%41%7a", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo\t\u0091%91", + "base": null, + "href": "http://example.com/foo%C2%91%91", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%C2%91%91", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo%00%51", + "base": null, + "href": "http://example.com/foo%00%51", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foo%00%51", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/(%28:%3A%29)", + "base": null, + "href": "http://example.com/(%28:%3A%29)", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/(%28:%3A%29)", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/%3A%3a%3C%3c", + "base": null, + "href": "http://example.com/%3A%3a%3C%3c", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%3A%3a%3C%3c", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/foo\tbar", + "base": null, + "href": "http://example.com/foobar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/foobar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com\\\\foo\\\\bar", + "base": null, + "href": "http://example.com//foo//bar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "//foo//bar", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd", + "base": null, + "href": "http://example.com/%7Ffp3%3Eju%3Dduvgw%3Dd", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%7Ffp3%3Eju%3Dduvgw%3Dd", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/@asdf%40", + "base": null, + "href": "http://example.com/@asdf%40", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/@asdf%40", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/你好你好", + "base": null, + "href": "http://example.com/%E4%BD%A0%E5%A5%BD%E4%BD%A0%E5%A5%BD", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%E4%BD%A0%E5%A5%BD%E4%BD%A0%E5%A5%BD", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/‥/foo", + "base": null, + "href": "http://example.com/%E2%80%A5/foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%E2%80%A5/foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com//foo", + "base": null, + "href": "http://example.com/%EF%BB%BF/foo", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%EF%BB%BF/foo", + "search": "", + "hash": "" + }, + { + "input": "http://example.com/‮/foo/‭/bar", + "base": null, + "href": "http://example.com/%E2%80%AE/foo/%E2%80%AD/bar", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/%E2%80%AE/foo/%E2%80%AD/bar", + "search": "", + "hash": "" + }, + "# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/script-tests/relative.js", + { + "input": "http://www.google.com/foo?bar=baz#", + "base": null, + "href": "http://www.google.com/foo?bar=baz#", + "origin": "http://www.google.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.google.com", + "hostname": "www.google.com", + "port": "", + "pathname": "/foo", + "search": "?bar=baz", + "hash": "" + }, + { + "input": "http://www.google.com/foo?bar=baz# »", + "base": null, + "href": "http://www.google.com/foo?bar=baz#%20%C2%BB", + "origin": "http://www.google.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.google.com", + "hostname": "www.google.com", + "port": "", + "pathname": "/foo", + "search": "?bar=baz", + "hash": "#%20%C2%BB" + }, + { + "input": "data:test# »", + "base": null, + "href": "data:test#%20%C2%BB", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "test", + "search": "", + "hash": "#%20%C2%BB" + }, + { + "input": "http://www.google.com", + "base": null, + "href": "http://www.google.com/", + "origin": "http://www.google.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.google.com", + "hostname": "www.google.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://192.0x00A80001", + "base": null, + "href": "http://192.168.0.1/", + "origin": "http://192.168.0.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.0.1", + "hostname": "192.168.0.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://www/foo%2Ehtml", + "base": null, + "href": "http://www/foo%2Ehtml", + "origin": "http://www", + "protocol": "http:", + "username": "", + "password": "", + "host": "www", + "hostname": "www", + "port": "", + "pathname": "/foo%2Ehtml", + "search": "", + "hash": "" + }, + { + "input": "http://www/foo/%2E/html", + "base": null, + "href": "http://www/foo/html", + "origin": "http://www", + "protocol": "http:", + "username": "", + "password": "", + "host": "www", + "hostname": "www", + "port": "", + "pathname": "/foo/html", + "search": "", + "hash": "" + }, + { + "input": "http://user:pass@/", + "base": null, + "failure": true + }, + { + "input": "http://%25DOMAIN:foobar@foodomain.com/", + "base": null, + "href": "http://%25DOMAIN:foobar@foodomain.com/", + "origin": "http://foodomain.com", + "protocol": "http:", + "username": "%25DOMAIN", + "password": "foobar", + "host": "foodomain.com", + "hostname": "foodomain.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:\\\\www.google.com\\foo", + "base": null, + "href": "http://www.google.com/foo", + "origin": "http://www.google.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.google.com", + "hostname": "www.google.com", + "port": "", + "pathname": "/foo", + "search": "", + "hash": "" + }, + { + "input": "http://foo:80/", + "base": null, + "href": "http://foo/", + "origin": "http://foo", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://foo:81/", + "base": null, + "href": "http://foo:81/", + "origin": "http://foo:81", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo:81", + "hostname": "foo", + "port": "81", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "httpa://foo:80/", + "base": null, + "href": "httpa://foo:80/", + "origin": "null", + "protocol": "httpa:", + "username": "", + "password": "", + "host": "foo:80", + "hostname": "foo", + "port": "80", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://foo:-80/", + "base": null, + "failure": true + }, + { + "input": "https://foo:443/", + "base": null, + "href": "https://foo/", + "origin": "https://foo", + "protocol": "https:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://foo:80/", + "base": null, + "href": "https://foo:80/", + "origin": "https://foo:80", + "protocol": "https:", + "username": "", + "password": "", + "host": "foo:80", + "hostname": "foo", + "port": "80", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ftp://foo:21/", + "base": null, + "href": "ftp://foo/", + "origin": "ftp://foo", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ftp://foo:80/", + "base": null, + "href": "ftp://foo:80/", + "origin": "ftp://foo:80", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "foo:80", + "hostname": "foo", + "port": "80", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "gopher://foo:70/", + "base": null, + "href": "gopher://foo:70/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "foo:70", + "hostname": "foo", + "port": "70", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "gopher://foo:443/", + "base": null, + "href": "gopher://foo:443/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "foo:443", + "hostname": "foo", + "port": "443", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ws://foo:80/", + "base": null, + "href": "ws://foo/", + "origin": "ws://foo", + "protocol": "ws:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ws://foo:81/", + "base": null, + "href": "ws://foo:81/", + "origin": "ws://foo:81", + "protocol": "ws:", + "username": "", + "password": "", + "host": "foo:81", + "hostname": "foo", + "port": "81", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ws://foo:443/", + "base": null, + "href": "ws://foo:443/", + "origin": "ws://foo:443", + "protocol": "ws:", + "username": "", + "password": "", + "host": "foo:443", + "hostname": "foo", + "port": "443", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ws://foo:815/", + "base": null, + "href": "ws://foo:815/", + "origin": "ws://foo:815", + "protocol": "ws:", + "username": "", + "password": "", + "host": "foo:815", + "hostname": "foo", + "port": "815", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss://foo:80/", + "base": null, + "href": "wss://foo:80/", + "origin": "wss://foo:80", + "protocol": "wss:", + "username": "", + "password": "", + "host": "foo:80", + "hostname": "foo", + "port": "80", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss://foo:81/", + "base": null, + "href": "wss://foo:81/", + "origin": "wss://foo:81", + "protocol": "wss:", + "username": "", + "password": "", + "host": "foo:81", + "hostname": "foo", + "port": "81", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss://foo:443/", + "base": null, + "href": "wss://foo/", + "origin": "wss://foo", + "protocol": "wss:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss://foo:815/", + "base": null, + "href": "wss://foo:815/", + "origin": "wss://foo:815", + "protocol": "wss:", + "username": "", + "password": "", + "host": "foo:815", + "hostname": "foo", + "port": "815", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/example.com/", + "base": null, + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ftp:/example.com/", + "base": null, + "href": "ftp://example.com/", + "origin": "ftp://example.com", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https:/example.com/", + "base": null, + "href": "https://example.com/", + "origin": "https://example.com", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "madeupscheme:/example.com/", + "base": null, + "href": "madeupscheme:/example.com/", + "origin": "null", + "protocol": "madeupscheme:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "file:/example.com/", + "base": null, + "href": "file:///example.com/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "ftps:/example.com/", + "base": null, + "href": "ftps:/example.com/", + "origin": "null", + "protocol": "ftps:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "gopher:/example.com/", + "base": null, + "href": "gopher:/example.com/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "ws:/example.com/", + "base": null, + "href": "ws://example.com/", + "origin": "ws://example.com", + "protocol": "ws:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss:/example.com/", + "base": null, + "href": "wss://example.com/", + "origin": "wss://example.com", + "protocol": "wss:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "data:/example.com/", + "base": null, + "href": "data:/example.com/", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "javascript:/example.com/", + "base": null, + "href": "javascript:/example.com/", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "mailto:/example.com/", + "base": null, + "href": "mailto:/example.com/", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/example.com/", + "search": "", + "hash": "" + }, + { + "input": "http:example.com/", + "base": null, + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ftp:example.com/", + "base": null, + "href": "ftp://example.com/", + "origin": "ftp://example.com", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https:example.com/", + "base": null, + "href": "https://example.com/", + "origin": "https://example.com", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "madeupscheme:example.com/", + "base": null, + "href": "madeupscheme:example.com/", + "origin": "null", + "protocol": "madeupscheme:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "ftps:example.com/", + "base": null, + "href": "ftps:example.com/", + "origin": "null", + "protocol": "ftps:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "gopher:example.com/", + "base": null, + "href": "gopher:example.com/", + "origin": "null", + "protocol": "gopher:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "ws:example.com/", + "base": null, + "href": "ws://example.com/", + "origin": "ws://example.com", + "protocol": "ws:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "wss:example.com/", + "base": null, + "href": "wss://example.com/", + "origin": "wss://example.com", + "protocol": "wss:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "data:example.com/", + "base": null, + "href": "data:example.com/", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "javascript:example.com/", + "base": null, + "href": "javascript:example.com/", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "mailto:example.com/", + "base": null, + "href": "mailto:example.com/", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "example.com/", + "search": "", + "hash": "" + }, + { + "input": "https://example.com/aaa/bbb/%2e%2e?query", + "base": null, + "href": "https://example.com/aaa/?query", + "origin": "https://example.com", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/aaa/", + "search": "?query", + "hash": "" + }, + "# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/segments-userinfo-vs-host.html", + { + "input": "http:@www.example.com", + "base": null, + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/@www.example.com", + "base": null, + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://@www.example.com", + "base": null, + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:a:b@www.example.com", + "base": null, + "href": "http://a:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/a:b@www.example.com", + "base": null, + "href": "http://a:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a:b@www.example.com", + "base": null, + "href": "http://a:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://@pple.com", + "base": null, + "href": "http://pple.com/", + "origin": "http://pple.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "pple.com", + "hostname": "pple.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http::b@www.example.com", + "base": null, + "href": "http://:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/:b@www.example.com", + "base": null, + "href": "http://:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://:b@www.example.com", + "base": null, + "href": "http://:b@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "b", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/:@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http://user@/www.example.com", + "base": null, + "failure": true + }, + { + "input": "http:@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http:/@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http://@/www.example.com", + "base": null, + "failure": true + }, + { + "input": "https:@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http:a:b@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http:/a:b@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http://a:b@/www.example.com", + "base": null, + "failure": true + }, + { + "input": "http::@/www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http:a:@www.example.com", + "base": null, + "href": "http://a@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:/a:@www.example.com", + "base": null, + "href": "http://a@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a:@www.example.com", + "base": null, + "href": "http://a@www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "a", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://www.@pple.com", + "base": null, + "href": "http://www.@pple.com/", + "origin": "http://pple.com", + "protocol": "http:", + "username": "www.", + "password": "", + "host": "pple.com", + "hostname": "pple.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http:@:www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http:/@:www.example.com", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "http://@:www.example.com", + "base": null, + "failure": true + }, + { + "input": "http://:@www.example.com", + "base": null, + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# Others", + { + "input": "/", + "base": "http://www.example.com/test", + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "/test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/test.txt", + "search": "", + "hash": "" + }, + { + "input": ".", + "base": "http://www.example.com/test", + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "http://www.example.com/test", + "href": "http://www.example.com/", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/test.txt", + "search": "", + "hash": "" + }, + { + "input": "./test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/test.txt", + "search": "", + "hash": "" + }, + { + "input": "../test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/test.txt", + "search": "", + "hash": "" + }, + { + "input": "../aaa/test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/aaa/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/aaa/test.txt", + "search": "", + "hash": "" + }, + { + "input": "../../test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/test.txt", + "search": "", + "hash": "" + }, + { + "input": "中/test.txt", + "base": "http://www.example.com/test", + "href": "http://www.example.com/%E4%B8%AD/test.txt", + "origin": "http://www.example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example.com", + "hostname": "www.example.com", + "port": "", + "pathname": "/%E4%B8%AD/test.txt", + "search": "", + "hash": "" + }, + { + "input": "http://www.example2.com", + "base": "http://www.example.com/test", + "href": "http://www.example2.com/", + "origin": "http://www.example2.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example2.com", + "hostname": "www.example2.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "//www.example2.com", + "base": "http://www.example.com/test", + "href": "http://www.example2.com/", + "origin": "http://www.example2.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.example2.com", + "hostname": "www.example2.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file:...", + "base": "http://www.example.com/test", + "href": "file:///...", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/...", + "search": "", + "hash": "" + }, + { + "input": "file:..", + "base": "http://www.example.com/test", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file:a", + "base": "http://www.example.com/test", + "href": "file:///a", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "file:.", + "base": null, + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file:.", + "base": "http://www.example.com/test", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# Based on http://trac.webkit.org/browser/trunk/LayoutTests/fast/url/host.html", + "Basic canonicalization, uppercase should be converted to lowercase", + { + "input": "http://ExAmPlE.CoM", + "base": "http://other.com/", + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://example example.com", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://Goo%20 goo%7C|.com", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[:]", + "base": "http://other.com/", + "failure": true + }, + "U+3000 is mapped to U+0020 (space) which is disallowed", + { + "input": "http://GOO\u00a0\u3000goo.com", + "base": "http://other.com/", + "failure": true + }, + "Other types of space (no-break, zero-width, zero-width-no-break) are name-prepped away to nothing. U+200B, U+2060, and U+FEFF, are ignored", + { + "input": "http://GOO\u200b\u2060\ufeffgoo.com", + "base": "http://other.com/", + "href": "http://googoo.com/", + "origin": "http://googoo.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "googoo.com", + "hostname": "googoo.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Leading and trailing C0 control or space", + { + "input": "\u0000\u001b\u0004\u0012 http://example.com/\u001f \u000d ", + "base": null, + "href": "http://example.com/", + "origin": "http://example.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special:opaque ", + "base": null, + "href": "non-special:opaque", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque", + "search": "", + "hash": "" + }, + { + "input": "non-special:opaque ?hi", + "base": null, + "href": "non-special:opaque %20?hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque %20", + "search": "?hi", + "hash": "" + }, + { + "input": "non-special:opaque #hi", + "base": null, + "href": "non-special:opaque %20#hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque %20", + "search": "", + "hash": "#hi" + }, + { + "input": "non-special:opaque x?hi", + "base": null, + "href": "non-special:opaque x?hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque x", + "search": "?hi", + "hash": "" + }, + { + "input": "non-special:opaque x#hi", + "base": null, + "href": "non-special:opaque x#hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque x", + "search": "", + "hash": "#hi" + }, + { + "input": "non-special:opaque \t\t \t#hi", + "base": null, + "href": "non-special:opaque %20#hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque %20", + "search": "", + "hash": "#hi" + }, + { + "input": "non-special:opaque \t\t #hi", + "base": null, + "href": "non-special:opaque %20#hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque %20", + "search": "", + "hash": "#hi" + }, + { + "input": "non-special:opaque\t\t \r #hi", + "base": null, + "href": "non-special:opaque %20#hi", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "opaque %20", + "search": "", + "hash": "#hi" + }, + "Ideographic full stop (full-width period for Chinese, etc.) should be treated as a dot. U+3002 is mapped to U+002E (dot)", + { + "input": "http://www.foo。bar.com", + "base": "http://other.com/", + "href": "http://www.foo.bar.com/", + "origin": "http://www.foo.bar.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "www.foo.bar.com", + "hostname": "www.foo.bar.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Invalid unicode characters should fail... U+FDD0 is disallowed; %ef%b7%90 is U+FDD0", + { + "input": "http://\ufdd0zyx.com", + "base": "http://other.com/", + "failure": true + }, + "This is the same as previous but escaped", + { + "input": "http://%ef%b7%90zyx.com", + "base": "http://other.com/", + "failure": true + }, + "U+FFFD", + { + "input": "https://\ufffd", + "base": null, + "failure": true + }, + { + "input": "https://%EF%BF%BD", + "base": null, + "failure": true + }, + { + "input": "https://x/\ufffd?\ufffd#\ufffd", + "base": null, + "href": "https://x/%EF%BF%BD?%EF%BF%BD#%EF%BF%BD", + "origin": "https://x", + "protocol": "https:", + "username": "", + "password": "", + "host": "x", + "hostname": "x", + "port": "", + "pathname": "/%EF%BF%BD", + "search": "?%EF%BF%BD", + "hash": "#%EF%BF%BD" + }, + "Domain is ASCII, but a label is invalid IDNA", + { + "input": "http://a.b.c.xn--pokxncvks", + "base": null, + "href": "http://a.b.c.xn--pokxncvks/", + "origin": "http://a.b.c.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "a.b.c.xn--pokxncvks", + "hostname": "a.b.c.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://10.0.0.xn--pokxncvks", + "base": null, + "href": "http://10.0.0.xn--pokxncvks/", + "origin": "http://10.0.0.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "10.0.0.xn--pokxncvks", + "hostname": "10.0.0.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "IDNA labels should be matched case-insensitively", + { + "input": "http://a.b.c.XN--pokxncvks", + "base": null, + "href": "http://a.b.c.xn--pokxncvks/", + "origin": "http://a.b.c.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "a.b.c.xn--pokxncvks", + "hostname": "a.b.c.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a.b.c.Xn--pokxncvks", + "base": null, + "href": "http://a.b.c.xn--pokxncvks/", + "origin": "http://a.b.c.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "a.b.c.xn--pokxncvks", + "hostname": "a.b.c.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://10.0.0.XN--pokxncvks", + "base": null, + "href": "http://10.0.0.xn--pokxncvks/", + "origin": "http://10.0.0.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "10.0.0.xn--pokxncvks", + "hostname": "10.0.0.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://10.0.0.xN--pokxncvks", + "base": null, + "href": "http://10.0.0.xn--pokxncvks/", + "origin": "http://10.0.0.xn--pokxncvks", + "protocol": "http:", + "username": "", + "password": "", + "host": "10.0.0.xn--pokxncvks", + "hostname": "10.0.0.xn--pokxncvks", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Test name prepping, fullwidth input should be converted to ASCII and NOT IDN-ized. This is 'Go' in fullwidth UTF-8/UTF-16.", + { + "input": "http://Go.com", + "base": "http://other.com/", + "href": "http://go.com/", + "origin": "http://go.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "go.com", + "hostname": "go.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "URL spec forbids the following. https://www.w3.org/Bugs/Public/show_bug.cgi?id=24257", + { + "input": "http://%41.com", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://%ef%bc%85%ef%bc%94%ef%bc%91.com", + "base": "http://other.com/", + "failure": true + }, + "...%00 in fullwidth should fail (also as escaped UTF-8 input)", + { + "input": "http://%00.com", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://%ef%bc%85%ef%bc%90%ef%bc%90.com", + "base": "http://other.com/", + "failure": true + }, + "Basic IDN support, UTF-8 and UTF-16 input should be converted to IDN", + { + "input": "http://你好你好", + "base": "http://other.com/", + "href": "http://xn--6qqa088eba/", + "origin": "http://xn--6qqa088eba", + "protocol": "http:", + "username": "", + "password": "", + "host": "xn--6qqa088eba", + "hostname": "xn--6qqa088eba", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://faß.ExAmPlE/", + "base": null, + "href": "https://xn--fa-hia.example/", + "origin": "https://xn--fa-hia.example", + "protocol": "https:", + "username": "", + "password": "", + "host": "xn--fa-hia.example", + "hostname": "xn--fa-hia.example", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "sc://faß.ExAmPlE/", + "base": null, + "href": "sc://fa%C3%9F.ExAmPlE/", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "fa%C3%9F.ExAmPlE", + "hostname": "fa%C3%9F.ExAmPlE", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Invalid escaped characters should fail and the percents should be escaped. https://www.w3.org/Bugs/Public/show_bug.cgi?id=24191", + { + "input": "http://%zz%66%a.com", + "base": "http://other.com/", + "failure": true + }, + "If we get an invalid character that has been escaped.", + { + "input": "http://%25", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://hello%00", + "base": "http://other.com/", + "failure": true + }, + "Escaped numbers should be treated like IP addresses if they are.", + { + "input": "http://%30%78%63%30%2e%30%32%35%30.01", + "base": "http://other.com/", + "href": "http://192.168.0.1/", + "origin": "http://192.168.0.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.0.1", + "hostname": "192.168.0.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://%30%78%63%30%2e%30%32%35%30.01%2e", + "base": "http://other.com/", + "href": "http://192.168.0.1/", + "origin": "http://192.168.0.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.0.1", + "hostname": "192.168.0.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://192.168.0.257", + "base": "http://other.com/", + "failure": true + }, + "Invalid escaping in hosts causes failure", + { + "input": "http://%3g%78%63%30%2e%30%32%35%30%2E.01", + "base": "http://other.com/", + "failure": true + }, + "A space in a host causes failure", + { + "input": "http://192.168.0.1 hello", + "base": "http://other.com/", + "failure": true + }, + { + "input": "https://x x:12", + "base": null, + "failure": true + }, + "Fullwidth and escaped UTF-8 fullwidth should still be treated as IP", + { + "input": "http://0Xc0.0250.01", + "base": "http://other.com/", + "href": "http://192.168.0.1/", + "origin": "http://192.168.0.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.0.1", + "hostname": "192.168.0.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Domains with empty labels", + { + "input": "http://./", + "base": null, + "href": "http://./", + "origin": "http://.", + "protocol": "http:", + "username": "", + "password": "", + "host": ".", + "hostname": ".", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://../", + "base": null, + "href": "http://../", + "origin": "http://..", + "protocol": "http:", + "username": "", + "password": "", + "host": "..", + "hostname": "..", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Non-special domains with empty labels", + { + "input": "h://.", + "base": null, + "href": "h://.", + "origin": "null", + "protocol": "h:", + "username": "", + "password": "", + "host": ".", + "hostname": ".", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + "Broken IPv6", + { + "input": "http://[www.google.com]/", + "base": null, + "failure": true + }, + { + "input": "http://[google.com]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::1.2.3.4x]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::1.2.3.]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::1.2.]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::.1.2]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::1.]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::.1]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://[::%31]", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://%5B::1]", + "base": "http://other.com/", + "failure": true + }, + "Misc Unicode", + { + "input": "http://foo:💩@example.com/bar", + "base": "http://other.com/", + "href": "http://foo:%F0%9F%92%A9@example.com/bar", + "origin": "http://example.com", + "protocol": "http:", + "username": "foo", + "password": "%F0%9F%92%A9", + "host": "example.com", + "hostname": "example.com", + "port": "", + "pathname": "/bar", + "search": "", + "hash": "" + }, + "Astral code point followed by a trailing character in the userinfo", + { + "input": "http://😀x@host/", + "base": null, + "href": "http://%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80x", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://a:😀x@host/", + "base": null, + "href": "http://a:%F0%9F%98%80x@host/", + "origin": "http://host", + "protocol": "http:", + "username": "a", + "password": "%F0%9F%98%80x", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://😀@host/", + "base": null, + "href": "http://%F0%9F%98%80@host/", + "origin": "http://host", + "protocol": "http:", + "username": "%F0%9F%98%80", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://localhost?q=🔥", + "base": null, + "href": "https://localhost/?q=%F0%9F%94%A5", + "origin": "https://localhost", + "protocol": "https:", + "username": "", + "password": "", + "host": "localhost", + "hostname": "localhost", + "port": "", + "pathname": "/", + "search": "?q=%F0%9F%94%A5", + "hash": "" + }, + { + "input": "https://localhost#🔥", + "base": null, + "href": "https://localhost/#%F0%9F%94%A5", + "origin": "https://localhost", + "protocol": "https:", + "username": "", + "password": "", + "host": "localhost", + "hostname": "localhost", + "port": "", + "pathname": "/", + "search": "", + "hash": "#%F0%9F%94%A5" + }, + "# resolving a fragment against any scheme succeeds", + { + "input": "#", + "base": "test:test", + "href": "test:test#", + "origin": "null", + "protocol": "test:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "test", + "search": "", + "hash": "" + }, + { + "input": "#x", + "base": "mailto:x@x.com", + "href": "mailto:x@x.com#x", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "x@x.com", + "search": "", + "hash": "#x" + }, + { + "input": "#x", + "base": "data:,", + "href": "data:,#x", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": ",", + "search": "", + "hash": "#x" + }, + { + "input": "#x", + "base": "about:blank", + "href": "about:blank#x", + "origin": "null", + "protocol": "about:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blank", + "search": "", + "hash": "#x" + }, + { + "input": "#x:y", + "base": "about:blank", + "href": "about:blank#x:y", + "origin": "null", + "protocol": "about:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blank", + "search": "", + "hash": "#x:y" + }, + { + "input": "#", + "base": "test:test?test", + "href": "test:test?test#", + "origin": "null", + "protocol": "test:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "test", + "search": "?test", + "hash": "" + }, + "# multiple @ in authority state", + { + "input": "https://@test@test@example:800/", + "base": "http://doesnotmatter/", + "href": "https://%40test%40test@example:800/", + "origin": "https://example:800", + "protocol": "https:", + "username": "%40test%40test", + "password": "", + "host": "example:800", + "hostname": "example", + "port": "800", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://@@@example", + "base": "http://doesnotmatter/", + "href": "https://%40%40@example/", + "origin": "https://example", + "protocol": "https:", + "username": "%40%40", + "password": "", + "host": "example", + "hostname": "example", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "non-az-09 characters", + { + "input": "http://`{}:`{}@h/`{}?`{}", + "base": "http://doesnotmatter/", + "href": "http://%60%7B%7D:%60%7B%7D@h/%60%7B%7D?`{}", + "origin": "http://h", + "protocol": "http:", + "username": "%60%7B%7D", + "password": "%60%7B%7D", + "host": "h", + "hostname": "h", + "port": "", + "pathname": "/%60%7B%7D", + "search": "?`{}", + "hash": "" + }, + "byte is ' and url is special", + { + "input": "http://host/?'", + "base": null, + "href": "http://host/?%27", + "origin": "http://host", + "protocol": "http:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "?%27", + "hash": "" + }, + { + "input": "notspecial://host/?'", + "base": null, + "href": "notspecial://host/?'", + "origin": "null", + "protocol": "notspecial:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/", + "search": "?'", + "hash": "" + }, + "# Credentials in base", + { + "input": "/some/path", + "base": "http://user@example.org/smth", + "href": "http://user@example.org/some/path", + "origin": "http://example.org", + "protocol": "http:", + "username": "user", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/some/path", + "search": "", + "hash": "" + }, + { + "input": "", + "base": "http://user:pass@example.org:21/smth", + "href": "http://user:pass@example.org:21/smth", + "origin": "http://example.org:21", + "protocol": "http:", + "username": "user", + "password": "pass", + "host": "example.org:21", + "hostname": "example.org", + "port": "21", + "pathname": "/smth", + "search": "", + "hash": "" + }, + { + "input": "/some/path", + "base": "http://user:pass@example.org:21/smth", + "href": "http://user:pass@example.org:21/some/path", + "origin": "http://example.org:21", + "protocol": "http:", + "username": "user", + "password": "pass", + "host": "example.org:21", + "hostname": "example.org", + "port": "21", + "pathname": "/some/path", + "search": "", + "hash": "" + }, + "# a set of tests designed by zcorpan for relative URLs with unknown schemes", + { + "input": "i", + "base": "sc:sd", + "failure": true + }, + { + "input": "i", + "base": "sc:sd/sd", + "failure": true + }, + { + "input": "i", + "base": "sc:/pa/pa", + "href": "sc:/pa/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/i", + "search": "", + "hash": "" + }, + { + "input": "i", + "base": "sc://ho/pa", + "href": "sc://ho/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "ho", + "hostname": "ho", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "i", + "base": "sc:///pa/pa", + "href": "sc:///pa/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/i", + "search": "", + "hash": "" + }, + { + "input": "../i", + "base": "sc:sd", + "failure": true + }, + { + "input": "../i", + "base": "sc:sd/sd", + "failure": true + }, + { + "input": "../i", + "base": "sc:/pa/pa", + "href": "sc:/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "../i", + "base": "sc://ho/pa", + "href": "sc://ho/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "ho", + "hostname": "ho", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "../i", + "base": "sc:///pa/pa", + "href": "sc:///i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "/i", + "base": "sc:sd", + "failure": true + }, + { + "input": "/i", + "base": "sc:sd/sd", + "failure": true + }, + { + "input": "/i", + "base": "sc:/pa/pa", + "href": "sc:/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "/i", + "base": "sc://ho/pa", + "href": "sc://ho/i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "ho", + "hostname": "ho", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "/i", + "base": "sc:///pa/pa", + "href": "sc:///i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/i", + "search": "", + "hash": "" + }, + { + "input": "?i", + "base": "sc:sd", + "failure": true + }, + { + "input": "?i", + "base": "sc:sd/sd", + "failure": true + }, + { + "input": "?i", + "base": "sc:/pa/pa", + "href": "sc:/pa/pa?i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/pa", + "search": "?i", + "hash": "" + }, + { + "input": "?i", + "base": "sc://ho/pa", + "href": "sc://ho/pa?i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "ho", + "hostname": "ho", + "port": "", + "pathname": "/pa", + "search": "?i", + "hash": "" + }, + { + "input": "?i", + "base": "sc:///pa/pa", + "href": "sc:///pa/pa?i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/pa", + "search": "?i", + "hash": "" + }, + { + "input": "#i", + "base": "sc:sd", + "href": "sc:sd#i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "sd", + "search": "", + "hash": "#i" + }, + { + "input": "#i", + "base": "sc:sd/sd", + "href": "sc:sd/sd#i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "sd/sd", + "search": "", + "hash": "#i" + }, + { + "input": "#i", + "base": "sc:/pa/pa", + "href": "sc:/pa/pa#i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/pa", + "search": "", + "hash": "#i" + }, + { + "input": "#i", + "base": "sc://ho/pa", + "href": "sc://ho/pa#i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "ho", + "hostname": "ho", + "port": "", + "pathname": "/pa", + "search": "", + "hash": "#i" + }, + { + "input": "#i", + "base": "sc:///pa/pa", + "href": "sc:///pa/pa#i", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pa/pa", + "search": "", + "hash": "#i" + }, + "# make sure that relative URL logic works on known typically non-relative schemes too", + { + "input": "about:/../", + "base": null, + "href": "about:/", + "origin": "null", + "protocol": "about:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "data:/../", + "base": null, + "href": "data:/", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "javascript:/../", + "base": null, + "href": "javascript:/", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "mailto:/../", + "base": null, + "href": "mailto:/", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# unknown schemes and their hosts", + { + "input": "sc://ñ.test/", + "base": null, + "href": "sc://%C3%B1.test/", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1.test", + "hostname": "%C3%B1.test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "sc://%/", + "base": null, + "href": "sc://%/", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%", + "hostname": "%", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "sc://@/", + "base": null, + "failure": true + }, + { + "input": "sc://te@s:t@/", + "base": null, + "failure": true + }, + { + "input": "sc://:/", + "base": null, + "failure": true + }, + { + "input": "sc://:12/", + "base": null, + "failure": true + }, + { + "input": "x", + "base": "sc://ñ", + "href": "sc://%C3%B1/x", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "/x", + "search": "", + "hash": "" + }, + "# unknown schemes and backslashes", + { + "input": "sc:\\../", + "base": null, + "href": "sc:\\../", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "\\../", + "search": "", + "hash": "" + }, + "# unknown scheme with path looking like a password", + { + "input": "sc::a@example.net", + "base": null, + "href": "sc::a@example.net", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": ":a@example.net", + "search": "", + "hash": "" + }, + "# unknown scheme with bogus percent-encoding", + { + "input": "wow:%NBD", + "base": null, + "href": "wow:%NBD", + "origin": "null", + "protocol": "wow:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "%NBD", + "search": "", + "hash": "" + }, + { + "input": "wow:%1G", + "base": null, + "href": "wow:%1G", + "origin": "null", + "protocol": "wow:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "%1G", + "search": "", + "hash": "" + }, + "# unknown scheme with non-URL characters", + { + "input": "wow:\uFFFF", + "base": null, + "href": "wow:%EF%BF%BF", + "origin": "null", + "protocol": "wow:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "%EF%BF%BF", + "search": "", + "hash": "" + }, + "Forbidden host code points", + { + "input": "sc://a\u0000b/", + "base": null, + "failure": true + }, + { + "input": "sc://a b/", + "base": null, + "failure": true + }, + { + "input": "sc://ab", + "base": null, + "failure": true + }, + { + "input": "sc://a[b/", + "base": null, + "failure": true + }, + { + "input": "sc://a\\b/", + "base": null, + "failure": true + }, + { + "input": "sc://a]b/", + "base": null, + "failure": true + }, + { + "input": "sc://a^b", + "base": null, + "failure": true + }, + { + "input": "sc://a|b/", + "base": null, + "failure": true + }, + "Forbidden host codepoints: tabs and newlines are removed during preprocessing", + { + "input": "foo://ho\u0009st/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"foo://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + { + "input": "foo://ho\u000Ast/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"foo://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + { + "input": "foo://ho\u000Dst/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"foo://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + "Forbidden domain code-points", + { + "input": "http://a\u0000b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0001b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0002b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0003b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0004b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0005b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0006b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0007b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0008b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u000Bb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u000Cb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u000Eb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u000Fb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0010b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0011b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0012b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0013b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0014b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0015b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0016b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0017b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0018b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u0019b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Ab/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Bb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Cb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Db/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Eb/", + "base": null, + "failure": true + }, + { + "input": "http://a\u001Fb/", + "base": null, + "failure": true + }, + { + "input": "http://a b/", + "base": null, + "failure": true + }, + { + "input": "http://a%b/", + "base": null, + "failure": true + }, + { + "input": "http://ab", + "base": null, + "failure": true + }, + { + "input": "http://a[b/", + "base": null, + "failure": true + }, + { + "input": "http://a]b/", + "base": null, + "failure": true + }, + { + "input": "http://a^b", + "base": null, + "failure": true + }, + { + "input": "http://a|b/", + "base": null, + "failure": true + }, + { + "input": "http://a\u007Fb/", + "base": null, + "failure": true + }, + "Forbidden domain codepoints: tabs and newlines are removed during preprocessing", + { + "input": "http://ho\u0009st/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"http://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "http:", + "search": "", + "username": "" + }, + { + "input": "http://ho\u000Ast/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"http://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "http:", + "search": "", + "username": "" + }, + { + "input": "http://ho\u000Dst/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href":"http://host/", + "password": "", + "pathname": "/", + "port":"", + "protocol": "http:", + "search": "", + "username": "" + }, + "Encoded forbidden domain codepoints in special URLs", + { + "input": "http://ho%00st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%01st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%02st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%03st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%04st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%05st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%06st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%07st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%08st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%09st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Ast/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Bst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Cst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Dst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Est/", + "base": null, + "failure": true + }, + { + "input": "http://ho%0Fst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%10st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%11st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%12st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%13st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%14st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%15st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%16st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%17st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%18st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%19st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Ast/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Bst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Cst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Dst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Est/", + "base": null, + "failure": true + }, + { + "input": "http://ho%1Fst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%20st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%23st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%25st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%2Fst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%3Ast/", + "base": null, + "failure": true + }, + { + "input": "http://ho%3Cst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%3Est/", + "base": null, + "failure": true + }, + { + "input": "http://ho%3Fst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%40st/", + "base": null, + "failure": true + }, + { + "input": "http://ho%5Bst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%5Cst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%5Dst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%7Cst/", + "base": null, + "failure": true + }, + { + "input": "http://ho%7Fst/", + "base": null, + "failure": true + }, + "Allowed host/domain code points", + { + "input": "http://!\"$&'()*+,-.;=_`{}~/", + "base": null, + "href": "http://!\"$&'()*+,-.;=_`{}~/", + "origin": "http://!\"$&'()*+,-.;=_`{}~", + "protocol": "http:", + "username": "", + "password": "", + "host": "!\"$&'()*+,-.;=_`{}~", + "hostname": "!\"$&'()*+,-.;=_`{}~", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "sc://\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u000B\u000C\u000E\u000F\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001A\u001B\u001C\u001D\u001E\u001F\u007F!\"$%&'()*+,-.;=_`{}~/", + "base": null, + "href": "sc://%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%7F!\"$%&'()*+,-.;=_`{}~/", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%7F!\"$%&'()*+,-.;=_`{}~", + "hostname": "%01%02%03%04%05%06%07%08%0B%0C%0E%0F%10%11%12%13%14%15%16%17%18%19%1A%1B%1C%1D%1E%1F%7F!\"$%&'()*+,-.;=_`{}~", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# Hosts and percent-encoding", + { + "input": "ftp://example.com%80/", + "base": null, + "failure": true + }, + { + "input": "ftp://example.com%A0/", + "base": null, + "failure": true + }, + { + "input": "https://example.com%80/", + "base": null, + "failure": true + }, + { + "input": "https://example.com%A0/", + "base": null, + "failure": true + }, + { + "input": "ftp://%e2%98%83", + "base": null, + "href": "ftp://xn--n3h/", + "origin": "ftp://xn--n3h", + "protocol": "ftp:", + "username": "", + "password": "", + "host": "xn--n3h", + "hostname": "xn--n3h", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://%e2%98%83", + "base": null, + "href": "https://xn--n3h/", + "origin": "https://xn--n3h", + "protocol": "https:", + "username": "", + "password": "", + "host": "xn--n3h", + "hostname": "xn--n3h", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# tests from jsdom/whatwg-url designed for code coverage", + { + "input": "http://127.0.0.1:10100/relative_import.html", + "base": null, + "href": "http://127.0.0.1:10100/relative_import.html", + "origin": "http://127.0.0.1:10100", + "protocol": "http:", + "username": "", + "password": "", + "host": "127.0.0.1:10100", + "hostname": "127.0.0.1", + "port": "10100", + "pathname": "/relative_import.html", + "search": "", + "hash": "" + }, + { + "input": "http://facebook.com/?foo=%7B%22abc%22", + "base": null, + "href": "http://facebook.com/?foo=%7B%22abc%22", + "origin": "http://facebook.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "facebook.com", + "hostname": "facebook.com", + "port": "", + "pathname": "/", + "search": "?foo=%7B%22abc%22", + "hash": "" + }, + { + "input": "https://localhost:3000/jqueryui@1.2.3", + "base": null, + "href": "https://localhost:3000/jqueryui@1.2.3", + "origin": "https://localhost:3000", + "protocol": "https:", + "username": "", + "password": "", + "host": "localhost:3000", + "hostname": "localhost", + "port": "3000", + "pathname": "/jqueryui@1.2.3", + "search": "", + "hash": "" + }, + "# tab/LF/CR", + { + "input": "h\tt\nt\rp://h\to\ns\rt:9\t0\n0\r0/p\ta\nt\rh?q\tu\ne\rry#f\tr\na\rg", + "base": null, + "href": "http://host:9000/path?query#frag", + "origin": "http://host:9000", + "protocol": "http:", + "username": "", + "password": "", + "host": "host:9000", + "hostname": "host", + "port": "9000", + "pathname": "/path", + "search": "?query", + "hash": "#frag" + }, + "# Stringification of URL.searchParams", + { + "input": "?a=b&c=d", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar?a=b&c=d", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "?a=b&c=d", + "searchParams": "a=b&c=d", + "hash": "" + }, + { + "input": "??a=b&c=d", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar??a=b&c=d", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "??a=b&c=d", + "searchParams": "%3Fa=b&c=d", + "hash": "" + }, + "# Scheme only", + { + "input": "http:", + "base": "http://example.org/foo/bar", + "href": "http://example.org/foo/bar", + "origin": "http://example.org", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/foo/bar", + "search": "", + "searchParams": "", + "hash": "" + }, + { + "input": "http:", + "base": "https://example.org/foo/bar", + "failure": true + }, + { + "input": "sc:", + "base": "https://example.org/foo/bar", + "href": "sc:", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "", + "search": "", + "searchParams": "", + "hash": "" + }, + "# Percent encoding of fragments", + { + "input": "http://foo.bar/baz?qux#foo\bbar", + "base": null, + "href": "http://foo.bar/baz?qux#foo%08bar", + "origin": "http://foo.bar", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.bar", + "hostname": "foo.bar", + "port": "", + "pathname": "/baz", + "search": "?qux", + "searchParams": "qux=", + "hash": "#foo%08bar" + }, + { + "input": "http://foo.bar/baz?qux#foo\"bar", + "base": null, + "href": "http://foo.bar/baz?qux#foo%22bar", + "origin": "http://foo.bar", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.bar", + "hostname": "foo.bar", + "port": "", + "pathname": "/baz", + "search": "?qux", + "searchParams": "qux=", + "hash": "#foo%22bar" + }, + { + "input": "http://foo.bar/baz?qux#foobar", + "base": null, + "href": "http://foo.bar/baz?qux#foo%3Ebar", + "origin": "http://foo.bar", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.bar", + "hostname": "foo.bar", + "port": "", + "pathname": "/baz", + "search": "?qux", + "searchParams": "qux=", + "hash": "#foo%3Ebar" + }, + { + "input": "http://foo.bar/baz?qux#foo`bar", + "base": null, + "href": "http://foo.bar/baz?qux#foo%60bar", + "origin": "http://foo.bar", + "protocol": "http:", + "username": "", + "password": "", + "host": "foo.bar", + "hostname": "foo.bar", + "port": "", + "pathname": "/baz", + "search": "?qux", + "searchParams": "qux=", + "hash": "#foo%60bar" + }, + "# IPv4 parsing (via https://github.com/nodejs/node/pull/10317)", + { + "input": "http://1.2.3.4/", + "base": "http://other.com/", + "href": "http://1.2.3.4/", + "origin": "http://1.2.3.4", + "protocol": "http:", + "username": "", + "password": "", + "host": "1.2.3.4", + "hostname": "1.2.3.4", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://1.2.3.4./", + "base": "http://other.com/", + "href": "http://1.2.3.4/", + "origin": "http://1.2.3.4", + "protocol": "http:", + "username": "", + "password": "", + "host": "1.2.3.4", + "hostname": "1.2.3.4", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://192.168.257", + "base": "http://other.com/", + "href": "http://192.168.1.1/", + "origin": "http://192.168.1.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.1.1", + "hostname": "192.168.1.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://192.168.257.", + "base": "http://other.com/", + "href": "http://192.168.1.1/", + "origin": "http://192.168.1.1", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.1.1", + "hostname": "192.168.1.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://192.168.257.com", + "base": "http://other.com/", + "href": "http://192.168.257.com/", + "origin": "http://192.168.257.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "192.168.257.com", + "hostname": "192.168.257.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://256", + "base": "http://other.com/", + "href": "http://0.0.1.0/", + "origin": "http://0.0.1.0", + "protocol": "http:", + "username": "", + "password": "", + "host": "0.0.1.0", + "hostname": "0.0.1.0", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://256.com", + "base": "http://other.com/", + "href": "http://256.com/", + "origin": "http://256.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "256.com", + "hostname": "256.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://999999999", + "base": "http://other.com/", + "href": "http://59.154.201.255/", + "origin": "http://59.154.201.255", + "protocol": "http:", + "username": "", + "password": "", + "host": "59.154.201.255", + "hostname": "59.154.201.255", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://999999999.", + "base": "http://other.com/", + "href": "http://59.154.201.255/", + "origin": "http://59.154.201.255", + "protocol": "http:", + "username": "", + "password": "", + "host": "59.154.201.255", + "hostname": "59.154.201.255", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://999999999.com", + "base": "http://other.com/", + "href": "http://999999999.com/", + "origin": "http://999999999.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "999999999.com", + "hostname": "999999999.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://10000000000", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://10000000000.com", + "base": "http://other.com/", + "href": "http://10000000000.com/", + "origin": "http://10000000000.com", + "protocol": "http:", + "username": "", + "password": "", + "host": "10000000000.com", + "hostname": "10000000000.com", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://4294967295", + "base": "http://other.com/", + "href": "http://255.255.255.255/", + "origin": "http://255.255.255.255", + "protocol": "http:", + "username": "", + "password": "", + "host": "255.255.255.255", + "hostname": "255.255.255.255", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://4294967296", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://18446744073709551616", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://18446744075840258049", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://0xffffffff", + "base": "http://other.com/", + "href": "http://255.255.255.255/", + "origin": "http://255.255.255.255", + "protocol": "http:", + "username": "", + "password": "", + "host": "255.255.255.255", + "hostname": "255.255.255.255", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://0xffffffff1", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://256.256.256.256", + "base": "http://other.com/", + "failure": true + }, + { + "input": "https://0x.0x.0", + "base": null, + "href": "https://0.0.0.0/", + "origin": "https://0.0.0.0", + "protocol": "https:", + "username": "", + "password": "", + "host": "0.0.0.0", + "hostname": "0.0.0.0", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://0x.0x.0x.0x", + "base": null, + "href": "https://0.0.0.0/", + "origin": "https://0.0.0.0", + "protocol": "https:", + "username": "", + "password": "", + "host": "0.0.0.0", + "hostname": "0.0.0.0", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://00.00.00.00", + "base": null, + "href": "https://0.0.0.0/", + "origin": "https://0.0.0.0", + "protocol": "https:", + "username": "", + "password": "", + "host": "0.0.0.0", + "hostname": "0.0.0.0", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "https://0000000000000000000000000000000000000000177.0.0.1", + "base": null, + "href": "https://127.0.0.1/", + "origin": "https://127.0.0.1", + "protocol": "https:", + "username": "", + "password": "", + "host": "127.0.0.1", + "hostname": "127.0.0.1", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "More IPv4 parsing (via https://github.com/jsdom/whatwg-url/issues/92)", + { + "input": "https://0x100000000/test", + "base": null, + "failure": true + }, + { + "input": "https://256.0.0.1/test", + "base": null, + "failure": true + }, + "# file URLs containing percent-encoded Windows drive letters (shouldn't work)", + { + "input": "file:///C%3A/", + "base": null, + "href": "file:///C%3A/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C%3A/", + "search": "", + "hash": "" + }, + { + "input": "file:///C%7C/", + "base": null, + "href": "file:///C%7C/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C%7C/", + "search": "", + "hash": "" + }, + { + "input": "file://%43%3A", + "base": null, + "failure": true + }, + { + "input": "file://%43%7C", + "base": null, + "failure": true + }, + { + "input": "file://%43|", + "base": null, + "failure": true + }, + { + "input": "file://C%7C", + "base": null, + "failure": true + }, + { + "input": "file://%43%7C/", + "base": null, + "failure": true + }, + { + "input": "https://%43%7C/", + "base": null, + "failure": true + }, + { + "input": "asdf://%43|/", + "base": null, + "failure": true + }, + { + "input": "asdf://%43%7C/", + "base": null, + "href": "asdf://%43%7C/", + "origin": "null", + "protocol": "asdf:", + "username": "", + "password": "", + "host": "%43%7C", + "hostname": "%43%7C", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# file URLs relative to other file URLs (via https://github.com/jsdom/whatwg-url/pull/60)", + { + "input": "pix/submit.gif", + "base": "file:///C:/Users/Domenic/Dropbox/GitHub/tmpvar/jsdom/test/level2/html/files/anchor.html", + "href": "file:///C:/Users/Domenic/Dropbox/GitHub/tmpvar/jsdom/test/level2/html/files/pix/submit.gif", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/Users/Domenic/Dropbox/GitHub/tmpvar/jsdom/test/level2/html/files/pix/submit.gif", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "file:///C:/", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "file:///", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# More file URL tests by zcorpan and annevk", + { + "input": "/", + "base": "file:///C:/a/b", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "/", + "base": "file://h/C:/a/b", + "href": "file://h/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "h", + "hostname": "h", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "/", + "base": "file://h/a/b", + "href": "file://h/", + "protocol": "file:", + "username": "", + "password": "", + "host": "h", + "hostname": "h", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "//d:", + "base": "file:///C:/a/b", + "href": "file:///d:", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/d:", + "search": "", + "hash": "" + }, + { + "input": "//d:/..", + "base": "file:///C:/a/b", + "href": "file:///d:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/d:/", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "file:///ab:/", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "file:///1:/", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "", + "base": "file:///test?test#test", + "href": "file:///test?test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?test", + "hash": "" + }, + { + "input": "file:", + "base": "file:///test?test#test", + "href": "file:///test?test", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?test", + "hash": "" + }, + { + "input": "?x", + "base": "file:///test?test#test", + "href": "file:///test?x", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?x", + "hash": "" + }, + { + "input": "file:?x", + "base": "file:///test?test#test", + "href": "file:///test?x", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?x", + "hash": "" + }, + { + "input": "#x", + "base": "file:///test?test#test", + "href": "file:///test?test#x", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?test", + "hash": "#x" + }, + { + "input": "file:#x", + "base": "file:///test?test#test", + "href": "file:///test?test#x", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?test", + "hash": "#x" + }, + "# File URLs and many (back)slashes", + { + "input": "file:\\\\//", + "base": null, + "href": "file:////", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "file:\\\\\\\\", + "base": null, + "href": "file:////", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "file:\\\\\\\\?fox", + "base": null, + "href": "file:////?fox", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "?fox", + "hash": "" + }, + { + "input": "file:\\\\\\\\#guppy", + "base": null, + "href": "file:////#guppy", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "#guppy" + }, + { + "input": "file://spider///", + "base": null, + "href": "file://spider///", + "protocol": "file:", + "username": "", + "password": "", + "host": "spider", + "hostname": "spider", + "port": "", + "pathname": "///", + "search": "", + "hash": "" + }, + { + "input": "file:\\\\localhost//", + "base": null, + "href": "file:////", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "file:///localhost//cat", + "base": null, + "href": "file:///localhost//cat", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/localhost//cat", + "search": "", + "hash": "" + }, + { + "input": "file://\\/localhost//cat", + "base": null, + "href": "file:////localhost//cat", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//localhost//cat", + "search": "", + "hash": "" + }, + { + "input": "file://localhost//a//../..//", + "base": null, + "href": "file://///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "///", + "search": "", + "hash": "" + }, + { + "input": "/////mouse", + "base": "file:///elephant", + "href": "file://///mouse", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "///mouse", + "search": "", + "hash": "" + }, + { + "input": "\\//pig", + "base": "file://lion/", + "href": "file:///pig", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/pig", + "search": "", + "hash": "" + }, + { + "input": "\\/localhost//pig", + "base": "file://lion/", + "href": "file:////pig", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//pig", + "search": "", + "hash": "" + }, + { + "input": "//localhost//pig", + "base": "file://lion/", + "href": "file:////pig", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//pig", + "search": "", + "hash": "" + }, + { + "input": "/..//localhost//pig", + "base": "file://lion/", + "href": "file://lion//localhost//pig", + "protocol": "file:", + "username": "", + "password": "", + "host": "lion", + "hostname": "lion", + "port": "", + "pathname": "//localhost//pig", + "search": "", + "hash": "" + }, + { + "input": "file://", + "base": "file://ape/", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# File URLs with non-empty hosts", + { + "input": "/rooibos", + "base": "file://tea/", + "href": "file://tea/rooibos", + "protocol": "file:", + "username": "", + "password": "", + "host": "tea", + "hostname": "tea", + "port": "", + "pathname": "/rooibos", + "search": "", + "hash": "" + }, + { + "input": "/?chai", + "base": "file://tea/", + "href": "file://tea/?chai", + "protocol": "file:", + "username": "", + "password": "", + "host": "tea", + "hostname": "tea", + "port": "", + "pathname": "/", + "search": "?chai", + "hash": "" + }, + "# Windows drive letter handling with the 'file:' base URL", + { + "input": "C|", + "base": "file://host/dir/file", + "href": "file://host/C:", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:", + "search": "", + "hash": "" + }, + { + "input": "C|", + "base": "file://host/D:/dir1/dir2/file", + "href": "file://host/C:", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:", + "search": "", + "hash": "" + }, + { + "input": "C|#", + "base": "file://host/dir/file", + "href": "file://host/C:#", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:", + "search": "", + "hash": "" + }, + { + "input": "C|?", + "base": "file://host/dir/file", + "href": "file://host/C:?", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:", + "search": "", + "hash": "" + }, + { + "input": "C|/", + "base": "file://host/dir/file", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "C|\n/", + "base": "file://host/dir/file", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "C|\\", + "base": "file://host/dir/file", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "C", + "base": "file://host/dir/file", + "href": "file://host/dir/C", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/dir/C", + "search": "", + "hash": "" + }, + { + "input": "C|a", + "base": "file://host/dir/file", + "href": "file://host/dir/C|a", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/dir/C|a", + "search": "", + "hash": "" + }, + "# Windows drive letter quirk in the file slash state", + { + "input": "/c:/foo/bar", + "base": "file:///c:/baz/qux", + "href": "file:///c:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/c:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "/c|/foo/bar", + "base": "file:///c:/baz/qux", + "href": "file:///c:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/c:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "file:\\c:\\foo\\bar", + "base": "file:///c:/baz/qux", + "href": "file:///c:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/c:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "/c:/foo/bar", + "base": "file://host/path", + "href": "file://host/c:/foo/bar", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/c:/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "abc://x/y/z/C:/", + "href": "abc://x/y/z/", + "protocol": "abc:", + "username": "", + "password": "", + "hostname": "x", + "port": "", + "host": "x", + "pathname": "/y/z/", + "search": "", + "hash": "" + }, + { + "input": "..", + "base": "file://x/C:/", + "href": "file://x/C:/", + "protocol": "file:", + "username": "", + "password": "", + "hostname": "x", + "port": "", + "host": "x", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + "# Do not drop the host in the presence of a drive letter", + { + "input": "file://example.net/C:/", + "base": null, + "href": "file://example.net/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "example.net", + "hostname": "example.net", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file://1.2.3.4/C:/", + "base": null, + "href": "file://1.2.3.4/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "1.2.3.4", + "hostname": "1.2.3.4", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file://[1::8]/C:/", + "base": null, + "href": "file://[1::8]/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "[1::8]", + "hostname": "[1::8]", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + "# Copy the host from the base URL in the following cases", + { + "input": "C|/", + "base": "file://host/", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "/C:/", + "base": "file://host/", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file:C:/", + "base": "file://host/", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file:/C:/", + "base": "file://host/", + "href": "file://host/C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + "# Copy the empty host from the input in the following cases", + { + "input": "//C:/", + "base": "file://host/", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file://C:/", + "base": "file://host/", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "///C:/", + "base": "file://host/", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file:///C:/", + "base": "file://host/", + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + "# Windows drive letter quirk (no host)", + { + "input": "file:/C|/", + "base": null, + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + { + "input": "file://C|/", + "base": null, + "href": "file:///C:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/C:/", + "search": "", + "hash": "" + }, + "# file URLs without base URL by Rimas Misevičius", + { + "input": "file:", + "base": null, + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "file:?q=v", + "base": null, + "href": "file:///?q=v", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "?q=v", + "hash": "" + }, + { + "input": "file:#frag", + "base": null, + "href": "file:///#frag", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "#frag" + }, + "# file: drive letter cases from https://crbug.com/1078698", + { + "input": "file:///Y:", + "base": null, + "href": "file:///Y:", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/Y:", + "search": "", + "hash": "" + }, + { + "input": "file:///Y:/", + "base": null, + "href": "file:///Y:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/Y:/", + "search": "", + "hash": "" + }, + { + "input": "file:///./Y", + "base": null, + "href": "file:///Y", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/Y", + "search": "", + "hash": "" + }, + { + "input": "file:///./Y:", + "base": null, + "href": "file:///Y:", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/Y:", + "search": "", + "hash": "" + }, + { + "input": "\\\\\\.\\Y:", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + "# file: drive letter cases from https://crbug.com/1078698 but lowercased", + { + "input": "file:///y:", + "base": null, + "href": "file:///y:", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/y:", + "search": "", + "hash": "" + }, + { + "input": "file:///y:/", + "base": null, + "href": "file:///y:/", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/y:/", + "search": "", + "hash": "" + }, + { + "input": "file:///./y", + "base": null, + "href": "file:///y", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/y", + "search": "", + "hash": "" + }, + { + "input": "file:///./y:", + "base": null, + "href": "file:///y:", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/y:", + "search": "", + "hash": "" + }, + { + "input": "\\\\\\.\\y:", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + "# Additional file URL tests for (https://github.com/whatwg/url/issues/405)", + { + "input": "file://localhost//a//../..//foo", + "base": null, + "href": "file://///foo", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "///foo", + "search": "", + "hash": "" + }, + { + "input": "file://localhost////foo", + "base": null, + "href": "file://////foo", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "////foo", + "search": "", + "hash": "" + }, + { + "input": "file:////foo", + "base": null, + "href": "file:////foo", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//foo", + "search": "", + "hash": "" + }, + { + "input": "file:///one/two", + "base": "file:///", + "href": "file:///one/two", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/one/two", + "search": "", + "hash": "" + }, + { + "input": "file:////one/two", + "base": "file:///", + "href": "file:////one/two", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//one/two", + "search": "", + "hash": "" + }, + { + "input": "//one/two", + "base": "file:///", + "href": "file://one/two", + "protocol": "file:", + "username": "", + "password": "", + "host": "one", + "hostname": "one", + "port": "", + "pathname": "/two", + "search": "", + "hash": "" + }, + { + "input": "///one/two", + "base": "file:///", + "href": "file:///one/two", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/one/two", + "search": "", + "hash": "" + }, + { + "input": "////one/two", + "base": "file:///", + "href": "file:////one/two", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//one/two", + "search": "", + "hash": "" + }, + { + "input": "file:///.//", + "base": "file:////", + "href": "file:////", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + "File URL tests for https://github.com/whatwg/url/issues/549", + { + "input": "file:.//p", + "base": null, + "href": "file:////p", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//p", + "search": "", + "hash": "" + }, + { + "input": "file:/.//p", + "base": null, + "href": "file:////p", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//p", + "search": "", + "hash": "" + }, + "# IPv6 tests", + "IPv4 pieces embedded in IPv6 addresses must not have leading zeroes", + { + "input": "https://[0:1:2:3:4:5:192.0.02.1]/", + "base": null, + "failure": true + }, + "An embedded IPv4 address must end the IPv6 address", + { + "input": "https://[192.0.2.1::]/", + "base": null, + "failure": true + }, + { + "input": "http://[1:0::]", + "base": "http://example.net/", + "href": "http://[1::]/", + "origin": "http://[1::]", + "protocol": "http:", + "username": "", + "password": "", + "host": "[1::]", + "hostname": "[1::]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://[0:1:2:3:4:5:6:7:8]", + "base": "http://example.net/", + "failure": true + }, + { + "input": "https://[0::0::0]", + "base": null, + "failure": true + }, + { + "input": "https://[0:.0]", + "base": null, + "failure": true + }, + { + "input": "https://[0:0:]", + "base": null, + "failure": true + }, + { + "input": "https://[0:1:2:3:4:5:6:7.0.0.0.1]", + "base": null, + "failure": true + }, + { + "input": "https://[0:1.00.0.0.0]", + "base": null, + "failure": true + }, + { + "input": "https://[0:1.290.0.0.0]", + "base": null, + "failure": true + }, + { + "input": "https://[0:1.23.23]", + "base": null, + "failure": true + }, + "# Empty host", + { + "input": "http://?", + "base": null, + "failure": true + }, + { + "input": "http://#", + "base": null, + "failure": true + }, + "Port overflow (2^32 + 81)", + { + "input": "http://f:4294967377/c", + "base": "http://example.org/", + "failure": true + }, + "Port overflow (2^64 + 81)", + { + "input": "http://f:18446744073709551697/c", + "base": "http://example.org/", + "failure": true + }, + "Port overflow (2^128 + 81)", + { + "input": "http://f:340282366920938463463374607431768211537/c", + "base": "http://example.org/", + "failure": true + }, + "# Non-special-URL path tests", + { + "input": "sc://ñ", + "base": null, + "href": "sc://%C3%B1", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "sc://ñ?x", + "base": null, + "href": "sc://%C3%B1?x", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "", + "search": "?x", + "hash": "" + }, + { + "input": "sc://ñ#x", + "base": null, + "href": "sc://%C3%B1#x", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "", + "search": "", + "hash": "#x" + }, + { + "input": "#x", + "base": "sc://ñ", + "href": "sc://%C3%B1#x", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "", + "search": "", + "hash": "#x" + }, + { + "input": "?x", + "base": "sc://ñ", + "href": "sc://%C3%B1?x", + "origin": "null", + "protocol": "sc:", + "username": "", + "password": "", + "host": "%C3%B1", + "hostname": "%C3%B1", + "port": "", + "pathname": "", + "search": "?x", + "hash": "" + }, + { + "input": "sc://?", + "base": null, + "href": "sc://?", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "sc://#", + "base": null, + "href": "sc://#", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "///", + "base": "sc://x/", + "href": "sc:///", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "////", + "base": "sc://x/", + "href": "sc:////", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "////x/", + "base": "sc://x/", + "href": "sc:////x/", + "protocol": "sc:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//x/", + "search": "", + "hash": "" + }, + { + "input": "tftp://foobar.com/someconfig;mode=netascii", + "base": null, + "href": "tftp://foobar.com/someconfig;mode=netascii", + "origin": "null", + "protocol": "tftp:", + "username": "", + "password": "", + "host": "foobar.com", + "hostname": "foobar.com", + "port": "", + "pathname": "/someconfig;mode=netascii", + "search": "", + "hash": "" + }, + { + "input": "telnet://user:pass@foobar.com:23/", + "base": null, + "href": "telnet://user:pass@foobar.com:23/", + "origin": "null", + "protocol": "telnet:", + "username": "user", + "password": "pass", + "host": "foobar.com:23", + "hostname": "foobar.com", + "port": "23", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "ut2004://10.10.10.10:7777/Index.ut2", + "base": null, + "href": "ut2004://10.10.10.10:7777/Index.ut2", + "origin": "null", + "protocol": "ut2004:", + "username": "", + "password": "", + "host": "10.10.10.10:7777", + "hostname": "10.10.10.10", + "port": "7777", + "pathname": "/Index.ut2", + "search": "", + "hash": "" + }, + { + "input": "redis://foo:bar@somehost:6379/0?baz=bam&qux=baz", + "base": null, + "href": "redis://foo:bar@somehost:6379/0?baz=bam&qux=baz", + "origin": "null", + "protocol": "redis:", + "username": "foo", + "password": "bar", + "host": "somehost:6379", + "hostname": "somehost", + "port": "6379", + "pathname": "/0", + "search": "?baz=bam&qux=baz", + "hash": "" + }, + { + "input": "rsync://foo@host:911/sup", + "base": null, + "href": "rsync://foo@host:911/sup", + "origin": "null", + "protocol": "rsync:", + "username": "foo", + "password": "", + "host": "host:911", + "hostname": "host", + "port": "911", + "pathname": "/sup", + "search": "", + "hash": "" + }, + { + "input": "git://github.com/foo/bar.git", + "base": null, + "href": "git://github.com/foo/bar.git", + "origin": "null", + "protocol": "git:", + "username": "", + "password": "", + "host": "github.com", + "hostname": "github.com", + "port": "", + "pathname": "/foo/bar.git", + "search": "", + "hash": "" + }, + { + "input": "irc://myserver.com:6999/channel?passwd", + "base": null, + "href": "irc://myserver.com:6999/channel?passwd", + "origin": "null", + "protocol": "irc:", + "username": "", + "password": "", + "host": "myserver.com:6999", + "hostname": "myserver.com", + "port": "6999", + "pathname": "/channel", + "search": "?passwd", + "hash": "" + }, + { + "input": "dns://fw.example.org:9999/foo.bar.org?type=TXT", + "base": null, + "href": "dns://fw.example.org:9999/foo.bar.org?type=TXT", + "origin": "null", + "protocol": "dns:", + "username": "", + "password": "", + "host": "fw.example.org:9999", + "hostname": "fw.example.org", + "port": "9999", + "pathname": "/foo.bar.org", + "search": "?type=TXT", + "hash": "" + }, + { + "input": "ldap://localhost:389/ou=People,o=JNDITutorial", + "base": null, + "href": "ldap://localhost:389/ou=People,o=JNDITutorial", + "origin": "null", + "protocol": "ldap:", + "username": "", + "password": "", + "host": "localhost:389", + "hostname": "localhost", + "port": "389", + "pathname": "/ou=People,o=JNDITutorial", + "search": "", + "hash": "" + }, + { + "input": "git+https://github.com/foo/bar", + "base": null, + "href": "git+https://github.com/foo/bar", + "origin": "null", + "protocol": "git+https:", + "username": "", + "password": "", + "host": "github.com", + "hostname": "github.com", + "port": "", + "pathname": "/foo/bar", + "search": "", + "hash": "" + }, + { + "input": "urn:ietf:rfc:2648", + "base": null, + "href": "urn:ietf:rfc:2648", + "origin": "null", + "protocol": "urn:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "ietf:rfc:2648", + "search": "", + "hash": "" + }, + { + "input": "tag:joe@example.org,2001:foo/bar", + "base": null, + "href": "tag:joe@example.org,2001:foo/bar", + "origin": "null", + "protocol": "tag:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "joe@example.org,2001:foo/bar", + "search": "", + "hash": "" + }, + "Serialize /. in path", + { + "input": "non-spec:/.//", + "base": null, + "href": "non-spec:/.//", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "non-spec:/..//", + "base": null, + "href": "non-spec:/.//", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "non-spec:/a/..//", + "base": null, + "href": "non-spec:/.//", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//", + "search": "", + "hash": "" + }, + { + "input": "non-spec:/.//path", + "base": null, + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "non-spec:/..//path", + "base": null, + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "non-spec:/a/..//path", + "base": null, + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "/.//path", + "base": "non-spec:/p", + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "/..//path", + "base": "non-spec:/p", + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "..//path", + "base": "non-spec:/p", + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "a/..//path", + "base": "non-spec:/p", + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + { + "input": "", + "base": "non-spec:/..//p", + "href": "non-spec:/.//p", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//p", + "search": "", + "hash": "" + }, + { + "input": "path", + "base": "non-spec:/..//p", + "href": "non-spec:/.//path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "//path", + "search": "", + "hash": "" + }, + "Do not serialize /. in path", + { + "input": "../path", + "base": "non-spec:/.//p", + "href": "non-spec:/path", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + "# percent encoded hosts in non-special-URLs", + { + "input": "non-special://%E2%80%A0/", + "base": null, + "href": "non-special://%E2%80%A0/", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "%E2%80%A0", + "hostname": "%E2%80%A0", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special://H%4fSt/path", + "base": null, + "href": "non-special://H%4fSt/path", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "H%4fSt", + "hostname": "H%4fSt", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + "# IPv6 in non-special-URLs", + { + "input": "non-special://[1:2:0:0:5:0:0:0]/", + "base": null, + "href": "non-special://[1:2:0:0:5::]/", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "[1:2:0:0:5::]", + "hostname": "[1:2:0:0:5::]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special://[1:2:0:0:0:0:0:3]/", + "base": null, + "href": "non-special://[1:2::3]/", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "[1:2::3]", + "hostname": "[1:2::3]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special://[1:2::3]:80/", + "base": null, + "href": "non-special://[1:2::3]:80/", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "[1:2::3]:80", + "hostname": "[1:2::3]", + "port": "80", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "non-special://[:80/", + "base": null, + "failure": true + }, + { + "input": "blob:https://example.com:443/", + "base": null, + "href": "blob:https://example.com:443/", + "origin": "https://example.com", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "https://example.com:443/", + "search": "", + "hash": "" + }, + { + "input": "blob:http://example.org:88/", + "base": null, + "href": "blob:http://example.org:88/", + "origin": "http://example.org:88", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "http://example.org:88/", + "search": "", + "hash": "" + }, + { + "input": "blob:d3958f5c-0777-0845-9dcf-2cb28783acaf", + "base": null, + "href": "blob:d3958f5c-0777-0845-9dcf-2cb28783acaf", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "d3958f5c-0777-0845-9dcf-2cb28783acaf", + "search": "", + "hash": "" + }, + { + "input": "blob:", + "base": null, + "href": "blob:", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "", + "search": "", + "hash": "" + }, + "blob: in blob:", + { + "input": "blob:blob:", + "base": null, + "href": "blob:blob:", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blob:", + "search": "", + "hash": "" + }, + { + "input": "blob:blob:https://example.org/", + "base": null, + "href": "blob:blob:https://example.org/", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blob:https://example.org/", + "search": "", + "hash": "" + }, + "Non-http(s): in blob:", + { + "input": "blob:about:blank", + "base": null, + "href": "blob:about:blank", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "about:blank", + "search": "", + "hash": "" + }, + { + "input": "blob:file://host/path", + "base": null, + "href": "blob:file://host/path", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "file://host/path", + "search": "", + "hash": "" + }, + { + "input": "blob:ftp://host/path", + "base": null, + "href": "blob:ftp://host/path", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "ftp://host/path", + "search": "", + "hash": "" + }, + { + "input": "blob:ws://example.org/", + "base": null, + "href": "blob:ws://example.org/", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "ws://example.org/", + "search": "", + "hash": "" + }, + { + "input": "blob:wss://example.org/", + "base": null, + "href": "blob:wss://example.org/", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "wss://example.org/", + "search": "", + "hash": "" + }, + "Percent-encoded http: in blob:", + { + "input": "blob:http%3a//example.org/", + "base": null, + "href": "blob:http%3a//example.org/", + "origin": "null", + "protocol": "blob:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "http%3a//example.org/", + "search": "", + "hash": "" + }, + "Invalid IPv4 radix digits", + { + "input": "http://0x7f.0.0.0x7g", + "base": null, + "href": "http://0x7f.0.0.0x7g/", + "protocol": "http:", + "username": "", + "password": "", + "host": "0x7f.0.0.0x7g", + "hostname": "0x7f.0.0.0x7g", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://0X7F.0.0.0X7G", + "base": null, + "href": "http://0x7f.0.0.0x7g/", + "protocol": "http:", + "username": "", + "password": "", + "host": "0x7f.0.0.0x7g", + "hostname": "0x7f.0.0.0x7g", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Invalid IPv4 portion of IPv6 address", + { + "input": "http://[::127.0.0.0.1]", + "base": null, + "failure": true + }, + "Uncompressed IPv6 addresses with 0", + { + "input": "http://[0:1:0:1:0:1:0:1]", + "base": null, + "href": "http://[0:1:0:1:0:1:0:1]/", + "protocol": "http:", + "username": "", + "password": "", + "host": "[0:1:0:1:0:1:0:1]", + "hostname": "[0:1:0:1:0:1:0:1]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "http://[1:0:1:0:1:0:1:0]", + "base": null, + "href": "http://[1:0:1:0:1:0:1:0]/", + "protocol": "http:", + "username": "", + "password": "", + "host": "[1:0:1:0:1:0:1:0]", + "hostname": "[1:0:1:0:1:0:1:0]", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Percent-encoded query and fragment", + { + "input": "http://example.org/test?\u0022", + "base": null, + "href": "http://example.org/test?%22", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%22", + "hash": "" + }, + { + "input": "http://example.org/test?\u0023", + "base": null, + "href": "http://example.org/test?#", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "http://example.org/test?\u003C", + "base": null, + "href": "http://example.org/test?%3C", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%3C", + "hash": "" + }, + { + "input": "http://example.org/test?\u003E", + "base": null, + "href": "http://example.org/test?%3E", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%3E", + "hash": "" + }, + { + "input": "http://example.org/test?\u2323", + "base": null, + "href": "http://example.org/test?%E2%8C%A3", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%E2%8C%A3", + "hash": "" + }, + { + "input": "http://example.org/test?%23%23", + "base": null, + "href": "http://example.org/test?%23%23", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%23%23", + "hash": "" + }, + { + "input": "http://example.org/test?%GH", + "base": null, + "href": "http://example.org/test?%GH", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?%GH", + "hash": "" + }, + { + "input": "http://example.org/test?a#%EF", + "base": null, + "href": "http://example.org/test?a#%EF", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?a", + "hash": "#%EF" + }, + { + "input": "http://example.org/test?a#%GH", + "base": null, + "href": "http://example.org/test?a#%GH", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?a", + "hash": "#%GH" + }, + "URLs that require a non-about:blank base. (Also serve as invalid base tests.)", + { + "input": "a", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "a/", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "a//", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + "Bases that don't fail to parse but fail to be bases", + { + "input": "test-a-colon.html", + "base": "a:", + "failure": true + }, + { + "input": "test-a-colon-b.html", + "base": "a:b", + "failure": true + }, + "Other base URL tests, that must succeed", + { + "input": "test-a-colon-slash.html", + "base": "a:/", + "href": "a:/test-a-colon-slash.html", + "protocol": "a:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test-a-colon-slash.html", + "search": "", + "hash": "" + }, + { + "input": "test-a-colon-slash-slash.html", + "base": "a://", + "href": "a:///test-a-colon-slash-slash.html", + "protocol": "a:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test-a-colon-slash-slash.html", + "search": "", + "hash": "" + }, + { + "input": "test-a-colon-slash-b.html", + "base": "a:/b", + "href": "a:/test-a-colon-slash-b.html", + "protocol": "a:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test-a-colon-slash-b.html", + "search": "", + "hash": "" + }, + { + "input": "test-a-colon-slash-slash-b.html", + "base": "a://b", + "href": "a://b/test-a-colon-slash-slash-b.html", + "protocol": "a:", + "username": "", + "password": "", + "host": "b", + "hostname": "b", + "port": "", + "pathname": "/test-a-colon-slash-slash-b.html", + "search": "", + "hash": "" + }, + "Null code point in fragment", + { + "input": "http://example.org/test?a#b\u0000c", + "base": null, + "href": "http://example.org/test?a#b%00c", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?a", + "hash": "#b%00c" + }, + { + "input": "non-spec://example.org/test?a#b\u0000c", + "base": null, + "href": "non-spec://example.org/test?a#b%00c", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/test", + "search": "?a", + "hash": "#b%00c" + }, + { + "input": "non-spec:/test?a#b\u0000c", + "base": null, + "href": "non-spec:/test?a#b%00c", + "protocol": "non-spec:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "?a", + "hash": "#b%00c" + }, + "First scheme char - not allowed: https://github.com/whatwg/url/issues/464", + { + "input": "10.0.0.7:8080/foo.html", + "base": "file:///some/dir/bar.html", + "href": "file:///some/dir/10.0.0.7:8080/foo.html", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/some/dir/10.0.0.7:8080/foo.html", + "search": "", + "hash": "" + }, + "Subsequent scheme chars - not allowed", + { + "input": "a!@$*=/foo.html", + "base": "file:///some/dir/bar.html", + "href": "file:///some/dir/a!@$*=/foo.html", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/some/dir/a!@$*=/foo.html", + "search": "", + "hash": "" + }, + "First and subsequent scheme chars - allowed", + { + "input": "a1234567890-+.:foo/bar", + "base": "http://example.com/dir/file", + "href": "a1234567890-+.:foo/bar", + "protocol": "a1234567890-+.:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "foo/bar", + "search": "", + "hash": "" + }, + "IDNA ignored code points in file URLs hosts", + { + "input": "file://a\u00ADb/p", + "base": null, + "href": "file://ab/p", + "protocol": "file:", + "username": "", + "password": "", + "host": "ab", + "hostname": "ab", + "port": "", + "pathname": "/p", + "search": "", + "hash": "" + }, + { + "input": "file://a%C2%ADb/p", + "base": null, + "href": "file://ab/p", + "protocol": "file:", + "username": "", + "password": "", + "host": "ab", + "hostname": "ab", + "port": "", + "pathname": "/p", + "search": "", + "hash": "" + }, + "IDNA hostnames which get mapped to 'localhost'", + { + "input": "file://loC𝐀𝐋𝐇𝐨𝐬𝐭/usr/bin", + "base": null, + "href": "file:///usr/bin", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/usr/bin", + "search": "", + "hash": "" + }, + "Empty host after the domain to ASCII", + { + "input": "file://\u00ad/p", + "base": null, + "failure": true + }, + { + "input": "file://%C2%AD/p", + "base": null, + "failure": true + }, + { + "input": "file://xn--/p", + "base": null, + "href": "file://xn--/p", + "protocol": "file:", + "username": "", + "password": "", + "host": "xn--", + "hostname": "xn--", + "port": "", + "pathname": "/p", + "search": "", + "hash": "" + }, + "https://bugzilla.mozilla.org/show_bug.cgi?id=1647058", + { + "input": "#link", + "base": "https://example.org/##link", + "href": "https://example.org/#link", + "protocol": "https:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/", + "search": "", + "hash": "#link" + }, + "UTF-8 percent-encode of C0 control percent-encode set and supersets", + { + "input": "non-special:cannot-be-a-base-url-\u0000\u0001\u001F\u001E\u007E\u007F\u0080", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:cannot-be-a-base-url-%00%01%1F%1E~%7F%C2%80", + "origin": "null", + "password": "", + "pathname": "cannot-be-a-base-url-%00%01%1F%1E~%7F%C2%80", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:cannot-be-a-base-url-!\"$%&'()*+,-.;<=>@[\\]^_`{|}~@/", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:cannot-be-a-base-url-!\"$%&'()*+,-.;<=>@[\\]^_`{|}~@/", + "origin": "null", + "password": "", + "pathname": "cannot-be-a-base-url-!\"$%&'()*+,-.;<=>@[\\]^_`{|}~@/", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "https://www.example.com/path{\u007Fpath.html?query'\u007F=query#fragment<\u007Ffragment", + "base": null, + "hash": "#fragment%3C%7Ffragment", + "host": "www.example.com", + "hostname": "www.example.com", + "href": "https://www.example.com/path%7B%7Fpath.html?query%27%7F=query#fragment%3C%7Ffragment", + "origin": "https://www.example.com", + "password": "", + "pathname": "/path%7B%7Fpath.html", + "port": "", + "protocol": "https:", + "search": "?query%27%7F=query", + "username": "" + }, + { + "input": "https://user:pass[\u007F@foo/bar", + "base": "http://example.org", + "hash": "", + "host": "foo", + "hostname": "foo", + "href": "https://user:pass%5B%7F@foo/bar", + "origin": "https://foo", + "password": "pass%5B%7F", + "pathname": "/bar", + "port": "", + "protocol": "https:", + "search": "", + "username": "user" + }, + "Tests for the distinct percent-encode sets", + { + "input": "foo:// !\"$%&'()*+,-.;<=>@[\\]^_`{|}~@host/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "foo://%20!%22$%&'()*+,-.%3B%3C%3D%3E%40%5B%5C%5D%5E_%60%7B%7C%7D~@host/", + "origin": "null", + "password": "", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "%20!%22$%&'()*+,-.%3B%3C%3D%3E%40%5B%5C%5D%5E_%60%7B%7C%7D~" + }, + { + "input": "wss:// !\"$%&'()*+,-.;<=>@[]^_`{|}~@host/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "wss://%20!%22$%&'()*+,-.%3B%3C%3D%3E%40%5B%5D%5E_%60%7B%7C%7D~@host/", + "origin": "wss://host", + "password": "", + "pathname": "/", + "port":"", + "protocol": "wss:", + "search": "", + "username": "%20!%22$%&'()*+,-.%3B%3C%3D%3E%40%5B%5D%5E_%60%7B%7C%7D~" + }, + { + "input": "foo://joe: !\"$%&'()*+,-.:;<=>@[\\]^_`{|}~@host/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "foo://joe:%20!%22$%&'()*+,-.%3A%3B%3C%3D%3E%40%5B%5C%5D%5E_%60%7B%7C%7D~@host/", + "origin": "null", + "password": "%20!%22$%&'()*+,-.%3A%3B%3C%3D%3E%40%5B%5C%5D%5E_%60%7B%7C%7D~", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "joe" + }, + { + "input": "wss://joe: !\"$%&'()*+,-.:;<=>@[]^_`{|}~@host/", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "wss://joe:%20!%22$%&'()*+,-.%3A%3B%3C%3D%3E%40%5B%5D%5E_%60%7B%7C%7D~@host/", + "origin": "wss://host", + "password": "%20!%22$%&'()*+,-.%3A%3B%3C%3D%3E%40%5B%5D%5E_%60%7B%7C%7D~", + "pathname": "/", + "port":"", + "protocol": "wss:", + "search": "", + "username": "joe" + }, + { + "input": "foo://!\"$%&'()*+,-.;=_`{}~/", + "base": null, + "hash": "", + "host": "!\"$%&'()*+,-.;=_`{}~", + "hostname": "!\"$%&'()*+,-.;=_`{}~", + "href":"foo://!\"$%&'()*+,-.;=_`{}~/", + "origin": "null", + "password": "", + "pathname": "/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + { + "input": "wss://!\"$&'()*+,-.;=_`{}~/", + "base": null, + "hash": "", + "host": "!\"$&'()*+,-.;=_`{}~", + "hostname": "!\"$&'()*+,-.;=_`{}~", + "href":"wss://!\"$&'()*+,-.;=_`{}~/", + "origin": "wss://!\"$&'()*+,-.;=_`{}~", + "password": "", + "pathname": "/", + "port":"", + "protocol": "wss:", + "search": "", + "username": "" + }, + { + "input": "foo://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "foo://host/%20!%22$%&'()*+,-./:;%3C=%3E@[\\]%5E_%60%7B|%7D~", + "origin": "null", + "password": "", + "pathname": "/%20!%22$%&'()*+,-./:;%3C=%3E@[\\]%5E_%60%7B|%7D~", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + { + "input": "wss://host/ !\"$%&'()*+,-./:;<=>@[\\]^_`{|}~", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "wss://host/%20!%22$%&'()*+,-./:;%3C=%3E@[/]%5E_%60%7B|%7D~", + "origin": "wss://host", + "password": "", + "pathname": "/%20!%22$%&'()*+,-./:;%3C=%3E@[/]%5E_%60%7B|%7D~", + "port":"", + "protocol": "wss:", + "search": "", + "username": "" + }, + { + "input": "foo://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "foo://host/dir/?%20!%22$%&'()*+,-./:;%3C=%3E?@[\\]^_`{|}~", + "origin": "null", + "password": "", + "pathname": "/dir/", + "port":"", + "protocol": "foo:", + "search": "?%20!%22$%&'()*+,-./:;%3C=%3E?@[\\]^_`{|}~", + "username": "" + }, + { + "input": "wss://host/dir/? !\"$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "base": null, + "hash": "", + "host": "host", + "hostname": "host", + "href": "wss://host/dir/?%20!%22$%&%27()*+,-./:;%3C=%3E?@[\\]^_`{|}~", + "origin": "wss://host", + "password": "", + "pathname": "/dir/", + "port":"", + "protocol": "wss:", + "search": "?%20!%22$%&%27()*+,-./:;%3C=%3E?@[\\]^_`{|}~", + "username": "" + }, + { + "input": "foo://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "base": null, + "hash": "#%20!%22#$%&'()*+,-./:;%3C=%3E?@[\\]^_%60{|}~", + "host": "host", + "hostname": "host", + "href": "foo://host/dir/#%20!%22#$%&'()*+,-./:;%3C=%3E?@[\\]^_%60{|}~", + "origin": "null", + "password": "", + "pathname": "/dir/", + "port":"", + "protocol": "foo:", + "search": "", + "username": "" + }, + { + "input": "wss://host/dir/# !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "base": null, + "hash": "#%20!%22#$%&'()*+,-./:;%3C=%3E?@[\\]^_%60{|}~", + "host": "host", + "hostname": "host", + "href": "wss://host/dir/#%20!%22#$%&'()*+,-./:;%3C=%3E?@[\\]^_%60{|}~", + "origin": "wss://host", + "password": "", + "pathname": "/dir/", + "port":"", + "protocol": "wss:", + "search": "", + "username": "" + }, + "Ensure that input schemes are not ignored when resolving non-special URLs", + { + "input": "abc:rootless", + "base": "abc://host/path", + "hash": "", + "host": "", + "hostname": "", + "href":"abc:rootless", + "password": "", + "pathname": "rootless", + "port":"", + "protocol": "abc:", + "search": "", + "username": "" + }, + { + "input": "abc:rootless", + "base": "abc:/path", + "hash": "", + "host": "", + "hostname": "", + "href":"abc:rootless", + "password": "", + "pathname": "rootless", + "port":"", + "protocol": "abc:", + "search": "", + "username": "" + }, + { + "input": "abc:rootless", + "base": "abc:path", + "hash": "", + "host": "", + "hostname": "", + "href":"abc:rootless", + "password": "", + "pathname": "rootless", + "port":"", + "protocol": "abc:", + "search": "", + "username": "" + }, + { + "input": "abc:/rooted", + "base": "abc://host/path", + "hash": "", + "host": "", + "hostname": "", + "href":"abc:/rooted", + "password": "", + "pathname": "/rooted", + "port":"", + "protocol": "abc:", + "search": "", + "username": "" + }, + "Empty query and fragment with blank should throw an error", + { + "input": "#", + "base": null, + "failure": true, + "relativeTo": "any-base" + }, + { + "input": "?", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + "Last component looks like a number, but not valid IPv4", + { + "input": "http://1.2.3.4.5", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://1.2.3.4.5.", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://0..0x300/", + "base": null, + "failure": true + }, + { + "input": "http://0..0x300./", + "base": null, + "failure": true + }, + { + "input": "http://256.256.256.256.256", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://256.256.256.256.256.", + "base": "http://other.com/", + "failure": true + }, + { + "input": "http://1.2.3.08", + "base": null, + "failure": true + }, + { + "input": "http://1.2.3.08.", + "base": null, + "failure": true + }, + { + "input": "http://1.2.3.09", + "base": null, + "failure": true + }, + { + "input": "http://09.2.3.4", + "base": null, + "failure": true + }, + { + "input": "http://09.2.3.4.", + "base": null, + "failure": true + }, + { + "input": "http://01.2.3.4.5", + "base": null, + "failure": true + }, + { + "input": "http://01.2.3.4.5.", + "base": null, + "failure": true + }, + { + "input": "http://0x100.2.3.4", + "base": null, + "failure": true + }, + { + "input": "http://0x100.2.3.4.", + "base": null, + "failure": true + }, + { + "input": "http://0x1.2.3.4.5", + "base": null, + "failure": true + }, + { + "input": "http://0x1.2.3.4.5.", + "base": null, + "failure": true + }, + { + "input": "http://foo.1.2.3.4", + "base": null, + "failure": true + }, + { + "input": "http://foo.1.2.3.4.", + "base": null, + "failure": true + }, + { + "input": "http://foo.2.3.4", + "base": null, + "failure": true + }, + { + "input": "http://foo.2.3.4.", + "base": null, + "failure": true + }, + { + "input": "http://foo.09", + "base": null, + "failure": true + }, + { + "input": "http://foo.09.", + "base": null, + "failure": true + }, + { + "input": "http://foo.0x4", + "base": null, + "failure": true + }, + { + "input": "http://foo.0x4.", + "base": null, + "failure": true + }, + { + "input": "http://foo.09..", + "base": null, + "hash": "", + "host": "foo.09..", + "hostname": "foo.09..", + "href":"http://foo.09../", + "password": "", + "pathname": "/", + "port":"", + "protocol": "http:", + "search": "", + "username": "" + }, + { + "input": "http://0999999999999999999/", + "base": null, + "failure": true + }, + { + "input": "http://foo.0x", + "base": null, + "failure": true + }, + { + "input": "http://foo.0XFfFfFfFfFfFfFfFfFfAcE123", + "base": null, + "failure": true + }, + { + "input": "http://💩.123/", + "base": null, + "failure": true + }, + "U+0000 and U+FFFF in various places", + { + "input": "https://\u0000y", + "base": null, + "failure": true + }, + { + "input": "https://x/\u0000y", + "base": null, + "hash": "", + "host": "x", + "hostname": "x", + "href": "https://x/%00y", + "password": "", + "pathname": "/%00y", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "input": "https://x/?\u0000y", + "base": null, + "hash": "", + "host": "x", + "hostname": "x", + "href": "https://x/?%00y", + "password": "", + "pathname": "/", + "port": "", + "protocol": "https:", + "search": "?%00y", + "username": "" + }, + { + "input": "https://x/?#\u0000y", + "base": null, + "hash": "#%00y", + "host": "x", + "hostname": "x", + "href": "https://x/?#%00y", + "password": "", + "pathname": "/", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "input": "https://\uFFFFy", + "base": null, + "failure": true + }, + { + "input": "https://x/\uFFFFy", + "base": null, + "hash": "", + "host": "x", + "hostname": "x", + "href": "https://x/%EF%BF%BFy", + "password": "", + "pathname": "/%EF%BF%BFy", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "input": "https://x/?\uFFFFy", + "base": null, + "hash": "", + "host": "x", + "hostname": "x", + "href": "https://x/?%EF%BF%BFy", + "password": "", + "pathname": "/", + "port": "", + "protocol": "https:", + "search": "?%EF%BF%BFy", + "username": "" + }, + { + "input": "https://x/?#\uFFFFy", + "base": null, + "hash": "#%EF%BF%BFy", + "host": "x", + "hostname": "x", + "href": "https://x/?#%EF%BF%BFy", + "password": "", + "pathname": "/", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "input": "non-special:\u0000y", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:%00y", + "password": "", + "pathname": "%00y", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:x/\u0000y", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:x/%00y", + "password": "", + "pathname": "x/%00y", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:x/?\u0000y", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:x/?%00y", + "password": "", + "pathname": "x/", + "port": "", + "protocol": "non-special:", + "search": "?%00y", + "username": "" + }, + { + "input": "non-special:x/?#\u0000y", + "base": null, + "hash": "#%00y", + "host": "", + "hostname": "", + "href": "non-special:x/?#%00y", + "password": "", + "pathname": "x/", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:\uFFFFy", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:%EF%BF%BFy", + "password": "", + "pathname": "%EF%BF%BFy", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:x/\uFFFFy", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:x/%EF%BF%BFy", + "password": "", + "pathname": "x/%EF%BF%BFy", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "non-special:x/?\uFFFFy", + "base": null, + "hash": "", + "host": "", + "hostname": "", + "href": "non-special:x/?%EF%BF%BFy", + "password": "", + "pathname": "x/", + "port": "", + "protocol": "non-special:", + "search": "?%EF%BF%BFy", + "username": "" + }, + { + "input": "non-special:x/?#\uFFFFy", + "base": null, + "hash": "#%EF%BF%BFy", + "host": "", + "hostname": "", + "href": "non-special:x/?#%EF%BF%BFy", + "password": "", + "pathname": "x/", + "port": "", + "protocol": "non-special:", + "search": "", + "username": "" + }, + { + "input": "", + "base": null, + "failure": true, + "relativeTo": "non-opaque-path-base" + }, + { + "input": "https://example.com/\"quoted\"", + "base": null, + "hash": "", + "host": "example.com", + "hostname": "example.com", + "href": "https://example.com/%22quoted%22", + "origin": "https://example.com", + "password": "", + "pathname": "/%22quoted%22", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "input": "https://a%C2%ADb/", + "base": null, + "hash": "", + "host": "ab", + "hostname": "ab", + "href": "https://ab/", + "origin": "https://ab", + "password": "", + "pathname": "/", + "port": "", + "protocol": "https:", + "search": "", + "username": "" + }, + { + "comment": "Empty host after domain to ASCII", + "input": "https://\u00AD/", + "base": null, + "failure": true + }, + { + "input": "https://%C2%AD/", + "base": null, + "failure": true + }, + { + "input": "https://xn--/", + "base": null, + "href": "https://xn--/", + "origin": "https://xn--", + "protocol": "https:", + "username": "", + "password": "", + "host": "xn--", + "hostname": "xn--", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "Non-special schemes that some implementations might incorrectly treat as special", + { + "input": "data://example.com:8080/pathname?search#hash", + "base": null, + "href": "data://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "data:///test", + "base": null, + "href": "data:///test", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "data://test/a/../b", + "base": null, + "href": "data://test/b", + "origin": "null", + "protocol": "data:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "data://:443", + "base": null, + "failure": true + }, + { + "input": "data://test:test", + "base": null, + "failure": true + }, + { + "input": "data://[:1]", + "base": null, + "failure": true + }, + { + "input": "javascript://example.com:8080/pathname?search#hash", + "base": null, + "href": "javascript://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "javascript:///test", + "base": null, + "href": "javascript:///test", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "javascript://test/a/../b", + "base": null, + "href": "javascript://test/b", + "origin": "null", + "protocol": "javascript:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "javascript://:443", + "base": null, + "failure": true + }, + { + "input": "javascript://test:test", + "base": null, + "failure": true + }, + { + "input": "javascript://[:1]", + "base": null, + "failure": true + }, + { + "input": "mailto://example.com:8080/pathname?search#hash", + "base": null, + "href": "mailto://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "mailto:///test", + "base": null, + "href": "mailto:///test", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "mailto://test/a/../b", + "base": null, + "href": "mailto://test/b", + "origin": "null", + "protocol": "mailto:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "mailto://:443", + "base": null, + "failure": true + }, + { + "input": "mailto://test:test", + "base": null, + "failure": true + }, + { + "input": "mailto://[:1]", + "base": null, + "failure": true + }, + { + "input": "intent://example.com:8080/pathname?search#hash", + "base": null, + "href": "intent://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "intent:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "intent:///test", + "base": null, + "href": "intent:///test", + "origin": "null", + "protocol": "intent:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "intent://test/a/../b", + "base": null, + "href": "intent://test/b", + "origin": "null", + "protocol": "intent:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "intent://:443", + "base": null, + "failure": true + }, + { + "input": "intent://test:test", + "base": null, + "failure": true + }, + { + "input": "intent://[:1]", + "base": null, + "failure": true + }, + { + "input": "urn://example.com:8080/pathname?search#hash", + "base": null, + "href": "urn://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "urn:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "urn:///test", + "base": null, + "href": "urn:///test", + "origin": "null", + "protocol": "urn:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "urn://test/a/../b", + "base": null, + "href": "urn://test/b", + "origin": "null", + "protocol": "urn:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "urn://:443", + "base": null, + "failure": true + }, + { + "input": "urn://test:test", + "base": null, + "failure": true + }, + { + "input": "urn://[:1]", + "base": null, + "failure": true + }, + { + "input": "turn://example.com:8080/pathname?search#hash", + "base": null, + "href": "turn://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "turn:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "turn:///test", + "base": null, + "href": "turn:///test", + "origin": "null", + "protocol": "turn:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "turn://test/a/../b", + "base": null, + "href": "turn://test/b", + "origin": "null", + "protocol": "turn:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "turn://:443", + "base": null, + "failure": true + }, + { + "input": "turn://test:test", + "base": null, + "failure": true + }, + { + "input": "turn://[:1]", + "base": null, + "failure": true + }, + { + "input": "stun://example.com:8080/pathname?search#hash", + "base": null, + "href": "stun://example.com:8080/pathname?search#hash", + "origin": "null", + "protocol": "stun:", + "username": "", + "password": "", + "host": "example.com:8080", + "hostname": "example.com", + "port": "8080", + "pathname": "/pathname", + "search": "?search", + "hash": "#hash" + }, + { + "input": "stun:///test", + "base": null, + "href": "stun:///test", + "origin": "null", + "protocol": "stun:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/test", + "search": "", + "hash": "" + }, + { + "input": "stun://test/a/../b", + "base": null, + "href": "stun://test/b", + "origin": "null", + "protocol": "stun:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/b", + "search": "", + "hash": "" + }, + { + "input": "stun://:443", + "base": null, + "failure": true + }, + { + "input": "stun://test:test", + "base": null, + "failure": true + }, + { + "input": "stun://[:1]", + "base": null, + "failure": true + }, + { + "input": "w://x:0", + "base": null, + "href": "w://x:0", + "origin": "null", + "protocol": "w:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "west://x:0", + "base": null, + "href": "west://x:0", + "origin": "null", + "protocol": "west:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "android://x:0/a", + "base": null, + "href": "android://x:0/a", + "origin": "null", + "protocol": "android:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "drivefs://x:0/a", + "base": null, + "href": "drivefs://x:0/a", + "origin": "null", + "protocol": "drivefs:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "chromeos-steam://x:0/a", + "base": null, + "href": "chromeos-steam://x:0/a", + "origin": "null", + "protocol": "chromeos-steam:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "steam://x:0/a", + "base": null, + "href": "steam://x:0/a", + "origin": "null", + "protocol": "steam:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "materialized-view://x:0/a", + "base": null, + "href": "materialized-view://x:0/a", + "origin": "null", + "protocol": "materialized-view:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "/a", + "search": "", + "hash": "" + }, + { + "input": "android-app://x:0", + "base": null, + "href": "android-app://x:0", + "origin": "null", + "protocol": "android-app:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "chrome-distiller://x:0", + "base": null, + "href": "chrome-distiller://x:0", + "origin": "null", + "protocol": "chrome-distiller:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "chrome-extension://x:0", + "base": null, + "href": "chrome-extension://x:0", + "origin": "null", + "protocol": "chrome-extension:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "chrome-native://x:0", + "base": null, + "href": "chrome-native://x:0", + "origin": "null", + "protocol": "chrome-native:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "chrome-resource://x:0", + "base": null, + "href": "chrome-resource://x:0", + "origin": "null", + "protocol": "chrome-resource:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "chrome-search://x:0", + "base": null, + "href": "chrome-search://x:0", + "origin": "null", + "protocol": "chrome-search:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "fuchsia-dir://x:0", + "base": null, + "href": "fuchsia-dir://x:0", + "origin": "null", + "protocol": "fuchsia-dir:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + { + "input": "isolated-app://x:0", + "base": null, + "href": "isolated-app://x:0", + "origin": "null", + "protocol": "isolated-app:", + "username": "", + "password": "", + "host": "x:0", + "hostname": "x", + "port": "0", + "pathname": "", + "search": "", + "hash": "" + }, + "Scheme relative path starting with multiple slashes", + { + "input": "///test", + "base": "http://example.org/", + "href": "http://test/", + "protocol": "http:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///\\//\\//test", + "base": "http://example.org/", + "href": "http://test/", + "protocol": "http:", + "username": "", + "password": "", + "host": "test", + "hostname": "test", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///example.org/path", + "base": "http://example.org/", + "href": "http://example.org/path", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + { + "input": "///example.org/../path", + "base": "http://example.org/", + "href": "http://example.org/path", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + { + "input": "///example.org/../../", + "base": "http://example.org/", + "href": "http://example.org/", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///example.org/../path/../../", + "base": "http://example.org/", + "href": "http://example.org/", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "///example.org/../path/../../path", + "base": "http://example.org/", + "href": "http://example.org/path", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + { + "input": "/\\/\\//example.org/../path", + "base": "http://example.org/", + "href": "http://example.org/path", + "protocol": "http:", + "username": "", + "password": "", + "host": "example.org", + "hostname": "example.org", + "port": "", + "pathname": "/path", + "search": "", + "hash": "" + }, + { + "input": "///abcdef/../", + "base": "file:///", + "href": "file:///", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + { + "input": "/\\//\\/a/../", + "base": "file:///", + "href": "file://////", + "protocol": "file:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "////", + "search": "", + "hash": "" + }, + { + "input": "//a/../", + "base": "file:///", + "href": "file://a/", + "protocol": "file:", + "username": "", + "password": "", + "host": "a", + "hostname": "a", + "port": "", + "pathname": "/", + "search": "", + "hash": "" + }, + "# Non-special URL and backslashes", + { + "input": "non-special:\\\\opaque", + "base": null, + "href": "non-special:\\\\opaque", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "\\\\opaque", + "search": "", + "hash": "" + }, + { + "input": "non-special:\\\\opaque/path", + "base": null, + "href": "non-special:\\\\opaque/path", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "\\\\opaque/path", + "search": "", + "hash": "" + }, + { + "input": "non-special:\\\\opaque\\path", + "base": null, + "href": "non-special:\\\\opaque\\path", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "\\\\opaque\\path", + "search": "", + "hash": "" + }, + { + "input": "non-special:\\/opaque", + "base": null, + "href": "non-special:\\/opaque", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "\\/opaque", + "search": "", + "hash": "" + }, + { + "input": "non-special:/\\path", + "base": null, + "href": "non-special:/\\path", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "/\\path", + "search": "", + "hash": "" + }, + { + "input": "non-special://host\\a", + "base": null, + "failure": true + }, + { + "input": "non-special://host/a\\b", + "base": null, + "href": "non-special://host/a\\b", + "origin": "null", + "protocol": "non-special:", + "username": "", + "password": "", + "host": "host", + "hostname": "host", + "port": "", + "pathname": "/a\\b", + "search": "", + "hash": "" + }, + "# Non-special relative URL and backslashes", + { + "comment": "Non-special relative: \\ should not enter RelativeSlash state", + "input": "\\a", + "base": "foo://foo/a", + "href": "foo://foo/\\a", + "protocol": "foo:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/\\a", + "search": "", + "hash": "" + }, + { + "comment": "Non-special relative: \\ should not enter RelativeSlash, so / is a path separator", + "input": "\\/a", + "base": "foo://foo/a", + "href": "foo://foo/\\/a", + "protocol": "foo:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/\\/a", + "search": "", + "hash": "" + }, + { + "comment": "Non-special relative: \\\\ should not trigger authority parsing", + "input": "\\\\a", + "base": "foo://foo/a", + "href": "foo://foo/\\\\a", + "protocol": "foo:", + "username": "", + "password": "", + "host": "foo", + "hostname": "foo", + "port": "", + "pathname": "/\\\\a", + "search": "", + "hash": "" + }, + { + "comment": "Fragment with <> on data: URI", + "input": "data:text/plain,test# ", + "base": null, + "href": "data:text/plain,test#%3Cfoo%3E%20%3Cbar%3E", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "text/plain,test", + "search": "", + "hash": "#%3Cfoo%3E%20%3Cbar%3E" + }, + { + "comment": "Fragment with <> on about:blank", + "input": "about:blank# ", + "base": null, + "href": "about:blank#%3Cfoo%3E%20%3Cbar%3E", + "protocol": "about:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blank", + "search": "", + "hash": "#%3Cfoo%3E%20%3Cbar%3E" + }, + { + "comment": "Fragment percent-encode set on data: URI; tabs and newlines are removed", + "input":"data:text/plain,test#\u0000\u0001\t\n\r\u001f !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080\u0081Éé", + "base": null, + "href": "data:text/plain,test#%00%01%1F%20!%22#$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + "protocol": "data:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "text/plain,test", + "search": "", + "hash": "#%00%01%1F%20!%22#$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9" + }, + { + "comment": "Fragment percent-encode set on about:blank; tabs and newlines are removed", + "input": "about:blank#\u0000\u0001\t\n\r\u001f !\"#$%&'()*+,-./09:;<=>?@AZ[\\]^_`az{|}~\u007f\u0080\u0081Éé", + "base": null, + "href": "about:blank#%00%01%1F%20!%22#$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9", + "protocol": "about:", + "username": "", + "password": "", + "host": "", + "hostname": "", + "port": "", + "pathname": "blank", + "search": "", + "hash": "#%00%01%1F%20!%22#$%&'()*+,-./09:;%3C=%3E?@AZ[\\]^_%60az{|}~%7F%C2%80%C2%81%C3%89%C3%A9" + } +] diff --git a/packages/router-core/tests/history-normalization.test.ts b/packages/router-core/tests/history-normalization.test.ts index 28dc31f3e6c..3d0bf8fbbc5 100644 --- a/packages/router-core/tests/history-normalization.test.ts +++ b/packages/router-core/tests/history-normalization.test.ts @@ -3,9 +3,48 @@ import { describe, expect, test } from 'vitest' import { BaseRootRoute, BaseRoute } from '../src' import { createTestRouter } from './routerTestUtils' -const fragments = ['/\\section', '\\/section', '\\\\section'] +const fragments = ['//section', '/\\section', '\\/section', '\\\\section'] describe('history normalization boundaries', () => { + test.each([false, true])( + 'normalizes a pathname exposed by the basepath input rewrite (server=%s)', + async (isServer) => { + const root = new BaseRootRoute() + const target = new BaseRoute({ + getParentRoute: () => root, + path: '/target', + loader: () => 'target data', + }) + const router = createTestRouter({ + routeTree: root.addChildren([target]), + basepath: '/app', + isServer, + history: createMemoryHistory({ + initialEntries: ['/app//target#//section'], + }), + }) + + await router.load() + + expect(router.state.location.pathname).toBe('/target') + expect(router.state.location.hash).toBe('//section') + if (isServer) { + expect(router._serverResult?.type).toBe('redirect') + if (router._serverResult?.type === 'redirect') { + expect(router._serverResult.redirect.headers.get('Location')).toBe( + '/app/target#//section', + ) + } + } else { + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: target.id, + status: 'success', + loaderData: 'target data', + }) + } + }, + ) + test('preserves distinct logical and masked fragment data through a rewrite', async () => { const router = createTestRouter({ routeTree: new BaseRootRoute(), @@ -59,6 +98,26 @@ describe('history normalization boundaries', () => { }, ) + test('normalizes protocol-relative paths from output rewrites', () => { + const origin = 'https://victim.example' + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history: createMemoryHistory({ initialEntries: ['/'] }), + origin, + rewrite: { + output: ({ url }) => + url.pathname === '/safe' + ? new URL(`${origin}//evil.example/path`) + : url, + }, + }) + + const location = router.buildLocation({ to: '/safe' }) + + expect(location.external).toBe(false) + expect(location.publicHref).toBe('/evil.example/path') + }) + test.each(fragments)('preserves fragment data %j on initial load', (hash) => { const router = createTestRouter({ routeTree: new BaseRootRoute(), diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts index 138e3bc2360..484e8d0e710 100644 --- a/packages/router-core/tests/load.test.ts +++ b/packages/router-core/tests/load.test.ts @@ -76,6 +76,32 @@ describe('redirect resolution', () => { }, ) + test.each([ + '//evil.example', + '/\\evil.example', + '/\\\\evil.example', + '/\\/evil.example', + '\\/evil.example', + '\\\\evil.example', + ])('server loaders do not emit an unsafe Location for %j', async (href) => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ href }), + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + isServer: true, + }) + + const response = await loadServerResponse(router, '/source') + + expect(response.status).toBe(500) + expect(response.headers.get('Location')).toBeNull() + }) + test.each([ [ 'preserves an explicit same-origin document redirect', @@ -116,6 +142,197 @@ describe('redirect resolution', () => { expect(windowLocation.replace).toHaveBeenCalledWith(expectedHref) }) }) + + test.each(['javascript:alert(1)', 'blob:https://victim.example/id'])( + 'client loaders block redirects made dangerous by output rewrite %j', + async (dangerousHref) => { + const unsafeRedirect = redirect({ to: '/target' }) + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => unsafeRedirect, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history, + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/target' ? new URL(dangerousHref) : url, + }, + }) + + await router.navigate({ to: '/source' }) + + expect(history.location.href).toBe('/source') + expect(unsafeRedirect.options.href).toBeUndefined() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: '/source', + status: 'error', + error: expect.objectContaining({ + message: expect.stringMatching(/Redirect blocked: unsafe protocol/), + }), + }) + }, + ) + + test('client loaders block masked redirects made dangerous by output rewrites', async () => { + const unsafeRedirect = redirect({ + to: '/target', + mask: { to: '/pretty' }, + }) + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => unsafeRedirect, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const history = createMemoryHistory({ initialEntries: ['/'] }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history, + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/pretty' ? new URL('javascript:alert(1)') : url, + }, + }) + + await router.navigate({ to: '/source' }) + + expect(history.location.href).toBe('/source') + expect(unsafeRedirect.options.href).toBeUndefined() + expect(router.state.matches.at(-1)).toMatchObject({ + routeId: '/source', + status: 'error', + error: expect.objectContaining({ + message: expect.stringMatching(/Redirect blocked: unsafe protocol/), + }), + }) + }) + + test('client loaders document-navigate an external output rewrite', async () => { + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ to: '/target' }), + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + origin: 'https://victim.example', + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/target' + ? new URL('https://other.example/rewritten') + : url, + }, + }) + const windowLocation = { href: '', replace: vi.fn() } + vi.stubGlobal('window', { location: windowLocation }) + + try { + void router.navigate({ to: '/source' }) + + await vi.waitFor(() => { + expect(windowLocation.replace).toHaveBeenCalledWith( + 'https://other.example/rewritten', + ) + }) + } finally { + vi.unstubAllGlobals() + } + }) + + test('preloading stops at a redirect with an external output rewrite', async () => { + const targetLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ to: '/target' }), + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: targetLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + origin: 'https://victim.example', + isServer: false, + rewrite: { + input: ({ url }) => url, + output: ({ url }) => + url.pathname === '/target' + ? new URL('https://other.example/rewritten') + : url, + }, + }) + + await router.preloadRoute({ to: '/source' }) + + expect(targetLoader).not.toHaveBeenCalled() + }) + + test('preloading follows an inferred same-origin absolute redirect', async () => { + const targetLoader = vi.fn() + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ href: 'https://victim.example/app/pretty' }), + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + loader: targetLoader, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/app/'] }), + origin: 'https://victim.example', + basepath: '/app', + isServer: false, + rewrite: { + input: ({ url }) => { + if (url.pathname === '/pretty') { + url.pathname = '/target' + } + return url + }, + output: ({ url }) => { + if (url.pathname === '/target') { + url.pathname = '/pretty' + } + return url + }, + }, + }) + + await router.preloadRoute({ to: '/source' }) + + expect(targetLoader).toHaveBeenCalledOnce() + }) }) describe('notFound detection', () => { diff --git a/packages/router-core/tests/public-preload-lane-contract.test.ts b/packages/router-core/tests/public-preload-lane-contract.test.ts index 9be86c1b036..a16c64b2dc8 100644 --- a/packages/router-core/tests/public-preload-lane-contract.test.ts +++ b/packages/router-core/tests/public-preload-lane-contract.test.ts @@ -1385,6 +1385,40 @@ describe('public preload lane contracts', () => { }) }) + test('limits inferred same-origin absolute redirects during preload', async () => { + const origin = 'https://example.com' + const beforeLoad = vi.fn(({ params }) => { + const hop = Number(params.hop) + if (hop < 21) { + throw redirect({ href: `${origin}/hop/${hop + 1}` }) + } + }) + const rootRoute = new BaseRootRoute({}) + const hopRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/hop/$hop', + beforeLoad, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([hopRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + origin, + isServer: false, + }) + + const matches = await router.preloadRoute({ + to: '/hop/$hop', + params: { hop: '0' }, + } as any) + + expect(beforeLoad).toHaveBeenCalledTimes(21) + expect(matches?.find((match) => match.status !== 'success')).toMatchObject({ + routeId: rootRoute.id, + status: 'error', + error: expect.objectContaining({ message: 'Too many redirects' }), + }) + }) + test('forwards a document redirect at the redirect limit', async () => { const beforeLoad = vi.fn(({ params }) => { const hop = Number(params.hop) diff --git a/packages/router-core/tests/redirect-resolution.test.ts b/packages/router-core/tests/redirect-resolution.test.ts index 31640c06169..b8fa4396096 100644 --- a/packages/router-core/tests/redirect-resolution.test.ts +++ b/packages/router-core/tests/redirect-resolution.test.ts @@ -25,6 +25,22 @@ describe('redirect destination classification', () => { ['https://app.example:444/target', 'https://app.example:444/target', true], ['mailto:person@example.com', 'mailto:person@example.com', true], ['tel:+123456789', 'tel:+123456789', true], + [ + 'https://user@app.example/target', + 'https://user@app.example/target', + true, + ], + [ + 'https://:password@app.example/target', + 'https://:password@app.example/target', + true, + ], + [ + 'HTTPS://APP.EXAMPLE:443//other.example/path', + 'https://app.example//other.example/path', + true, + ], + ['HTTPS://OTHER.EXAMPLE:443//path', 'https://other.example//path', true], ] as const const sameOriginDocumentCases = [ @@ -38,9 +54,7 @@ describe('redirect destination classification', () => { ] as const for (const reloadDocument of [undefined, false, true]) { - test.each( - reloadDocument === true ? [...cases, ...sameOriginDocumentCases] : cases, - )( + test.each([...cases, ...sameOriginDocumentCases])( `resolves %j with reloadDocument=${reloadDocument}`, (href, expectedHref, external) => { const router = createRouter() @@ -63,13 +77,68 @@ describe('redirect destination classification', () => { } test.each([ + '//other.example/path', + '//other.example:443/path', + '/\\other.example/path', + '\\\\other.example/path', + '/\t/other.example/path', 'javascript:alert(1)', 'java\tscript:alert(1)', 'custom:value', '\x01Ja\tvaScript:alert(1)', + '\x01/\\other.example/path', ])('rejects the unsafe destination %j', (href) => { expect(() => createRouter().resolveRedirect(redirect({ href }))).toThrow( 'Redirect blocked', ) }) + + test.each(['custom://[', 'blob:https://app.example/id'])( + 'keeps an explicitly allowed non-HTTP destination opaque: %s', + (href) => { + const router = createRouter(['custom:', 'blob:']) + const result = router.resolveRedirect(redirect({ href })) + expect(result.options.href).toBe(href) + expect(result.options.reloadDocument).toBe(true) + }, + ) + + test('does not implicitly allow HTTP when the configured allowlist excludes it', () => { + expect(() => + createRouter([]).resolveRedirect( + redirect({ href: 'https://app.example/target' }), + ), + ).toThrow('Redirect blocked') + }) + + test('classifies the Location header that takes precedence over options.href', () => { + const router = createRouter() + const result = router.resolveRedirect( + redirect({ + href: 'javascript:alert(1)', + headers: { Location: 'https://app.example/target' }, + }), + ) + expect(result.options.href).toBe('/target') + expect(result.options.reloadDocument).toBeUndefined() + expect(() => + router.resolveRedirect( + redirect({ + href: '/safe', + headers: { Location: '//other.example/path' }, + }), + ), + ).toThrow('Redirect blocked') + }) + + test('rejects an obfuscated scheme in the authoritative Location header', () => { + expect(() => + createRouter().resolveRedirect( + redirect({ + href: '/safe', + headers: { Location: '\x01Ja\tvaScript:alert(1)' }, + }), + ), + ).toThrow('Redirect blocked') + }) }) diff --git a/packages/router-core/tests/redirect-target-error.test.ts b/packages/router-core/tests/redirect-target-error.test.ts index d6a2063aa5b..4a6f5cb8fd7 100644 --- a/packages/router-core/tests/redirect-target-error.test.ts +++ b/packages/router-core/tests/redirect-target-error.test.ts @@ -266,6 +266,32 @@ describe('redirect target errors', () => { }) }) + test('a protocol-relative redirect becomes the originating route error', async () => { + const onError = vi.fn() + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + loader: () => redirect({ href: '/\\evil.example' }), + onError, + errorComponent: () => null, + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute]), + history: createMemoryHistory({ initialEntries: ['/source'] }), + }) + + await router.load() + + const error = onError.mock.calls[0]?.[0] + expect(error).toEqual( + expect.objectContaining({ + message: expect.stringContaining('unsafe protocol'), + }), + ) + expect(router.state.location.pathname).toBe('/source') + }) + test('a preload does not build a document redirect target', async () => { const search = vi.fn(() => ({ redirected: true })) const rootRoute = new BaseRootRoute({}) @@ -483,7 +509,7 @@ describe('redirect target errors', () => { }) }) - test.each([true])( + test.each([false, true])( 'document redirects materialize the mask within route error handling (throws=%s)', async (throws) => { const boom = new Error('mask search failed') @@ -538,4 +564,72 @@ describe('redirect target errors', () => { } }, ) + + test('an external rewrite does not bind a shared redirect to the preload location', async () => { + const loaderStarted = createControlledPromise() + const loaderGate = createControlledPromise>() + const beforeLoad = vi.fn() + const sourceLoader = vi.fn(() => { + loaderStarted.resolve() + return loaderGate + }) + const searchUpdater = vi.fn((search: { version?: number }) => ({ + version: search.version, + })) + const rootRoute = new BaseRootRoute({}) + const sourceRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/source', + validateSearch: (search: Record) => ({ + version: Number(search.version), + }), + beforeLoad, + loader: sourceLoader, + }) + const targetRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/target', + }) + const router = createTestRouter({ + routeTree: rootRoute.addChildren([sourceRoute, targetRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + rewrite: { + output: ({ url }) => + url.pathname === '/target' + ? new URL(url.pathname + url.search, 'https://other.example') + : url, + }, + }) + await router.load() + const matchRoutes = vi.spyOn(router, 'matchRoutes') + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue() + const preload = router.preloadRoute({ + to: '/source', + search: { version: 1 }, + }) + await loaderStarted + router.history.push('/source?version=2') + const navigation = router.load() + await vi.waitFor(() => expect(beforeLoad).toHaveBeenCalledTimes(2)) + const rawRedirect = redirect({ + to: '/target', + search: searchUpdater, + } as any) + loaderGate.resolve(rawRedirect) + await Promise.all([preload, navigation]) + + expect(sourceLoader).toHaveBeenCalledOnce() + expect(searchUpdater).toHaveBeenCalledTimes(2) + expect(navigate).toHaveBeenCalledWith( + expect.objectContaining({ + href: 'https://other.example/target?version=2', + reloadDocument: true, + }), + ) + expect( + matchRoutes.mock.calls.every(([location]) => location !== undefined), + ).toBe(true) + expect(rawRedirect.options.href).toBeUndefined() + expect(rawRedirect.headers.has('Location')).toBe(false) + }) }) diff --git a/packages/router-core/tests/url-standard.test.ts b/packages/router-core/tests/url-standard.test.ts new file mode 100644 index 00000000000..4be6c0dfd68 --- /dev/null +++ b/packages/router-core/tests/url-standard.test.ts @@ -0,0 +1,96 @@ +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { URL as NodeURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_PROTOCOL_ALLOWLIST, + getUrlScheme, + isDangerousProtocol, +} from '../src/utils' + +type UrlFixture = { + input: string + protocol?: string +} + +const sources = [ + { + file: 'urltestdata.json', + sha256: 'b8767904d25029a46e0d297e1320ded3a733322f489caa5a482c886fb11e9238', + }, + { + file: 'urltestdata-javascript-only.json', + sha256: '74eaf2f3b8246b1fe44c388e3ecf8eb8442e01f229418b634acecb9595caddfa', + }, +] +const allowlists = [ + { name: 'default', protocols: new Set(DEFAULT_PROTOCOL_ALLOWLIST) }, + { name: 'empty', protocols: new Set() }, + { + name: 'custom', + protocols: new Set([ + 'https:', + 'javascript:', + 'non-special:', + 'intent:', + 'a1234567890-+.:', + ]), + }, +] + +// These WPT inputs have a protocol-relative prefix but no valid HTTP(S) host. +const malformedProtocolRelative = new Set(['//', '///', '////', '//C|/foo/bar']) + +function getNativeScheme(input: string): string | undefined { + const colon = input.indexOf(':') + if (colon === -1) { + return undefined + } + + // A valid replacement body lets URL recognize the scheme even when the + // original host or port is invalid. No base is supplied, so relative prefixes fail. + try { + return new NodeURL(`${input.slice(0, colon + 1)}//example.com`).protocol + } catch { + return undefined + } +} + +describe.each(sources)('WPT $file', ({ file, sha256 }) => { + const contents = readFileSync( + new NodeURL(`./fixtures/wpt-url/${file}`, import.meta.url), + 'utf8', + ) + // JSON.parse preserves the unpaired surrogates in the JavaScript-only file. + const entries = JSON.parse(contents) as Array + const cases = entries.flatMap((fixture, index) => + typeof fixture === 'string' ? [] : [{ ...fixture, index }], + ) + + it('preserves the complete pinned upstream file', () => { + expect(createHash('sha256').update(contents).digest('hex')).toBe(sha256) + }) + + it.each(cases)('case $index: $input', ({ input, protocol }) => { + const scheme = getNativeScheme(input) + if (scheme !== undefined && protocol !== undefined) { + expect(scheme).toBe(protocol) + } + expect(getUrlScheme(input)).toBe(scheme) + + // Ordinary relative paths inherit the two different base hosts; a + // protocol-relative input supplies its own host, producing the same hostname. + const protocolRelative = + scheme === undefined && + (malformedProtocolRelative.has(input) || + new NodeURL(input, 'https://one.example/').hostname === + new NodeURL(input, 'https://two.example/').hostname) + + for (const { name, protocols } of allowlists) { + // The allowlist and blanket protocol-relative rejection are Router policy. + const blocked = + protocolRelative || (scheme !== undefined && !protocols.has(scheme)) + expect(isDangerousProtocol(input, protocols), name).toBe(blocked) + } + }) +}) diff --git a/packages/router-core/tests/utils.test.ts b/packages/router-core/tests/utils.test.ts index 9e5c14c4138..3a5bfa7030e 100644 --- a/packages/router-core/tests/utils.test.ts +++ b/packages/router-core/tests/utils.test.ts @@ -506,7 +506,7 @@ describe('decodePath', () => { 'https://mozilla.org/?x=%25%D1%88%D0%B5%5C%D0%BB%D0%BB%D1%8B%2F' const expectedResult = 'https://mozilla.org/?x=%25ше%5Cллы%2F' - const result = decodePath(stringToCheck).path + const result = decodePath(stringToCheck) expect(result).toBe(expectedResult) }) @@ -514,34 +514,34 @@ describe('decodePath', () => { it('should handle malformed percent-encodings gracefully', () => { const stringToCheck = 'path%ZZ%D1%88test%5C%C3%A9' // Malformed sequences should remain as-is, valid ones decoded - const result = decodePath(stringToCheck).path + const result = decodePath(stringToCheck) expect(result).toBe(`path%ZZ%D1%88test%5Cé`) }) it('should return empty string unchanged', () => { - expect(decodePath('').path).toBe('') + expect(decodePath('')).toBe('') }) it('should return strings without encoding unchanged', () => { const stringToCheck = 'plain-text-path' - expect(decodePath(stringToCheck).path).toBe(stringToCheck) + expect(decodePath(stringToCheck)).toBe(stringToCheck) }) it('should handle consecutive ignored characters', () => { const stringToCheck = 'test%25%25end' const expectedResult = 'test%25%25end' - expect(decodePath(stringToCheck).path).toBe(expectedResult) + expect(decodePath(stringToCheck)).toBe(expectedResult) }) it('should handle multiple ignored items of the same type with varying case', () => { const stringToCheck = '/params-ps/named/foo%2Fabc/c%2Fh' const expectedResult = '/params-ps/named/foo%2Fabc/c%2Fh' - expect(decodePath(stringToCheck).path).toBe(expectedResult) + expect(decodePath(stringToCheck)).toBe(expectedResult) const stringToCheckWithLowerCase = '/params-ps/named/foo%2Fabc/c%5C%2f%5cAh' const expectedResultWithLowerCase = '/params-ps/named/foo%2Fabc/c%5C%2f%5cAh' - expect(decodePath(stringToCheckWithLowerCase).path).toBe( + expect(decodePath(stringToCheckWithLowerCase)).toBe( expectedResultWithLowerCase, ) }) @@ -551,88 +551,64 @@ describe('decodePath', () => { // %0d stays encoded — no decoding, no stripping, no path mismatch // Output is uppercase hex per RFC 3986 const result = decodePath('/%0d/google.com/') - expect(result.path).toBe('/%0D/google.com/') - expect(result.path).not.toMatch(/^\/\//) - expect(result.handledProtocolRelativeURL).toBe(false) + expect(result).toBe('/%0D/google.com/') + expect(result).not.toMatch(/^\/\//) }) it('should keep LF (%0a) encoded to prevent open redirect', () => { const result = decodePath('/%0a/evil.com/') - expect(result.path).toBe('/%0A/evil.com/') - expect(result.path).not.toMatch(/^\/\//) - expect(result.handledProtocolRelativeURL).toBe(false) + expect(result).toBe('/%0A/evil.com/') + expect(result).not.toMatch(/^\/\//) }) it('should keep CRLF (%0d%0a) encoded to prevent open redirect', () => { const result = decodePath('/%0d%0a/evil.com/') - expect(result.path).toBe('/%0D%0A/evil.com/') - expect(result.path).not.toMatch(/^\/\//) - expect(result.handledProtocolRelativeURL).toBe(false) + expect(result).toBe('/%0D%0A/evil.com/') + expect(result).not.toMatch(/^\/\//) }) it('should keep multiple control characters encoded', () => { const result = decodePath('/%0d%0d%0d/evil.com/') - expect(result.path).toBe('/%0D%0D%0D/evil.com/') - expect(result.path).not.toMatch(/^\/\//) - expect(result.handledProtocolRelativeURL).toBe(false) + expect(result).toBe('/%0D%0D%0D/evil.com/') + expect(result).not.toMatch(/^\/\//) }) it('should keep null bytes encoded', () => { const result = decodePath('/%00/test/') - expect(result.path).toBe('/%00/test/') - expect(result.handledProtocolRelativeURL).toBe(false) - }) - - it('should collapse leading double slashes to prevent protocol-relative URLs', () => { - // Direct // input should still be collapsed as defense-in-depth - const result = decodePath('//evil.com/path') - const url = new URL(result.path, 'http://localhost:3000') - expect(url.origin).toBe('http://localhost:3000') - expect(result.handledProtocolRelativeURL).toBe(true) + expect(result).toBe('/%00/test/') }) it('should handle normal paths unchanged', () => { - expect(decodePath('/users/profile/').path).toBe('/users/profile/') - expect(decodePath('/users/profile/').handledProtocolRelativeURL).toBe( - false, - ) - expect(decodePath('/api/v1/data').path).toBe('/api/v1/data') - expect(decodePath('/api/v1/data').handledProtocolRelativeURL).toBe(false) - }) - - it('should handle double slash only input', () => { - // Direct // input should also be collapsed - const result = decodePath('//') - expect(result.path).toBe('/') - expect(result.handledProtocolRelativeURL).toBe(true) + expect(decodePath('/users/profile/')).toBe('/users/profile/') + expect(decodePath('/api/v1/data')).toBe('/api/v1/data') }) }) describe('WHATWG path percent-encode set preserved', () => { it('should keep curly braces encoded', () => { const result = decodePath('/%7B%7Bapp_name%7D%7D/Makefile') - expect(result.path).toBe('/%7B%7Bapp_name%7D%7D/Makefile') + expect(result).toBe('/%7B%7Bapp_name%7D%7D/Makefile') }) it('should keep angle brackets encoded', () => { - expect(decodePath('/%3Ctest%3E').path).toBe('/%3Ctest%3E') + expect(decodePath('/%3Ctest%3E')).toBe('/%3Ctest%3E') }) it('should keep double quotes encoded', () => { - expect(decodePath('/foo%22bar').path).toBe('/foo%22bar') + expect(decodePath('/foo%22bar')).toBe('/foo%22bar') }) it('should keep backticks encoded', () => { - expect(decodePath('/back%60tick').path).toBe('/back%60tick') + expect(decodePath('/back%60tick')).toBe('/back%60tick') }) it('should decode space (handled by encodePathLikeUrl for outgoing URLs)', () => { - expect(decodePath('/file%20name').path).toBe('/file name') + expect(decodePath('/file%20name')).toBe('/file name') }) it('should still decode safe characters', () => { // Regular letters/unicode should still be decoded - expect(decodePath('/%D1%88%D0%B5%D0%BB%D0%BB%D1%8B').path).toBe('/шеллы') + expect(decodePath('/%D1%88%D0%B5%D0%BB%D0%BB%D1%8B')).toBe('/шеллы') }) }) }) diff --git a/packages/solid-router/src/link.tsx b/packages/solid-router/src/link.tsx index 1f70fa8af93..65fc0303a2a 100644 --- a/packages/solid-router/src/link.tsx +++ b/packages/solid-router/src/link.tsx @@ -4,10 +4,9 @@ import { mergeRefs } from '@solid-primitives/refs' import { deepEqual, - exactPathTest, functionalUpdate, + getUrlScheme, hasKeys, - isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -127,6 +126,7 @@ export function useLinkProps< 'reloadDocument', 'unsafeRelative', 'from', + 'href', ]) const currentLocation = Solid.createMemo( @@ -153,44 +153,48 @@ export function useLinkProps< const publicHref = location.publicHref const external = location.external - if (external) { - return { href: publicHref, external: true } + const href = external + ? publicHref + : router.history.createHref(publicHref) || '/' + if ( + (external || href !== publicHref) && + isDangerousProtocol(href, router.protocolAllowlist) + ) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${href}`) + } + return undefined } - return { - href: router.history.createHref(publicHref) || '/', - external: false, - } + return href }) const externalLink = Solid.createMemo(() => { - const _href = hrefOption() - if (_href?.external) { - // Block dangerous protocols for external links - if (isDangerousProtocol(_href.href, router.protocolAllowlist)) { - if (process.env.NODE_ENV !== 'production') { - console.warn(`Blocked Link with dangerous protocol: ${_href.href}`) - } - return undefined - } - return _href.href - } const to = options.to - if (!isSafeInternal(to) && isAbsoluteUrl(to)) { - // Block dangerous protocols like javascript:, blob:, data: - if (isDangerousProtocol(to!, router.protocolAllowlist)) { + const scheme = typeof to === 'string' && getUrlScheme(to) + if (scheme) { + if (!router.protocolAllowlist.has(scheme)) { if (process.env.NODE_ENV !== 'production') { console.warn(`Blocked Link with dangerous protocol: ${to}`) } - return undefined + return null } return to } - return undefined + + const _href = hrefOption() + if (!_href && !options.disabled) { + return null + } + return _href && getUrlScheme(_href) ? _href : undefined }) const preload = Solid.createMemo(() => { - if (options.reloadDocument || externalLink() || local.disabled) { + if ( + options.reloadDocument || + externalLink() !== undefined || + local.disabled + ) { return false } return local.preload ?? router.options.defaultPreload @@ -199,34 +203,27 @@ export function useLinkProps< local.preloadDelay ?? router.options.defaultPreloadDelay ?? 0 const isActive = Solid.createMemo(() => { - if (externalLink()) return false + if (externalLink() !== undefined) { + return false + } const activeOptions = local.activeOptions const current = currentLocation() const nextLocation = next() - if (activeOptions?.exact) { - const testExact = exactPathTest( - current.pathname, - nextLocation.pathname, - router.basepath, - ) - if (!testExact) { - return false - } - } else { - const currentPath = removeTrailingSlash(current.pathname, router.basepath) - const nextPath = removeTrailingSlash( - nextLocation.pathname, - router.basepath, - ) + const currentPath = removeTrailingSlash(current.pathname, router.basepath) + const nextPath = removeTrailingSlash(nextLocation.pathname, router.basepath) - const pathIsFuzzyEqual = - currentPath.startsWith(nextPath) && - (currentPath.length === nextPath.length || - currentPath[nextPath.length] === '/') - if (!pathIsFuzzyEqual) { - return false - } + // Both modes compare normalized paths; fuzzy matches need a segment boundary. + if ( + activeOptions?.exact + ? currentPath !== nextPath + : !( + currentPath.startsWith(nextPath) && + (currentPath.length === nextPath.length || + currentPath[nextPath.length] === '/') + ) + ) { + return false } if (activeOptions?.includeSearch ?? true) { @@ -305,28 +302,39 @@ export function useLinkProps< } }) - if (externalLink()) { - return Solid.mergeProps( - propsSafeToSpread, - { - ref: mergeRefs(setRef, options.ref), - href: externalLink(), - }, - Solid.splitProps(local, [ - 'target', - 'disabled', - 'style', - 'class', - 'onClick', - 'onBlur', - 'onFocus', - 'onMouseEnter', - 'onMouseLeave', - 'onMouseOut', - 'onMouseOver', - 'onTouchStart', - ])[0], - ) as any + // SSR has no reactive destination changes or internal event handlers. + // Keep this guard inline so browser builds drop the entire shortcut. + if (isServer ?? router.isServer) { + const external = externalLink() + if ( + external !== undefined && + local.activeProps === STATIC_ACTIVE_PROPS_GET && + local.inactiveProps === STATIC_INACTIVE_PROPS_GET && + local.class === undefined && + local.style === undefined + ) { + const disabled = local.disabled || external === null + return Solid.mergeProps( + propsSafeToSpread, + Solid.splitProps(local, [ + 'target', + 'onClick', + 'onBlur', + 'onFocus', + 'onMouseEnter', + 'onMouseLeave', + 'onMouseOut', + 'onMouseOver', + 'onTouchStart', + ])[0], + { + ref: mergeRefs(setRef, options.ref), + href: external ?? undefined, + disabled, + ...(disabled && STATIC_DISABLED_PROPS), + }, + ) as any + } } // The click handler @@ -340,6 +348,7 @@ export function useLinkProps< if ( !local.disabled && + externalLink() === undefined && !(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) && !e.defaultPrevented && (!effectiveTarget || effectiveTarget === '_self') && @@ -407,9 +416,11 @@ export function useLinkProps< const resolvedProps = Solid.createMemo(() => { const active = isActive() + const external = externalLink() + const disabled = local.disabled || external === null const base = { - href: hrefOption()?.href, + href: external === null ? undefined : external || hrefOption(), ref: mergeRefs(setRef, options.ref), onClick, onBlur, @@ -419,9 +430,9 @@ export function useLinkProps< onMouseLeave, onMouseOut, onTouchStart, - disabled: !!local.disabled, + disabled, target: local.target, - ...(local.disabled && STATIC_DISABLED_PROPS), + ...(disabled && STATIC_DISABLED_PROPS), } if (simpleStyling()) { @@ -431,24 +442,18 @@ export function useLinkProps< } } - const activeProps: ResolvedLinkStateProps = active + // Active and inactive props are mutually exclusive. + const stateProps: ResolvedLinkStateProps = active ? (functionalUpdate(local.activeProps as any, {}) ?? EMPTY_OBJECT) - : EMPTY_OBJECT - const inactiveProps: ResolvedLinkStateProps = active - ? EMPTY_OBJECT : functionalUpdate(local.inactiveProps, {}) const style = { ...local.style, - ...activeProps.style, - ...inactiveProps.style, + ...stateProps.style, } - const className = [local.class, activeProps.class, inactiveProps.class] - .filter(Boolean) - .join(' ') + const className = [local.class, stateProps.class].filter(Boolean).join(' ') return { - ...activeProps, - ...inactiveProps, + ...stateProps, ...base, ...(hasKeys(style) ? { style } : undefined), ...(className ? { class: className } : undefined), @@ -661,13 +666,6 @@ export const Link: LinkComponent<'a'> = (props) => { ) } -function isSafeInternal(to: unknown) { - if (typeof to !== 'string') return false - const zero = to.charCodeAt(0) - if (zero === 47) return to.charCodeAt(1) !== 47 // '/' but not '//' - return zero === 46 // '.', '..', './', '../' -} - export type LinkOptionsFnOptions< TOptions, TComp, diff --git a/packages/solid-router/tests/link-href-cases.ts b/packages/solid-router/tests/link-href-cases.ts index 3641cf05ff1..3e47ac99565 100644 --- a/packages/solid-router/tests/link-href-cases.ts +++ b/packages/solid-router/tests/link-href-cases.ts @@ -7,6 +7,15 @@ export const linkHrefCases: Array< active: boolean, ] > = [ + ['history', '//evil.example/path', true, false], + ['history', '/\\evil.example/path', true, false], + ['history', '\\/evil.example/path', true, false], + ['history', '\x01 \t//evil.example/path', true, false], + ['history', 'javascript:blocked()', true, false], ['history', '/formatted', false, true], + ['rewrite', 'javascript:blocked()', true, false], + ['rewrite', 'https://other.example/', false, false], ['rewrite', '/safe', false, true], + ['to', 'myapp:open', false, false], + ['to', 'mailto:person@example.com', true, false], ] diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index cefacc8f1fa..0ac4042149a 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -70,6 +70,150 @@ afterEach(() => { const WAIT_TIME = 300 describe('Link', () => { + test('custom state props follow external, internal and blocked destination changes', async () => { + const [to, setTo] = Solid.createSignal('https://example.com/') + const root = createRootRoute() + const activeProps = () => ({ + class: 'active-state', + style: { color: 'red' }, + }) + const inactiveProps = () => ({ + class: 'inactive-state', + style: { color: 'blue' }, + href: 'javascript:inactive()', + }) + const index = createRoute({ + getParentRoute: () => root, + path: '/', + component: () => ( + + ), + }) + const router = createRouter({ + routeTree: root.addChildren([index]), + history, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + render(() => ) + const link = await screen.findByTestId('transition-link') + for (const destination of [ + 'https://example.com/', + '/', + 'javascript:blocked()', + '/', + ]) { + setTo(destination) + const active = destination === '/' + await waitFor(() => { + expect(link.getAttribute('href')).toBe( + destination.startsWith('javascript:') ? null : destination, + ) + expect(link.getAttribute('class')?.trim()).toBe( + `base ${active ? 'active-state' : 'inactive-state'}`, + ) + expect(link).toHaveStyle({ + color: active ? 'rgb(255, 0, 0)' : 'rgb(0, 0, 255)', + 'font-weight': '700', + }) + }) + } + }) + + test('keeps protocol-relative Link paths on the router origin', async () => { + const inputs = [ + '//evil.example', + '/\\evil.example', + '\\/evil.example', + ' \t/\\evil.example', + ] + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + {inputs.map((to, index) => ( + + ))} + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render(() => ) + + for (let index = 0; index < inputs.length; index++) { + const link = await screen.findByTestId(`unsafe-link-${index}`) + const href = link.getAttribute('href') + expect(href).not.toBeNull() + expect(new URL(href!, window.location.href).origin).toBe( + window.location.origin, + ) + } + }) + + test('blocks a dangerous final href produced by an output rewrite', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + rewrite: { + output: ({ url }) => + url.pathname === '/safe' ? new URL('javascript:alert(1)') : url, + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render(() => ) + const link = await screen.findByTestId('rewritten-link') + + expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('role', 'link') + expect(link).toHaveAttribute('aria-disabled', 'true') + expect(fireEvent.click(link)).toBe(true) + }) + + test('blocks a dangerous final href produced by custom history', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'javascript:alert(1)', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render(() => ) + const link = await screen.findByTestId('custom-history-link') + + expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('role', 'link') + expect(link).toHaveAttribute('aria-disabled', 'true') + expect(fireEvent.click(link)).toBe(true) + customHistory.destroy() + }) + test('does not transform a direct HTTPS link through custom history', async () => { const customHistory = createBrowserHistory({ createHref: () => 'https://other.example/', @@ -97,6 +241,124 @@ describe('Link', () => { } }) + test('keeps a scheme-bearing custom-history href native', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'https://other.example/path', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + + try { + render(() => ) + const link = await screen.findByTestId('custom-history-link') + + expect(link).toHaveAttribute('href', 'https://other.example/path') + expect(fireEvent.click(link)).toBe(true) + } finally { + customHistory.destroy() + } + }) + + test('reactively stops intercepting when a destination becomes external', async () => { + let setTo!: (to: string) => void + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + const [to, _setTo] = Solid.createSignal('/safe') + setTo = _setTo + return + }, + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + render(() => ) + const link = await screen.findByTestId('reactive-external-link') + setTo('https://other.example/path') + await waitFor(() => + expect(link).toHaveAttribute('href', 'https://other.example/path'), + ) + + expect(fireEvent.click(link)).toBe(true) + }) + + test('blocks a custom protocol that is not in the allowlist', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + protocolAllowlist: [], + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render(() => ) + const link = await screen.findByTestId('custom-protocol-link') + + expect(link).not.toHaveAttribute('href') + }) + + test('a blocked link stays inactive and cannot regain an href', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + {({ isActive }) => String(isActive)} + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + rewrite: { + output: ({ url }) => + url.pathname === '/blocked' ? new URL('javascript:alert(1)') : url, + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render(() => ) + + expect( + await screen.findByTestId('blocked-caller-href'), + ).not.toHaveAttribute('href') + expect(screen.getByTestId('blocked-caller-href')).toHaveAttribute( + 'data-inactive', + 'true', + ) + expect(screen.getByTestId('blocked-caller-href')).toHaveTextContent('false') + expect(fireEvent.click(screen.getByTestId('blocked-caller-href'))).toBe( + true, + ) + }) + // rerender doesn't exist in solid // test('when using renderHook it returns a hook with same content to prove rerender works', async () => { diff --git a/packages/solid-router/tests/server/link.test.tsx b/packages/solid-router/tests/server/link.test.tsx index fc52cc4ce96..1c266b524c7 100644 --- a/packages/solid-router/tests/server/link.test.tsx +++ b/packages/solid-router/tests/server/link.test.tsx @@ -2,6 +2,7 @@ import { renderToString } from 'solid-js/web' import { expect, test, vi } from 'vitest' import { + Link, RouterContextProvider, createLink, createMemoryHistory, @@ -13,6 +14,7 @@ import type { JSX } from 'solid-js' test.each([ { to: 'https://example.com/', href: 'https://example.com/' }, { to: '/external', href: 'https://example.com/rewritten' }, + { to: 'javascript:blocked()', href: undefined }, ])('SSR custom links retain caller props for $to', ({ to, href }) => { let received: JSX.AnchorHTMLAttributes | undefined const CustomLink = createLink( @@ -77,3 +79,75 @@ test.each([ warn.mockRestore() } }) + +test.each([ + { to: 'https://example.com/', href: 'https://example.com/' }, + { to: 'myapp:open', href: 'myapp:open' }, + { to: 'javascript:alert(1)', href: undefined }, + { to: 'mailto:person@example.com', href: undefined }, + { to: '/external', href: 'https://example.com/rewritten' }, + { to: '/blocked', href: undefined }, +])('keeps the final SSR href safe and inactive for $to', ({ to, href }) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const router = createRouter({ + routeTree: createRootRoute(), + history: createMemoryHistory(), + isServer: true, + protocolAllowlist: ['http:', 'https:', 'myapp:'], + rewrite: { + output: ({ url }) => + url.pathname === '/external' + ? new URL('https://example.com/rewritten') + : url.pathname === '/blocked' + ? new URL('javascript:alert(1)') + : url, + }, + }) + for (const disabled of [false, true]) { + for (const customStyles of [false, true]) { + const inactiveProps = customStyles + ? { + class: 'inactive', + style: { color: 'red' }, + href: 'javascript:inactive()', + } + : undefined + const html = renderToString(() => ( + + {() => ( + + {({ isActive }) => String(isActive)} + + )} + + )) + // Direct schemes retain their href when disabled; built locations do not. + const expectedHref = disabled && to.startsWith('/') ? undefined : href + if (expectedHref) { + expect(html).toContain(`href="${expectedHref}"`) + } else { + expect(html).not.toContain('href=') + } + expect(html).not.toContain('javascript:') + expect(html).not.toContain('aria-current') + expect(html).toContain('false') + if (disabled || !href) { + expect(html).toContain('aria-disabled="true"') + } + if (customStyles) { + expect(html).toContain('base inactive') + expect(html).toContain('color:red') + } + } + } + } finally { + warn.mockRestore() + } +}) diff --git a/packages/start-plugin-core/src/prerender.ts b/packages/start-plugin-core/src/prerender.ts index 2fbd9e74b9c..9510a9ab379 100644 --- a/packages/start-plugin-core/src/prerender.ts +++ b/packages/start-plugin-core/src/prerender.ts @@ -10,6 +10,7 @@ const DEFAULT_RETRY_DELAY = 500 export interface PrerenderHandler { getClientOutputDirectory: () => string + getOrigin?: () => string request: (path: string, options?: RequestInit) => Promise close?: () => Promise } @@ -44,7 +45,10 @@ export async function prerender({ } const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') + const routerBaseUrl = new URL( + routerBasePath, + handler.getOrigin?.() ?? 'http://localhost', + ) startConfig.pages = validateAndNormalizePrerenderPages( startConfig.pages, @@ -89,8 +93,6 @@ export async function prerender({ const concurrency = startConfig.prerender?.concurrency ?? os.cpus().length logger.info(`Concurrency: ${concurrency}`) const queue = new Queue({ concurrency }) - const routerBasePath = joinURL('/', startConfig.router.basepath ?? '') - const routerBaseUrl = new URL(routerBasePath, 'http://localhost') startConfig.pages = validateAndNormalizePrerenderPages( startConfig.pages, @@ -189,7 +191,19 @@ export async function prerender({ ) const html = await res.text() - const filepath = path.join(outputDir, filename) + const resolvedOutputDir = path.resolve(outputDir) + const filepath = path.resolve(outputDir, filename.replace(/^\/+/, '')) + const outputPrefix = resolvedOutputDir.endsWith(path.sep) + ? resolvedOutputDir + : resolvedOutputDir + path.sep + if ( + filepath !== resolvedOutputDir && + !filepath.startsWith(outputPrefix) + ) { + throw new Error( + `Prerender output path must stay within the client output directory: ${filename}`, + ) + } await fsp.mkdir(path.dirname(filepath), { recursive: true, @@ -239,21 +253,36 @@ export async function prerender({ } async function requestWithRedirects( - path: string, + requestPath: string, options?: RequestInit, maxRedirects: number = 5, + currentUrl = resolveInternalUrl(requestPath, routerBaseUrl, routerBaseUrl), ): Promise { - const response = await handler.request(path, options) + const path = currentUrl && toPreviewPath(currentUrl, routerBaseUrl) + if (!path) { + throw new Error(`Prerender request path must be relative: ${requestPath}`) + } - if (isRedirectResponse(response) && maxRedirects > 0) { - const location = response.headers.get('location')! + const response = await handler.request(path, { + ...options, + redirect: 'manual', + }) - if (location.startsWith('http://localhost') || location.startsWith('/')) { - const nextPath = location.replace('http://localhost', '') - return requestWithRedirects(nextPath, options, maxRedirects - 1) + if (isRedirectResponse(response) && maxRedirects > 0) { + const location = response.headers.get('location')!.trim() + const url = resolveInternalUrl(location, currentUrl, routerBaseUrl) + const redirectPath = url && toPreviewPath(url, routerBaseUrl) + + if (url && redirectPath) { + return requestWithRedirects( + redirectPath, + options, + maxRedirects - 1, + url, + ) } - logger.warn(`Skipping redirect to external location: ${location}`) + logger.warn(`Skipping redirect outside the preview basepath: ${location}`) } return response @@ -261,7 +290,46 @@ export async function prerender({ } function isRedirectResponse(res: Response) { - return res.status >= 300 && res.status < 400 && res.headers.get('location') + return ( + [301, 302, 303, 307, 308].includes(res.status) && + !!res.headers.get('location')?.trim() + ) +} + +function resolveInternalUrl( + href: string, + baseUrl: URL, + allowedOrigin: URL, +): URL | undefined { + try { + const url = new URL(href, baseUrl) + if ( + url.protocol !== allowedOrigin.protocol || + url.origin !== allowedOrigin.origin || + url.username || + url.password + ) { + return undefined + } + return url + } catch { + return undefined + } +} + +function toPreviewPath(url: URL, routerBaseUrl: URL): string | undefined { + const basepath = routerBaseUrl.pathname.replace(/\/$/, '') + if ( + basepath && + url.pathname !== basepath && + !url.pathname.startsWith(basepath + '/') + ) { + return undefined + } + if (url.pathname.startsWith('//')) { + return url.href + } + return url.pathname + url.search } export function validateAndNormalizePrerenderPages( @@ -278,15 +346,29 @@ export function validateAndNormalizePrerenderPages( }) } - if (url.origin !== 'http://localhost') { + if ( + url.protocol !== routerBaseUrl.protocol || + url.origin !== routerBaseUrl.origin || + url.username || + url.password + ) { throw new Error(`prerender page path must be relative: ${page.path}`) } const decodedPathname = decodeURIComponent(url.pathname) + const normalizedPath = decodedPathname + url.search + url.hash + const normalizedUrl = resolveInternalUrl( + normalizedPath, + routerBaseUrl, + routerBaseUrl, + ) + if (!normalizedUrl) { + throw new Error(`prerender page path must be relative: ${page.path}`) + } return { ...page, - path: decodedPathname + url.search + url.hash, + path: normalizedPath, } }) } diff --git a/packages/start-plugin-core/src/vite/prerender.ts b/packages/start-plugin-core/src/vite/prerender.ts index 5eeb76e8d50..5d1838e43aa 100644 --- a/packages/start-plugin-core/src/vite/prerender.ts +++ b/packages/start-plugin-core/src/vite/prerender.ts @@ -38,8 +38,14 @@ export async function prerenderWithVite({ getClientOutputDirectory() { return outputDir }, + getOrigin() { + return baseUrl.origin + }, request(path, options) { const url = new URL(path, baseUrl) + if (url.origin !== baseUrl.origin) { + throw new Error(`Prerender request URL must be relative: ${path}`) + } return fetch(new Request(url, options)) }, close() { diff --git a/packages/start-plugin-core/tests/prerender-ssrf.test.ts b/packages/start-plugin-core/tests/prerender-ssrf.test.ts index 0e649dcbe6f..06050fdeee2 100644 --- a/packages/start-plugin-core/tests/prerender-ssrf.test.ts +++ b/packages/start-plugin-core/tests/prerender-ssrf.test.ts @@ -78,7 +78,25 @@ describe('prerender public sinks', () => { expect(request).not.toHaveBeenCalled() }) - it.each([{ outputPath: 'nested', expected: '/client/nested/index.html' }])( + it('requests relative pages without automatic redirect following', async () => { + const request = vi.fn(async () => htmlResponse()) + + await prerender({ + startConfig: makeStartConfig('/about'), + handler: { getClientOutputDirectory: () => '/client', request }, + }) + + expect(request).toHaveBeenCalledWith( + '/about/', + expect.objectContaining({ redirect: 'manual' }), + ) + }) + + it.each([ + { outputPath: '../escape', expected: undefined }, + { outputPath: '../client-leak', expected: undefined }, + { outputPath: 'nested', expected: '/client/nested/index.html' }, + ])( 'keeps output $outputPath inside the client directory', async ({ outputPath, expected }) => { vi.mocked(fsp.writeFile).mockClear() @@ -109,8 +127,57 @@ describe('prerender public sinks', () => { }, ) - it.each(['https://attacker.test/leak'])( - 'does not request raw redirect target %j', + it.each([ + 'https://attacker.test/leak', + '//attacker.test/leak', + '/\\attacker.test/leak', + ])('does not request raw redirect target %j', async (location) => { + const request = vi.fn( + async () => + new Response(null, { + status: 307, + headers: { location }, + }), + ) + const startConfig = makeStartConfig('/about') + startConfig.prerender.failOnError = false + + await prerender({ + startConfig, + handler: { getClientOutputDirectory: () => '/client', request }, + }) + + expect(request).toHaveBeenCalledTimes(1) + }) + + it('keeps a canonical double-slash redirect on the preview origin', async () => { + const requestedUrls: Array = [] + const request = vi.fn(async (requestPath: string) => { + requestedUrls.push(new URL(requestPath, 'http://localhost').href) + if (requestedUrls.length === 1) { + return new Response(null, { + status: 307, + headers: { + location: 'http://localhost/a/..//attacker.test/leak', + }, + }) + } + return htmlResponse() + }) + + await prerender({ + startConfig: makeStartConfig('/about'), + handler: { getClientOutputDirectory: () => '/client', request }, + }) + + expect(requestedUrls).toEqual([ + 'http://localhost/about/', + 'http://localhost//attacker.test/leak', + ]) + }) + + it.each(['/outside', '/application', '/app-private', '/app/../outside'])( + 'does not request a redirect outside the router basepath: %s', async (location) => { const request = vi.fn( async () => @@ -119,7 +186,8 @@ describe('prerender public sinks', () => { headers: { location }, }), ) - const startConfig = makeStartConfig('/about') + const startConfig = makeStartConfig('/page') + startConfig.router.basepath = '/app' startConfig.prerender.failOnError = false await prerender({ @@ -127,33 +195,39 @@ describe('prerender public sinks', () => { handler: { getClientOutputDirectory: () => '/client', request }, }) - expect(request).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledOnce() + expect(request).toHaveBeenCalledWith( + '/app/page/', + expect.objectContaining({ redirect: 'manual' }), + ) }, ) - it('keeps a canonical double-slash redirect on the preview origin', async () => { - const requestedUrls: Array = [] + it('follows a raw redirect on the preview origin and basepath', async () => { const request = vi.fn(async (requestPath: string) => { - requestedUrls.push(new URL(requestPath, 'http://localhost').href) - if (requestedUrls.length === 1) { + if (requestPath === '/app/page/') { return new Response(null, { status: 307, - headers: { - location: 'http://localhost/a/..//attacker.test/leak', - }, + headers: { location: 'http://127.0.0.1:4173/app/next/' }, }) } return htmlResponse() }) + const startConfig = makeStartConfig('/page') + startConfig.router.basepath = '/app' await prerender({ - startConfig: makeStartConfig('/about'), - handler: { getClientOutputDirectory: () => '/client', request }, + startConfig, + handler: { + getClientOutputDirectory: () => '/client', + getOrigin: () => 'http://127.0.0.1:4173', + request, + }, }) - expect(requestedUrls).toEqual([ - 'http://localhost/about/', - 'http://localhost//attacker.test/leak', + expect(request.mock.calls.map(([requestPath]) => requestPath)).toEqual([ + '/app/page/', + '/app/next/', ]) }) diff --git a/packages/start-plugin-core/tests/prerender-vite.test.ts b/packages/start-plugin-core/tests/prerender-vite.test.ts new file mode 100644 index 00000000000..013557e4dfa --- /dev/null +++ b/packages/start-plugin-core/tests/prerender-vite.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { prerenderWithVite } from '../src/vite/prerender' + +const preview = vi.hoisted(() => vi.fn()) + +vi.mock('../src/utils', async () => { + const actual = await vi.importActual('../src/utils') + return { + ...actual, + createLogger: () => ({ info: () => {}, warn: () => {}, error: () => {} }), + } +}) +vi.mock('vite', () => ({ preview })) + +const originalPrerendering = process.env.TSS_PRERENDERING +const originalClientOutputDir = process.env.TSS_CLIENT_OUTPUT_DIR + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + +function makeStartConfig() { + return { + prerender: { + enabled: true, + autoStaticPathsDiscovery: false, + concurrency: 1, + failOnError: false, + }, + pages: [{ path: '/about' }], + router: { basepath: '' }, + spa: { + enabled: false, + prerender: { + outputPath: '/_shell', + crawlLinks: false, + retryCount: 0, + enabled: true, + }, + }, + } as any +} + +describe('Vite prerender network sink', () => { + beforeEach(() => { + preview.mockReset().mockResolvedValue({ + resolvedUrls: { local: ['http://127.0.0.1:4173/'] }, + close: vi.fn(), + }) + }) + + afterEach(() => { + restoreEnv('TSS_PRERENDERING', originalPrerendering) + restoreEnv('TSS_CLIENT_OUTPUT_DIR', originalClientOutputDir) + vi.unstubAllGlobals() + }) + + it('does not fetch a raw redirect outside the preview origin', async () => { + const fetch = vi.fn( + async (_input: string | URL | Request, _init?: RequestInit) => + new Response(null, { + status: 307, + headers: { location: 'https://attacker.test/leak' }, + }), + ) + vi.stubGlobal('fetch', fetch) + const builder = { + environments: { + ssr: { config: { configFile: '/vite.config.ts' } }, + client: { config: { build: { outDir: '/client' } } }, + }, + } as any + + await prerenderWithVite({ startConfig: makeStartConfig(), builder }) + + expect(fetch).toHaveBeenCalledOnce() + const request = fetch.mock.calls[0]![0] + expect(request).toBeInstanceOf(Request) + if (!(request instanceof Request)) { + throw new Error('Expected the Vite prerender sink to fetch a Request') + } + expect(request.url).toBe('http://127.0.0.1:4173/about/') + expect(request.redirect).toBe('manual') + }) + + it('follows an absolute redirect on the actual Vite preview origin', async () => { + const fetch = vi.fn( + async (_input: string | URL | Request) => + new Response(null, { status: 404 }), + ) + fetch.mockResolvedValueOnce( + new Response(null, { + status: 307, + headers: { location: 'http://127.0.0.1:4173/next/?from=about' }, + }), + ) + vi.stubGlobal('fetch', fetch) + const builder = { + environments: { + ssr: { config: { configFile: '/vite.config.ts' } }, + client: { config: { build: { outDir: '/client' } } }, + }, + } as any + + await prerenderWithVite({ startConfig: makeStartConfig(), builder }) + + expect(fetch).toHaveBeenCalledTimes(2) + const requests = fetch.mock.calls.map(([request]) => { + expect(request).toBeInstanceOf(Request) + if (!(request instanceof Request)) { + throw new Error('Expected the Vite prerender sink to fetch a Request') + } + expect(request.redirect).toBe('manual') + return request.url + }) + expect(requests).toEqual([ + 'http://127.0.0.1:4173/about/', + 'http://127.0.0.1:4173/next/?from=about', + ]) + }) +}) diff --git a/packages/start-server-core/src/createStartHandler.ts b/packages/start-server-core/src/createStartHandler.ts index c7b427bfb1f..a25dd7ac55b 100644 --- a/packages/start-server-core/src/createStartHandler.ts +++ b/packages/start-server-core/src/createStartHandler.ts @@ -10,8 +10,8 @@ import { import { _getRenderedMatches, executeRewriteInput, + isDangerousProtocol, isRedirect, - isResolvedRedirect, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, @@ -626,9 +626,9 @@ export function createStartHandler( const result = await handleRedirectResponse( middlewareResponse, - request, getRouter, request.signal, + request.headers.get('x-tsr-serverFn') === 'true', ) bindSsrResponseToRequest(router ?? undefined, result, request.signal) request.signal.throwIfAborted() @@ -771,9 +771,9 @@ export function createStartHandler( const response = await handleRedirectResponse( middlewareResponse, - request, getRouter, request.signal, + false, ) bindSsrResponseToRequest(router ?? undefined, response, request.signal) request.signal.throwIfAborted() @@ -793,11 +793,13 @@ export function createStartHandler( return requestHandler(startRequestResolver) } +const relativeRedirectProtocols = new Set() + async function handleRedirectResponse( response: HandlerCallbackResult, - request: Request, getRouter: () => Promise, signal: AbortSignal, + serializeRedirect: boolean, ): Promise { signal.throwIfAborted() const ssrResponse = normalizeSsrResponse(response) @@ -805,31 +807,21 @@ async function handleRedirectResponse( return ssrResponse } - if (isResolvedRedirect(ssrResponse.response)) { - if (request.headers.get('x-tsr-serverFn') === 'true') { - return waitForRequest( - replaceSsrResponse( - ssrResponse, - Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, - ), - 'redirect response replaced', - ), - signal, - ) - } - return ssrResponse - } - const opts = ssrResponse.response.options - if (opts.to && typeof opts.to === 'string' && !opts.to.startsWith('/')) { + const href = ssrResponse.response.headers.get('Location') || opts.href + if ( + !href && + opts.to && + typeof opts.to === 'string' && + !opts.to.startsWith('/') + ) { throw new Error( `Server side redirects must use absolute paths via the 'href' or 'to' options. The redirect() method's "to" property accepts an internal path only. Use the "href" property to provide an external URL. Received: ${JSON.stringify(opts)}`, ) } if ( + !href && ['params', 'search', 'hash'].some( (d) => typeof (opts as TODO)[d] === 'function', ) @@ -845,17 +837,32 @@ async function handleRedirectResponse( } signal.throwIfAborted() - const router = await waitForRequest(getRouter(), signal) - signal.throwIfAborted() - const redirect = router.resolveRedirect(ssrResponse.response) + let redirect = ssrResponse.response + // Unambiguous relative hrefs need no route resolution or protocol policy. + // Every scheme falls back to the router to honor its custom allowlist. + if (href && !isDangerousProtocol(href, relativeRedirectProtocols)) { + redirect.options.href = href + redirect.headers.set('Location', href) + } else { + const router = await waitForRequest(getRouter(), signal) + signal.throwIfAborted() + redirect = router.resolveRedirect(redirect) + } - if (request.headers.get('x-tsr-serverFn') === 'true') { + if (serializeRedirect) { + const redirectOptions = { ...(redirect.options as TODO) } + delete redirectOptions.headers + const responseHeaders = new Headers(redirect.headers) + responseHeaders.set('content-type', 'application/json') return waitForRequest( replaceSsrResponse( ssrResponse, Response.json( - { ...ssrResponse.response.options, isSerializedRedirect: true }, - { headers: ssrResponse.response.headers }, + { + ...redirectOptions, + isSerializedRedirect: true, + }, + { headers: responseHeaders }, ), 'redirect response replaced', ), @@ -980,9 +987,9 @@ async function handleServerRoutes({ const resolved = await handleRedirectResponse( response, - request, getRouter, request.signal, + false, ) return waitForRequest( stripSsrResponseBody(resolved, 'HEAD body stripped'), diff --git a/packages/start-server-core/tests/createStartHandler.test.ts b/packages/start-server-core/tests/createStartHandler.test.ts index ea4274f5c79..9a4affb0c24 100644 --- a/packages/start-server-core/tests/createStartHandler.test.ts +++ b/packages/start-server-core/tests/createStartHandler.test.ts @@ -15,9 +15,12 @@ import { BaseRootRoute, BaseRoute, RouterCore, + redirect, + type AnyRouter, +} from '@tanstack/router-core' +import { createNonReactiveMutableStore, createNonReactiveReadonlyStore, - redirect, } from '@tanstack/router-core' import { attachRouterServerSsrUtils, @@ -28,7 +31,6 @@ import { getStaticHandlerInlineCssDefault, resolveInlineCssForRequest, } from '../src/inlineCss' -import type { AnyRouter } from '@tanstack/router-core' const startMocks = vi.hoisted(() => { const previousServerFnBase = process.env.TSS_SERVER_FN_BASE @@ -145,6 +147,55 @@ afterAll(() => { }) describe('createStartHandler redirect safety', () => { + it.each( + [false, true].flatMap((rpc) => + ['relative-to', 'functional-options'].flatMap((options) => + ['/login', 'http://localhost/login', '//evil.example'].map((href) => ({ + rpc, + options, + href, + })), + ), + ), + )( + 'uses an explicit Location before ignored $options (RPC=$rpc, href=$href)', + async ({ rpc, options, href }) => { + const factory = vi.fn(makeRouter) + const updater = vi.fn(() => ({})) + const hash = vi.fn(() => 'ignored') + startMocks.routerFactory = factory + const headers = { Location: href } + const result = + options === 'relative-to' + ? redirect({ headers, to: 'ignored' }) + : redirect({ headers, search: updater, params: updater, hash }) + if (rpc) { + startMocks.serverFnResult = result + } else { + startMocks.requestMiddleware = [createMiddleware().server(() => result)] + } + const handler = createStartHandler(() => new Response('unused')) + const response = await handler( + new Request(`http://localhost/${rpc ? '_serverFn/test' : ''}`, { + headers: rpc ? { 'x-tsr-serverFn': 'true' } : undefined, + }), + {}, + ) + const blocked = href.startsWith('//') + expect(response.status).toBe(blocked ? 500 : rpc ? 200 : 307) + expect(response.headers.get('Location')).toBe(blocked ? null : '/login') + expect(factory).toHaveBeenCalledTimes(href === '/login' ? 0 : 1) + expect(updater).not.toHaveBeenCalled() + expect(hash).not.toHaveBeenCalled() + if (rpc && !blocked) { + expect(await response.json()).toMatchObject({ + href: '/login', + isSerializedRedirect: true, + }) + } + }, + ) + it.each( [undefined, ''].flatMap((location) => ['to', 'params', 'search', 'hash'].map((option) => ({ @@ -230,6 +281,64 @@ describe('createStartHandler redirect safety', () => { }, ) + it('serializes an early relative server-function redirect without initializing the router', async () => { + const factory = vi.fn(makeRouter) + startMocks.routerFactory = factory + startMocks.serverFnResult = redirect({ + href: '/ignored', + headers: { Location: '/login', 'set-cookie': 'session=secret; HttpOnly' }, + }) + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(200) + expect(response.headers.get('Location')).toBe('/login') + expect(response.headers.get('set-cookie')).toBe('session=secret; HttpOnly') + const body = await response.json() + expect(body).toMatchObject({ href: '/login', isSerializedRedirect: true }) + expect(body).not.toHaveProperty('headers') + expect(factory).not.toHaveBeenCalled() + }) + + it.each([ + { href: 'https://example.com/login', protocols: [], status: 500 }, + { href: 'myapp:login', protocols: ['myapp:'], status: 307 }, + { href: 'myapp:login', protocols: [], status: 500 }, + ])( + 'uses router policy for an early redirect to $href with $protocols', + async ({ href, protocols, status }) => { + const factory = vi.fn(() => { + const router = makeRouter() + router.update({ protocolAllowlist: protocols }) + return router + }) + startMocks.routerFactory = factory + startMocks.requestMiddleware = [ + createMiddleware().server(() => + redirect({ + href: '/ignored', + headers: { Location: href }, + }), + ), + ] + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(status) + expect(response.headers.get('Location')).toBe( + status === 307 ? href : null, + ) + expect(factory).toHaveBeenCalledTimes(1) + }, + ) + it('resolves route-based early redirects through the router', async () => { const factory = vi.fn(() => makeRouterWithRouteWork({})) startMocks.routerFactory = factory @@ -248,7 +357,166 @@ describe('createStartHandler redirect safety', () => { expect(factory).toHaveBeenCalledTimes(1) }) - it.each([{ href: '/work' }, { to: '/work' }])( + it.each([ + { href: '/login', status: 307, factories: 0, location: '/login' }, + { href: '/\\evil.example', status: 500, factories: 1, location: null }, + { + href: 'http://localhost/login', + status: 307, + factories: 1, + location: '/login', + }, + ])( + 'validates a header-only early redirect to $href', + async ({ href, status, factories, location }) => { + const factory = vi.fn(makeRouter) + startMocks.routerFactory = factory + startMocks.requestMiddleware = [ + createMiddleware().server(() => + redirect({ + headers: { Location: href }, + throw: true, + }), + ), + ] + const handler = createStartHandler(() => new Response('unused')) + const response = await handler(new Request('http://localhost/'), {}) + expect(response.status).toBe(status) + expect(response.headers.get('Location')).toBe(location) + expect(factory).toHaveBeenCalledTimes(factories) + }, + ) + + it.each( + [ + '//evil.example', + '/\\evil.example', + '/\\\\evil.example', + '/\\/evil.example', + '\\/evil.example', + '\\\\evil.example', + ].flatMap((href) => [false, true].map((rpc) => ({ href, rpc }))), + )( + 'validates a resolved server function redirect to $href (RPC=$rpc)', + async ({ href, rpc }) => { + startMocks.router = makeRouter() + startMocks.serverFnResult = redirect({ href }) + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: rpc ? { 'x-tsr-serverFn': 'true' } : undefined, + }), + {}, + ) + + expect(response.status).toBe(500) + expect(response.headers.get('Location')).toBeNull() + }, + ) + + it('validates a structured redirect before returning it directly', async () => { + startMocks.router = makeRouter() + startMocks.requestMiddleware = [ + createMiddleware().server(() => redirect({ href: '/\\evil.example' })), + ] + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler(new Request('http://localhost/'), {}) + + expect(response.status).toBe(500) + expect(response.headers.get('Location')).toBeNull() + }) + + it('does not serialize structured redirect headers into the response body', async () => { + startMocks.router = makeRouter() + startMocks.serverFnResult = redirect({ + href: '/safe', + headers: { 'set-cookie': 'session=secret; HttpOnly' }, + }) + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(200) + expect(response.headers.get('set-cookie')).toBe('session=secret; HttpOnly') + const responseText = await response.text() + expect(responseText).not.toContain('session=secret') + expect(JSON.parse(responseText)).toEqual( + expect.objectContaining({ + href: '/safe', + statusCode: 307, + isSerializedRedirect: true, + }), + ) + }) + + it('preserves redirect headers and caller options across RPC then native form reuse', async () => { + const headers = { + Location: '/work', + 'set-cookie': 'session=secret; HttpOnly', + 'content-type': 'text/plain', + } + const options = { href: '/work', statusCode: 303, headers } + const result = redirect(options) + startMocks.serverFnResult = result + const handler = createStartHandler(() => new Response('unused')) + + const rpcResponse = await handler( + new Request('http://localhost/_serverFn/test', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + expect(rpcResponse.status).toBe(200) + expect(rpcResponse.headers.get('content-type')).toBe('application/json') + expect(await rpcResponse.json()).not.toHaveProperty('headers') + expect(options.headers).toBe(headers) + expect(result.headers.get('content-type')).toBe('text/plain') + + const formResponse = await handler( + new Request('http://localhost/_serverFn/test', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: 'name=test', + }), + {}, + ) + expect(formResponse.status).toBe(303) + expect(formResponse.headers.get('Location')).toBe('/work') + expect(formResponse.headers.get('set-cookie')).toBe( + 'session=secret; HttpOnly', + ) + expect(formResponse.headers.get('content-type')).toBe('text/plain') + expect(await formResponse.text()).toBe('') + }) + + it('does not serialize an ordinary redirect with a spoofed server function header', async () => { + startMocks.router = makeRouter() + startMocks.requestMiddleware = [ + createMiddleware().server(() => redirect({ href: '/safe' })), + ] + const handler = createStartHandler(() => new Response('unused')) + + const response = await handler( + new Request('http://localhost/', { + headers: { 'x-tsr-serverFn': 'true' }, + }), + {}, + ) + + expect(response.status).toBe(307) + expect(response.headers.get('Location')).toBe('/safe') + expect(response.headers.get('content-type')).toBeNull() + expect(await response.text()).toBe('') + }) + + it.each([{ href: '/work' }, { to: '/work' }, { hash: () => 'ignored' }])( 'preserves native form redirects for %j without the RPC header', async (target) => { startMocks.routerFactory = () => makeRouterWithRouteWork({}) diff --git a/packages/vue-router/src/link.tsx b/packages/vue-router/src/link.tsx index 4c2a04e4894..c54fc2db064 100644 --- a/packages/vue-router/src/link.tsx +++ b/packages/vue-router/src/link.tsx @@ -1,9 +1,8 @@ import * as Vue from 'vue' import { deepEqual, - exactPathTest, + getUrlScheme, hasKeys, - isAbsoluteUrl, isDangerousProtocol, preloadWarning, removeTrailingSlash, @@ -114,19 +113,13 @@ function useLinkPropsImpl( return Vue.computed(() => ({})) as unknown as LinkHTMLAttributes } - // Determine if the link is external or internal - const type = Vue.computed(() => { - const options = getOptions() - return isAbsoluteUrl(`${options.to}`) ? 'external' : 'internal' - }) - const ref = Vue.ref(null) // During SSR we render exactly once and do not need reactivity. // Avoid store subscriptions, effects and observers on the server. if (isServer ?? router.isServer) { const options = getOptions() - if (type.value === 'external') { + if (getUrlScheme(`${options.to}`)) { return Vue.ref( getExternalLinkProps(options, router, ref), ) as unknown as LinkHTMLAttributes @@ -135,12 +128,15 @@ function useLinkPropsImpl( const next = router.buildLocation(options as any) const href = getHref(options, router, next) - const isActive = getIsActive( - router.stores.location.get(), - next, - options.activeOptions, - router, - ) + const isActive = + !options.disabled && (href === undefined || !!getUrlScheme(href)) + ? false + : getIsActive( + router.stores.location.get(), + next, + options.activeOptions, + router, + ) const { resolvedActiveProps, @@ -164,24 +160,29 @@ function useLinkPropsImpl( ) as unknown as LinkHTMLAttributes } + // Determine if the link is external or internal. This is client-only so + // server renders do not allocate a computed wrapper for every link. + const isExternal = Vue.computed(() => !!getUrlScheme(`${getOptions().to}`)) + const currentLocation: Vue.Ref< ReturnType - > = - type.value === 'external' - ? Vue.shallowRef(router.stores.location.get()) - : (useStore(router.stores.location, (l) => l, { - equal: (prev, next) => prev.href === next.href, - }) as Vue.Ref>) + > = isExternal.value + ? Vue.shallowRef(router.stores.location.get()) + : (useStore(router.stores.location, (l) => l, { + equal: (prev, next) => prev.href === next.href, + }) as Vue.Ref>) // Links that start external skip useStore above. Subscribe if they later // become internal so active state follows subsequent location changes. - if (type.value === 'external') { + if (isExternal.value) { Vue.watchEffect((onCleanup) => { - if (type.value === 'external') { + if (isExternal.value) { return } const store = router.stores.location + // Catch up on navigations while this external link was unsubscribed. + currentLocation.value = store.get() const subscription = store.subscribe((location) => { if (currentLocation.value.href !== location.href) { currentLocation.value = location @@ -199,10 +200,17 @@ function useLinkPropsImpl( return router.buildLocation(opts) }) + const href = Vue.computed(() => { + const options = getOptions() + return getHref(options, router, next.value) + }) + const preload = Vue.computed(() => { const options = getOptions() if ( - type.value === 'external' || + isExternal.value || + (!options.disabled && + (href.value === undefined || !!getUrlScheme(href.value))) || options.reloadDocument || options.disabled ) { @@ -217,6 +225,13 @@ function useLinkPropsImpl( const isActive = Vue.computed(() => { const options = getOptions() + if ( + isExternal.value || + (!options.disabled && + (href.value === undefined || !!getUrlScheme(href.value))) + ) { + return false + } return getIsActive( currentLocation.value, next.value, @@ -302,11 +317,15 @@ function useLinkPropsImpl( // The click handler const handleClick = (e: PointerEvent): void => { - if (type.value === 'external') { + const options = getOptions() + if ( + isExternal.value || + (!options.disabled && + (href.value === undefined || !!getUrlScheme(href.value))) + ) { return } - const options = getOptions() // Check actual element's target attribute as fallback const elementTarget = ( e.currentTarget as HTMLAnchorElement | SVGAElement @@ -371,11 +390,6 @@ function useLinkPropsImpl( return resolveStyleProps(options, isActive.value) }) - const href = Vue.computed(() => { - const options = getOptions() - return getHref(options, router, next.value) - }) - // Create static event handlers that don't change between renders const staticEventHandlers: LinkEventHandlers = { onClick: composeEventHandlers(() => getOptions().onClick, handleClick), @@ -407,7 +421,7 @@ function useLinkPropsImpl( // Using Vue.computed ensures props are calculated at render time, not after const computedProps = Vue.computed(() => { const options = getOptions() - if (type.value === 'external') { + if (isExternal.value) { return getExternalLinkProps(options, router, ref, staticEventHandlers) } @@ -503,12 +517,12 @@ function combineResultProps({ ref?: Vue.VNodeRef | undefined staticEventHandlers?: LinkEventHandlers }) { + const disabled = options.disabled || href === undefined const result: Record = { ...getPropsSafeToSpread(options), ref, ...staticEventHandlers, - href, - disabled: options._asChild ? !!options.disabled : undefined, + disabled: options._asChild ? disabled : undefined, target: options.target, } @@ -520,7 +534,7 @@ function combineResultProps({ result.class = resolvedClassName } - if (options.disabled) { + if (disabled) { result.role = 'link' result['aria-disabled'] = true } @@ -541,6 +555,8 @@ function combineResultProps({ result[key] = resolvedInactiveProps[key] } } + + result.href = href return result } @@ -554,6 +570,7 @@ function getExternalLinkProps( options.to as string, router.protocolAllowlist, ) + const disabled = options.disabled || dangerous if (process.env.NODE_ENV !== 'production' && dangerous) { console.warn(`Blocked Link with dangerous protocol: ${options.to}`) } @@ -562,9 +579,9 @@ function getExternalLinkProps( const result: Record = { ...getPropsSafeToSpread(options), ref, - href: dangerous || options.disabled ? undefined : options.to, + href: disabled ? undefined : options.to, target: options.target, - disabled: options._asChild ? !!options.disabled : undefined, + disabled: options._asChild ? disabled : undefined, style: options.style, class: options.class, onClick: staticEventHandlers?.onClick ?? options.onClick, @@ -580,7 +597,7 @@ function getExternalLinkProps( staticEventHandlers?.onTouchstart ?? eventHandlers.onTouchstart, } - if (options.disabled) { + if (disabled) { result.role = 'link' result['aria-disabled'] = true } @@ -673,26 +690,20 @@ function getIsActive( activeOptions: LinkOptions['activeOptions'], router: AnyRouter, ) { - if (activeOptions?.exact) { - const testExact = exactPathTest( - loc.pathname, - nextLoc.pathname, - router.basepath, - ) - if (!testExact) { - return false - } - } else { - const currentPath = removeTrailingSlash(loc.pathname, router.basepath) - const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath) - - const pathIsFuzzyEqual = - currentPath.startsWith(nextPath) && - (currentPath.length === nextPath.length || - currentPath[nextPath.length] === '/') - if (!pathIsFuzzyEqual) { - return false - } + const currentPath = removeTrailingSlash(loc.pathname, router.basepath) + const nextPath = removeTrailingSlash(nextLoc.pathname, router.basepath) + + // Both modes compare normalized paths; fuzzy matches need a segment boundary. + if ( + activeOptions?.exact + ? currentPath !== nextPath + : !( + currentPath.startsWith(nextPath) && + (currentPath.length === nextPath.length || + currentPath[nextPath.length] === '/') + ) + ) { + return false } if (activeOptions?.includeSearch ?? true) { @@ -728,10 +739,20 @@ function getHref( const publicHref = location?.publicHref if (!publicHref) return undefined - const external = location?.external - if (external) return publicHref + const href = location?.external + ? publicHref + : router.history.createHref(publicHref) || '/' + if ( + (location?.external || href !== publicHref) && + isDangerousProtocol(href, router.protocolAllowlist) + ) { + if (process.env.NODE_ENV !== 'production') { + console.warn(`Blocked Link with dangerous protocol: ${href}`) + } + return undefined + } - return router.history.createHref(publicHref) || '/' + return href } // Type definitions diff --git a/packages/vue-router/tests/link-href-safety.test.tsx b/packages/vue-router/tests/link-href-safety.test.tsx index eefdb93c1db..a5518e83c02 100644 --- a/packages/vue-router/tests/link-href-safety.test.tsx +++ b/packages/vue-router/tests/link-href-safety.test.tsx @@ -23,7 +23,19 @@ const cases: Array< blocked: boolean, active: boolean, ] -> = [['to', 'myapp:open', false, false]] +> = [ + ['history', '//evil.example/path', true, false], + ['history', '/\\evil.example/path', true, false], + ['history', '\\/evil.example/path', true, false], + ['history', '\x01 \t//evil.example/path', true, false], + ['history', 'javascript:blocked()', true, false], + ['history', '/formatted', false, true], + ['rewrite', 'javascript:blocked()', true, false], + ['rewrite', 'https://other.example/', false, false], + ['rewrite', '/safe', false, true], + ['to', 'myapp:open', false, false], + ['to', 'mailto:person@example.com', true, false], +] for (const isServer of [true, false]) { test.each(cases)( diff --git a/packages/vue-router/tests/link-location-transition.test.tsx b/packages/vue-router/tests/link-location-transition.test.tsx new file mode 100644 index 00000000000..69093faed95 --- /dev/null +++ b/packages/vue-router/tests/link-location-transition.test.tsx @@ -0,0 +1,87 @@ +import * as Vue from 'vue' +import { cleanup, render, waitFor } from '@testing-library/vue' +import { afterEach, expect, test } from 'vitest' +import { + Link, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from '../src' + +afterEach(cleanup) + +test('external links use the current location when becoming internal again', async () => { + const to = Vue.ref('https://other.example/') + const root = createRootRoute({ + component: Vue.defineComponent({ + setup: () => () => ( + + Target + + ), + }), + }) + const item = createRoute({ + getParentRoute: () => root, + path: '/items/$id', + validateSearch: (search: Record) => ({ + keep: String(search.keep), + }), + }) + const router = createRouter({ + routeTree: root.addChildren([item]), + history: createMemoryHistory({ + initialEntries: ['/items/one?keep=first#first'], + }), + }) + await router.load() + const view = render() + const anchor = await view.findByText('Target') + for (const [id, keep] of [ + ['two', 'second'], + ['three', 'third'], + ] as const) { + to.value = 'https://other.example/' + await Vue.nextTick() + await router.navigate({ + to: '/items/$id', + params: { id }, + search: { keep }, + hash: keep, + }) + expect(anchor).toHaveAttribute('href', 'https://other.example/') + to.value = '.' + await waitFor(() => { + expect(anchor).toHaveAttribute( + 'href', + `/items/${id}?keep=${keep}#${keep}`, + ) + expect(anchor).toHaveAttribute('aria-current', 'page') + }) + } + await router.navigate({ + to: '/items/$id', + params: { id: 'four' }, + search: { keep: 'fourth' }, + hash: 'fourth', + }) + await waitFor(() => + expect(anchor).toHaveAttribute('href', '/items/four?keep=fourth#fourth'), + ) + to.value = 'https://other.example/' + await Vue.nextTick() + await router.navigate({ to: '.', search: true, hash: 'latest' }) + to.value = '/items/three' + await waitFor(() => { + expect(anchor).toHaveAttribute('href', '/items/three?keep=fourth#latest') + expect(anchor).not.toHaveAttribute('aria-current') + }) +}) diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index d456285d83d..31ea10f05d4 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -70,6 +70,170 @@ afterEach(() => { const WAIT_TIME = 300 describe('Link', () => { + test('keeps protocol-relative Link paths on the router origin', async () => { + const inputs = [ + '//evil.example', + '/\\evil.example', + '\\/evil.example', + ' \t/\\evil.example', + ] + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + <> + {inputs.map((to, index) => ( + + ))} + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + + for (let index = 0; index < inputs.length; index++) { + const link = await screen.findByTestId(`unsafe-link-${index}`) + const href = link.getAttribute('href') + expect(href).not.toBeNull() + expect(new URL(href!, window.location.href).origin).toBe( + window.location.origin, + ) + } + }) + + test('blocks a dangerous final href produced by an output rewrite', async () => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + rewrite: { + output: ({ url }) => + url.pathname === '/safe' ? new URL('javascript:alert(1)') : url, + }, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + const link = await screen.findByTestId('rewritten-link') + + expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('role', 'link') + expect(link).toHaveAttribute('aria-disabled', 'true') + expect( + link.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ).toBe(true) + }) + + test('blocks a dangerous final href produced by custom history', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'javascript:alert(1)', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + render() + const link = await screen.findByTestId('custom-history-link') + + expect(link).not.toHaveAttribute('href') + expect( + link.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ).toBe(true) + customHistory.destroy() + }) + + test.each([ + { to: '/', disabled: false }, + { to: '/safe', disabled: false }, + { to: '/', disabled: true }, + { to: '/safe', disabled: true }, + ])( + 'keeps the validated href when applying state props: %j', + async (props) => { + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( + + ), + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history, + }) + + render() + const link = await screen.findByTestId('state-props-link') + + if (props.disabled) { + expect(link).not.toHaveAttribute('href') + } else { + expect(link).toHaveAttribute('href', props.to) + } + }, + ) + + test('does not intercept an external href produced by custom history', async () => { + const customHistory = createBrowserHistory({ + createHref: () => 'https://other.example/path', + }) + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => , + }) + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute]), + history: customHistory, + }) + render() + const link = await screen.findByTestId('custom-history-link') + + expect(link).toHaveAttribute('href', 'https://other.example/path') + expect( + link.dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true }), + ), + ).toBe(true) + customHistory.destroy() + }) + // rerender doesn't exist in solid // test('when using renderHook it returns a hook with same content to prove rerender works', async () => { @@ -449,7 +613,6 @@ describe('Link', () => { routeTree: rootRoute.addChildren([postsRoute, aboutRoute]), history, }) - const navigateSpy = vi.spyOn(router, 'navigate') render() @@ -481,10 +644,13 @@ describe('Link', () => { to.value = 'javascript:alert(1)' await Vue.nextTick() expect(link).not.toHaveAttribute('href') + expect(link).toHaveAttribute('role', 'link') + expect(link).toHaveAttribute('aria-disabled', 'true') to.value = 'https://example.com/three' await Vue.nextTick() expect(link).toHaveAttribute('href', 'https://example.com/three') + expect(link).not.toHaveAttribute('aria-disabled') to.value = '/about' target.value = undefined @@ -510,7 +676,7 @@ describe('Link', () => { expect(link).not.toHaveClass('decorated') await fireEvent.click(link) - expect(navigateSpy).toHaveBeenCalledOnce() + await waitFor(() => expect(window.location.pathname).toBe('/about')) }) test('tracks router location after an external link becomes internal', async () => { From d52467ffdead8ce1235d469ee683efa9c84c115c Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 9 Sep 2026 21:10:37 +0200 Subject: [PATCH 2/3] fix: preserve unload blockers after document navigation --- .../src/routes/history-blocking.tsx | 37 ++- .../tests/history-blocking.spec.ts | 67 ++++++ packages/history/src/index.ts | 17 +- packages/router-core/src/router.ts | 2 +- .../document-navigation-blocking.test.ts | 218 ++++++++++++++++++ 5 files changed, 335 insertions(+), 6 deletions(-) create mode 100644 packages/router-core/tests/document-navigation-blocking.test.ts diff --git a/e2e/react-router/basic-file-based/src/routes/history-blocking.tsx b/e2e/react-router/basic-file-based/src/routes/history-blocking.tsx index 4cd96efa395..019893a6280 100644 --- a/e2e/react-router/basic-file-based/src/routes/history-blocking.tsx +++ b/e2e/react-router/basic-file-based/src/routes/history-blocking.tsx @@ -18,7 +18,9 @@ function HistoryBlocking() { const { step } = Route.useSearch() const [draft, setDraft] = React.useState('') const [ignoreBlocker, setIgnoreBlocker] = React.useState(false) - const { status, reset } = useBlocker({ + const [documentHref, setDocumentHref] = React.useState('/') + const [navigationError, setNavigationError] = React.useState('') + const { status, reset, proceed } = useBlocker({ shouldBlockFn: () => draft.length > 0, enableBeforeUnload: draft.length > 0, withResolver: true, @@ -45,7 +47,38 @@ function HistoryBlocking() {

{draft ? 'Unsaved changes' : 'No changes'}

Blocker status: {status}

- {status === 'blocked' && } + {status === 'blocked' && ( + <> + + + + )} + + {[false, true].map((replace) => ( + + ))} + {navigationError &&

{navigationError}

} Add history entry diff --git a/e2e/react-router/basic-file-based/tests/history-blocking.spec.ts b/e2e/react-router/basic-file-based/tests/history-blocking.spec.ts index 135594200cd..244456a2abb 100644 --- a/e2e/react-router/basic-file-based/tests/history-blocking.spec.ts +++ b/e2e/react-router/basic-file-based/tests/history-blocking.spec.ts @@ -184,3 +184,70 @@ for (const ignoreBlocker of [false, true]) { } }) } + +for (const action of ['Navigate document', 'Replace document']) { + test(`${action}: invalid URLs preserve the next unload warning`, async ({ + page, + }) => { + await page.goto('/history-blocking') + await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft') + await page.getByLabel('Ignore blockers').check() + await page.getByLabel('Document destination').fill('https://[') + const dialogs = dismissUnloadDialogs(page) + + await page.getByRole('button', { name: action, exact: true }).click() + + await expect(page.getByRole('status')).toBeVisible() + expect(dialogs).toEqual([]) + await page.getByRole('link', { name: 'Leave document' }).click() + + await expect.poll(() => dialogs).toEqual(['beforeunload']) + await expect(page.getByLabel('Draft', { exact: true })).toHaveValue( + 'Unsaved draft', + ) + }) + + test(`${action}: the current fragment preserves the next unload warning`, async ({ + page, + }) => { + await page.goto('/history-blocking?step=0#same') + await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft') + await page.getByLabel('Ignore blockers').check() + await page.getByLabel('Document destination').fill('#same') + const dialogs = dismissUnloadDialogs(page) + + await page.getByRole('button', { name: action, exact: true }).click() + + await expect(page).toHaveURL('/history-blocking?step=0#same') + expect(dialogs).toEqual([]) + await page.getByRole('link', { name: 'Leave document' }).click() + + await expect.poll(() => dialogs).toEqual(['beforeunload']) + await expect(page.getByLabel('Draft', { exact: true })).toHaveValue( + 'Unsaved draft', + ) + }) + + for (const ignoreBlocker of [false, true]) { + test(`${action}: ${ignoreBlocker ? 'skipped' : 'accepted'} blockers need no native confirmation`, async ({ + page, + }) => { + await page.goto('/history-blocking') + await page.getByLabel('Draft', { exact: true }).fill('Unsaved draft') + if (ignoreBlocker) { + await page.getByLabel('Ignore blockers').check() + } + const dialogs = dismissUnloadDialogs(page) + + await page.getByRole('button', { name: action, exact: true }).click() + if (!ignoreBlocker) { + await page + .getByRole('button', { name: 'Continue navigation', exact: true }) + .click() + } + + await expect(page).toHaveURL('/') + expect(dialogs).toEqual([]) + }) + } +} diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index 513e694af48..da81a31b3e8 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -38,7 +38,7 @@ export interface RouterHistory { notify: (action: SubscriberHistoryAction) => void _getBlockers: () => Array _ignoreSubscribers?: boolean - _ignoreNextBeforeUnload?: () => void + _ignoreNextBeforeUnload?: (href: string) => void } export interface HistoryLocation extends ParsedPath { @@ -571,8 +571,19 @@ export function createBrowserHistory(opts?: { notifyOnIndexChange: false, }) - history._ignoreNextBeforeUnload = () => { - ignoreNextBeforeUnload = true + history._ignoreNextBeforeUnload = (href) => { + ignoreNextBeforeUnload = false + try { + const url = new URL(href, win.document.baseURI) + // External handlers and same-document fragments may emit neither + // beforeunload nor popstate, leaving an exemption for a later departure. + ignoreNextBeforeUnload = + /^https?:/.test(url.href) && + (!url.href.includes('#') || + url.href.split('#')[0] !== win.location.href.split('#')[0]) + } catch { + // Invalid URLs cannot unload the document. + } } win.addEventListener(beforeUnloadEvent, onBeforeUnload, { capture: true }) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 9de2af86152..4ae7554d2cd 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2711,7 +2711,7 @@ async function documentNavigation( // All blockers have allowed this navigation (or were explicitly skipped). // Avoid asking for approval again in the native beforeunload handler. - router.history._ignoreNextBeforeUnload?.() + router.history._ignoreNextBeforeUnload?.(href) if (replace) { window.location.replace(href) } else { diff --git a/packages/router-core/tests/document-navigation-blocking.test.ts b/packages/router-core/tests/document-navigation-blocking.test.ts new file mode 100644 index 00000000000..8dfae6d265c --- /dev/null +++ b/packages/router-core/tests/document-navigation-blocking.test.ts @@ -0,0 +1,218 @@ +import { URL as NodeURL } from 'node:url' +import { createBrowserHistory, createHashHistory } from '@tanstack/history' +import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' +import { BaseRootRoute } from '../src' +import { createTestRouter } from './routerTestUtils' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function setupDocumentNavigation({ + initialHref = '/current?query=1#same', + baseHref, + hashHistory = false, +}: { + initialHref?: string + baseHref?: string + hashHistory?: boolean +} = {}) { + const browserWindow = window + const originalHref = browserWindow.location.href + const originalState = browserWindow.history.state + browserWindow.history.replaceState(null, '', initialHref) + const base = baseHref ? document.createElement('base') : undefined + if (base) { + base.href = baseHref! + document.head.prepend(base) + } + const history = (hashHistory ? createHashHistory : createBrowserHistory)({ + window: browserWindow, + }) + onTestFinished(() => { + history.destroy() + base?.remove() + browserWindow.history.replaceState(originalState, '', originalHref) + }) + const blockerFn = vi.fn(() => false) + history.block({ blockerFn, enableBeforeUnload: true }) + + // Preserve native URL parse failures, but emit no navigation events. The + // first beforeunload we dispatch then belongs to a later attempt to leave. + const assign = vi.fn((href: string) => { + new NodeURL(href, browserWindow.document.baseURI) + }) + const replace = vi.fn((href: string) => assign(href)) + vi.stubGlobal('window', { + location: { + get href() { + return browserWindow.location.href + }, + set href(href: string) { + assign(href) + }, + replace, + }, + }) + const router = createTestRouter({ + routeTree: new BaseRootRoute(), + history, + origin: browserWindow.location.origin, + protocolAllowlist: [ + 'http:', + 'https:', + 'mailto:', + 'tel:', + 'custom:', + 'httpx:', + ], + isServer: false, + }) + const dispatchBeforeUnload = () => { + const event = new browserWindow.Event('beforeunload', { + cancelable: true, + }) + browserWindow.dispatchEvent(event) + return event.defaultPrevented + } + return { + router, + browserWindow, + assign, + replace, + blockerFn, + dispatchBeforeUnload, + } +} + +describe.each([false, true])( + 'document navigation with replace=%j', + (replace) => { + it.each(['https://[', 'https://example.com:99999/', ' \tHt\nTpS://['])( + 'preserves the next unload warning when %j throws', + async (href) => { + const { router, assign, dispatchBeforeUnload } = + setupDocumentNavigation() + + await expect(router.navigate({ href, replace })).rejects.toThrow() + + expect(assign).toHaveBeenCalledWith(href) + expect(dispatchBeforeUnload()).toBe(true) + }, + ) + + it.each([ + 'mailto:user@example.com', + 'tel:+15555550100', + 'custom:target', + 'httpx://example.com/', + ' \tMa\niLtO:user@example.com', + '#same', + '#changed', + '#', + '/current?query=1#same', + '/folder/../current?query=1#same', + '/current?query=1#mailto:user@example.com', + ])('preserves the next unload warning after %j', async (href) => { + const { router, assign, blockerFn, dispatchBeforeUnload } = + setupDocumentNavigation() + + await router.navigate({ href, reloadDocument: true, replace }) + + expect(assign).toHaveBeenCalledWith(href) + expect(blockerFn).toHaveBeenCalledOnce() + expect(dispatchBeforeUnload()).toBe(true) + }) + + it('preserves the next unload warning after an unchanged empty fragment', async () => { + const { router, dispatchBeforeUnload } = setupDocumentNavigation({ + initialHref: '/current?query=1#', + }) + + await router.navigate({ href: '#', reloadDocument: true, replace }) + + expect(dispatchBeforeUnload()).toBe(true) + }) + + it('compares absolute fragment destinations with the visible hash-history URL', async () => { + const { router, browserWindow, dispatchBeforeUnload } = + setupDocumentNavigation({ + initialHref: '/shell?query=1#/route', + hashHistory: true, + }) + + await router.navigate({ href: browserWindow.location.href, replace }) + + expect(dispatchBeforeUnload()).toBe(true) + }) + + it.each([ + 'https://other.example/next#section', + 'http://other.example/next', + ' \tHt\nTpS://other.example/next', + '/next#same', + '/current?query=2#same', + '/current?query=1', + ])('exempts only the first unload for %j', async (href) => { + const { + router, + assign, + replace: replaceLocation, + dispatchBeforeUnload, + } = setupDocumentNavigation() + + await router.navigate({ href, reloadDocument: true, replace }) + + expect(assign).toHaveBeenCalledWith(href) + expect(replaceLocation).toHaveBeenCalledTimes(replace ? 1 : 0) + expect(dispatchBeforeUnload()).toBe(false) + expect(dispatchBeforeUnload()).toBe(true) + }) + + it('resolves relative fragments against the document base', async () => { + const { router, dispatchBeforeUnload } = setupDocumentNavigation({ + baseHref: '/different-document', + }) + + await router.navigate({ href: '#same', reloadDocument: true, replace }) + + expect(dispatchBeforeUnload()).toBe(false) + expect(dispatchBeforeUnload()).toBe(true) + }) + + it('preserves the next unload warning when an external handler skips blockers', async () => { + const { router, blockerFn, dispatchBeforeUnload } = + setupDocumentNavigation() + + await router.navigate({ + href: 'mailto:user@example.com', + ignoreBlocker: true, + replace, + }) + + expect(blockerFn).not.toHaveBeenCalled() + expect(dispatchBeforeUnload()).toBe(true) + }) + + it.each(['https://[', 'mailto:user@example.com', '#same'])( + 'clears a previously prepared exemption before attempting %j', + async (href) => { + const { router, dispatchBeforeUnload } = setupDocumentNavigation() + await router.navigate({ href: 'https://other.example/next' }) + + const navigation = router.navigate({ + href, + reloadDocument: true, + replace, + }) + if (href === 'https://[') { + await expect(navigation).rejects.toThrow() + } else { + await navigation + } + + expect(dispatchBeforeUnload()).toBe(true) + }, + ) + }, +) From 952788f1a9e4b96c30a459c6931cf404bc4dadd6 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Wed, 9 Sep 2026 22:05:38 +0200 Subject: [PATCH 3/3] perf: reduce document navigation guard size --- packages/history/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/history/src/index.ts b/packages/history/src/index.ts index da81a31b3e8..b0e006c80da 100644 --- a/packages/history/src/index.ts +++ b/packages/history/src/index.ts @@ -574,13 +574,13 @@ export function createBrowserHistory(opts?: { history._ignoreNextBeforeUnload = (href) => { ignoreNextBeforeUnload = false try { - const url = new URL(href, win.document.baseURI) + href = new URL(href, win.document.baseURI).href // External handlers and same-document fragments may emit neither // beforeunload nor popstate, leaving an exemption for a later departure. ignoreNextBeforeUnload = - /^https?:/.test(url.href) && - (!url.href.includes('#') || - url.href.split('#')[0] !== win.location.href.split('#')[0]) + /^https?:/.test(href) && + (!href.includes('#') || + href.split('#')[0] !== win.location.href.split('#')[0]) } catch { // Invalid URLs cannot unload the document. }