diff --git a/packages/bridge/src/runtime/composables/router.ts b/packages/bridge/src/runtime/composables/router.ts index 0a7d452cf..a385bd3a5 100644 --- a/packages/bridge/src/runtime/composables/router.ts +++ b/packages/bridge/src/runtime/composables/router.ts @@ -3,8 +3,9 @@ import type VueRouter from 'vue-router' import type { Location, RawLocation, Route, NavigationFailure } from 'vue-router' import { sanitizeStatusCode } from 'h3' import { useRouter as useVueRouter, useRoute as useVueRoute } from 'vue-router/composables' -import { hasProtocol, joinURL, parseURL, withQuery } from 'ufo' +import { joinURL, withQuery } from 'ufo' import { useNuxtApp, callWithNuxt, useRuntimeConfig } from '../nuxt' +import { getScriptProtocol, isAbsoluteHref } from '../utils/url' import { createError, showError } from './error' import type { NuxtError } from './error' @@ -116,6 +117,11 @@ export const navigateTo = (to: RawLocation | undefined | null, options?: Navigat // Early open handler if (process.client && options?.open) { + const protocol = getScriptProtocol(toPath) + if (protocol !== null) { + throw new Error(`Cannot navigate to a URL with '${protocol}' protocol.`) + } + const { target = '_blank', windowFeatures = {} } = options.open const features = Object.entries(windowFeatures) @@ -127,12 +133,16 @@ export const navigateTo = (to: RawLocation | undefined | null, options?: Navigat return Promise.resolve() } - const isExternal = options?.external || hasProtocol(toPath, { acceptRelative: true }) - if (isExternal && !options?.external) { - throw new Error('Navigating to external URL is not allowed by default. Use `nagivateTo (url, { external: true })`.') - } - if (isExternal && parseURL(toPath).protocol === 'script:') { - throw new Error('Cannot navigate to an URL with script protocol.') + const isExternalHost = isAbsoluteHref(toPath) + const isExternal = options?.external || isExternalHost + if (isExternal) { + if (!options?.external) { + throw new Error('Navigating to external URL is not allowed by default. Use `nagivateTo (url, { external: true })`.') + } + const protocol = getScriptProtocol(toPath) + if (protocol !== null) { + throw new Error(`Cannot navigate to a URL with '${protocol}' protocol.`) + } } const inMiddleware = isProcessingMiddleware() @@ -151,8 +161,6 @@ export const navigateTo = (to: RawLocation | undefined | null, options?: Navigat const fullPath = typeof to === 'string' || isExternal ? toPath : router.resolve(to).resolved.fullPath || '/' const location = isExternal ? toPath : joinURL(useRuntimeConfig().app.baseURL, fullPath) - const isExternalHost = hasProtocol(toPath, { acceptRelative: true }) - const redirect = async function (response: any) { // @ts-expect-error await nuxtApp.callHook('app:redirected') diff --git a/packages/bridge/src/runtime/utils/url.ts b/packages/bridge/src/runtime/utils/url.ts new file mode 100644 index 000000000..c53b10df5 --- /dev/null +++ b/packages/bridge/src/runtime/utils/url.ts @@ -0,0 +1,50 @@ +import { isScriptProtocol } from 'ufo' + +const PROBE_BASE_A = 'https://a.invalid/probe/' +const PROBE_BASE_B = 'https://b.invalid/probe/' + +function parseAnchorURL (value: string, base: string = PROBE_BASE_A): URL | null { + try { + return new URL(value, base) + } catch { + return null + } +} + +// a relative value inherits whichever base it resolves against; an absolute one ignores both +function resolvesWithoutBase (value: string): boolean { + const resolved = parseAnchorURL(value) + return !!resolved && resolved.href === parseAnchorURL(value, PROBE_BASE_B)?.href +} + +/** Whether a URL string is a path rooted at the current origin, so carries no protocol. */ +export function isRootedPath (value: string): boolean { + if (value[0] !== '/' || value[1] === '/' || value[1] === '\\') { + return false + } + // the URL parser drops ASCII tab and newline, which can join the leading `/` to a host + return (value[1] !== '\t' && value[1] !== '\n' && value[1] !== '\r') || !resolvesWithoutBase(value) +} + +/** Whether a URL string carries its own protocol or authority. */ +export function isAbsoluteHref (value: string): boolean { + return !!value && !isRootedPath(value) && resolvesWithoutBase(value) +} + +/** The script-capable protocol a URL string would navigate to, or `null` when it is safe. */ +export function getScriptProtocol (value: string): string | null { + if (!value || isRootedPath(value)) { + return null + } + // browser URL parsers ignore whitespace and control characters around a scheme + // eslint-disable-next-line no-control-regex + let resolved = parseAnchorURL(value.replace(/[\u0000-\u001F\u007F\s]+/g, '')) + // Chromium resolves `view-source:` transparently to the inner URL + while (resolved?.protocol === 'view-source:') { + resolved = parseAnchorURL(resolved.pathname) + } + if (!resolved) { + return '' + } + return isScriptProtocol(resolved.protocol) ? resolved.protocol : null +} diff --git a/packages/bridge/test/composables.nuxt.test.ts b/packages/bridge/test/composables.nuxt.test.ts index faef6b9de..646c2d376 100644 --- a/packages/bridge/test/composables.nuxt.test.ts +++ b/packages/bridge/test/composables.nuxt.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom -import { describe, it, expect, beforeAll, afterAll } from 'vitest' -import { useCookie } from '../src/runtime' +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import { navigateTo, useCookie } from '../src/runtime' describe('useCookie', () => { beforeAll(() => { @@ -23,3 +23,58 @@ describe('useCookie', () => { expect(document.cookie).toContain('cookie-readonly=foo') }) }) + +describe('navigateTo', () => { + beforeAll(() => { + process.client = true + }) + + afterAll(() => { + process.client = false + }) + + it('should disallow navigation to external URLs by default', () => { + expect(() => navigateTo('https://test.com')).toThrow('Navigating to external URL is not allowed by default.') + expect(() => navigateTo('/\t/test.com')).toThrow('Navigating to external URL is not allowed by default.') + expect(() => navigateTo('/\n\\test.com')).toThrow('Navigating to external URL is not allowed by default.') + }) + + it('should disallow navigation to data/script URLs', () => { + const urls = [ + ['javascript:alert("hi")', 'javascript'], + ['data:alert("hi")', 'data'], + ['\0data:alert("hi")', 'data'] + ] + for (const [url, protocol] of urls) { + expect(() => navigateTo(url, { external: true })).toThrow(`Cannot navigate to a URL with '${protocol}:' protocol.`) + } + }) + + it('should disallow opening data/script URLs via the `open` option', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + const urls = [ + ['javascript:alert("hi")', 'javascript'], + ['data:alert("hi")', 'data'], + ['vbscript:alert("hi")', 'vbscript'], + ['\0javascript:alert("hi")', 'javascript'] + ] + for (const [url, protocol] of urls) { + expect(() => navigateTo(url, { open: { target: '_blank' } })).toThrow(`Cannot navigate to a URL with '${protocol}:' protocol.`) + } + expect(open).not.toHaveBeenCalled() + } finally { + open.mockRestore() + } + }) + + it('should still allow opening safe URLs via the `open` option', () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null) + try { + expect(() => navigateTo('https://example.com', { open: { target: '_blank' } })).not.toThrow() + expect(open).toHaveBeenCalledWith('https://example.com', '_blank', '') + } finally { + open.mockRestore() + } + }) +}) diff --git a/packages/bridge/test/url.test.ts b/packages/bridge/test/url.test.ts new file mode 100644 index 000000000..4f3327a19 --- /dev/null +++ b/packages/bridge/test/url.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { getScriptProtocol, isAbsoluteHref, isRootedPath } from '../src/runtime/utils/url' + +describe('isRootedPath', () => { + it.each([ + '/', + '/foo/bar?a=1#b', + '/\tfoo', + '/\n', + '/\t/', + '/foo\t//bar' + ])('should treat %j as rooted', (value) => { + expect(isRootedPath(value)).toBe(true) + }) + + it.each([ + '', + 'foo', + '//evil.com', + '/\\evil.com', + '/\t/evil.com', + '/\n/evil.com', + '/\r/evil.com', + '/\t\\evil.com', + '/\t\r\n/evil.com' + ])('should not treat %j as rooted', (value) => { + expect(isRootedPath(value)).toBe(false) + }) +}) + +describe('isAbsoluteHref', () => { + it.each([ + '', + '/', + '/foo/bar?a=1#b', + '/\tfoo', + '/foo:bar', + '#hash', + 'foo', + './foo', + '../foo', + 'foo bar:baz' + ])('should treat %j as relative', (value) => { + expect(isAbsoluteHref(value)).toBe(false) + }) + + it.each([ + 'https://example.com', + 'HTTP://example.com', + 'mailto:a@b.c', + '//evil.com', + '\\\\evil.com', + '/\\evil.com', + '/\t/evil.com', + '/\n\\evil.com', + 'javascript:alert(1)', + '\u0001javascript:alert(1)', + 'java\tscript:alert(1)', + 'c:\\windows' + ])('should treat %j as absolute', (value) => { + expect(isAbsoluteHref(value)).toBe(true) + }) +}) + +describe('getScriptProtocol', () => { + it.each([ + 'javascript:alert(1)', + 'JaVaScRiPt:alert(1)', + '\u0001javascript:alert(1)', + '\u0000javascript:alert(1)', + '\tjavascript:alert(1)', + ' javascript:alert(1)', + 'java\tscript:alert(1)', + 'data:text/html,', + 'vbscript:alert(1)', + 'blob:https://example.com/x', + 'view-source:javascript:alert(1)', + '\u0001view-source:\u0001javascript:alert(1)' + ])('should reject %j', (value) => { + expect(getScriptProtocol(value)).not.toBeNull() + }) + + it.each([ + '', + '/foo/bar?a=1#b', + '#hash', + '/view-source:javascript:alert(1)', + '%01javascript:alert(1)', + 'https://example.com', + 'mailto:a@b.c', + 'tel:123', + 'about:blank' + ])('should allow %j', (value) => { + expect(getScriptProtocol(value)).toBeNull() + }) +})