From c6151970ee29b15e83f7e0e3e5e8dbe0ab8cb85c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Fri, 26 Jun 2026 11:42:20 +0200 Subject: [PATCH 1/9] forbid external redirects by default; approach 1 --- packages/kit/src/exports/index.js | 11 +- packages/kit/src/exports/index.spec.js | 63 ++++++++++ packages/kit/src/utils/url.js | 117 ++++++++++++++++++ packages/kit/src/utils/url.spec.js | 55 +++++++- .../src/routes/redirect-encoded/+page.js | 6 +- .../src/routes/redirect-malicious/+page.js | 2 +- .../routes/redirect-server/+page.server.js | 2 +- .../basics/src/routes/redirect/+page.js | 2 +- .../basics/src/routes/spa-shell/+page.js | 2 +- 9 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index ff1a02d834c5..ff6805ea488a 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -11,6 +11,7 @@ import { strip_resolution_suffix } from '../runtime/pathname.js'; import { text_encoder } from '../runtime/utils.js'; +import { validate_redirect_location } from '../utils/url.js'; export { VERSION } from '../version.js'; @@ -92,19 +93,23 @@ export function isHttpError(e, status) { * * @param {300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number)} status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). Must be in the range 300-308. * @param {string | URL} location The location to redirect to. + * @param {{ external?: boolean | string[] }} [options] To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted URLs. * @throws {import('./public.js').Redirect} This error instructs SvelteKit to redirect to the specified location. - * @throws {Error} If the provided status is invalid or the location cannot be used as a header value. + * @throws {Error} If the provided status is invalid, the location cannot be used as a header value, or the location is an external URL without permission. * @return {never} */ -export function redirect(status, location) { +export function redirect(status, location, options) { if ((!BROWSER || DEV) && (isNaN(status) || status < 300 || status > 308)) { throw new Error('Invalid status code'); } + const href = location.toString(); + validate_redirect_location(href, options); + throw new Redirect( // @ts-ignore status, - location.toString() + href ); } diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js index faac39323672..56ccda654ca5 100644 --- a/packages/kit/src/exports/index.spec.js +++ b/packages/kit/src/exports/index.spec.js @@ -67,6 +67,69 @@ describe('redirect', () => { } }); + it('throws Redirect for external locations with external: true', () => { + try { + redirect(307, 'https://google.de', { external: true }); + assert.fail('Expected redirect to throw'); + } catch (e) { + if (!isRedirect(e)) { + assert.fail('Expected a Redirect error'); + } + + assert.equal(e.status, 307); + assert.equal(e.location, 'https://google.de'); + } + }); + + it('throws Redirect for allowlisted external locations', () => { + try { + redirect(307, 'https://google.de/search', { external: ['https://google.de'] }); + assert.fail('Expected redirect to throw'); + } catch (e) { + if (!isRedirect(e)) { + assert.fail('Expected a Redirect error'); + } + + assert.equal(e.status, 307); + assert.equal(e.location, 'https://google.de/search'); + } + }); + + it('throws a descriptive error for external redirect locations', () => { + assert.throws( + () => redirect(307, 'https://google.de'), + /Cannot redirect to external URL "https:\/\/google\.de"/ + ); + }); + + it('throws a descriptive error for javascript URLs with external: true', () => { + assert.throws( + () => redirect(307, 'javascript:alert(1)', { external: true }), + /Cannot redirect to "javascript:alert\(1\)" with `{ external: true }`/ + ); + }); + + it('throws Redirect for allowlisted javascript URLs', () => { + try { + redirect(307, 'javascript:alert(1)', { external: ['javascript:'] }); + assert.fail('Expected redirect to throw'); + } catch (e) { + if (!isRedirect(e)) { + assert.fail('Expected a Redirect error'); + } + + assert.equal(e.status, 307); + assert.equal(e.location, 'javascript:alert(1)'); + } + }); + + it('throws a descriptive error for disallowed external locations', () => { + assert.throws( + () => redirect(307, 'https://evil.com', { external: ['https://google.de'] }), + /Cannot redirect to "https:\/\/evil\.com": URL is not included in the `external` allowlist/ + ); + }); + it('throws a descriptive error for invalid redirect locations', () => { assert.throws( () => redirect(307, '/invalid\r\nset-cookie: x=y'), diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 04445d3a286b..e4bc039f2818 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -1,4 +1,5 @@ import { BROWSER, DEV } from 'esm-env'; +import { try_get_request_store } from '../exports/internal/event.js'; /** * Matches a URI scheme. See https://www.rfc-editor.org/rfc/rfc3986#section-3.1 @@ -27,6 +28,122 @@ export function is_root_relative(path) { return path[0] === '/' && path[1] !== '/'; } +/** + * Whether a redirect location is absolute, i.e. not a root-relative or path-relative URL. + * @param {string} location + */ +export function is_external_location(location) { + const is_absolute = (location[0] === '/' && location[1] === '/') || SCHEME.test(location); + + if (is_absolute) { + // TODO we need base path here but that fails right now because apparently other imports + // not using Vite end up here. Ideally we also can determine that a relative link is + // not part of the app and therefore also external, for which we would need to check this + // all the time + if (BROWSER) { + if (matches_external_allowlist_entry(location, window.location.origin)) return false; + } else { + const event = try_get_request_store(); + if (event && matches_external_allowlist_entry(location, event?.event.url.origin)) { + return false; + } + } + } + + return is_absolute; +} + +/** + * @param {string} location + */ +function is_javascript_location(location) { + return /^javascript:/i.test(location); +} + +/** + * @param {string} location + * @param {string} allowed + */ +export function matches_external_allowlist_entry(location, allowed) { + if (location === allowed) return true; + + try { + const loc = new URL(location); + const allow = new URL(allowed); + + if (loc.protocol !== allow.protocol || loc.host !== allow.host) { + return false; + } + + const allow_path = allow.pathname; + + if (allow.pathname === '/' || allow.pathname === '' /* happens in case of 'javascript:' */) { + return true; + } + + const loc_path = loc.pathname; + + if (loc_path === allow_path) return true; + + const prefix = allow_path.endsWith('/') ? allow_path : allow_path + '/'; + return loc_path.startsWith(prefix); + } catch { + return false; + } +} + +/** + * @param {string} location + * @param {{ external?: boolean | string[] }} [options] + */ +export function validate_redirect_location(location, options) { + if (!is_external_location(location)) return; + + const external = options?.external; + + if (!external) { + throw new Error( + DEV + ? `Cannot redirect to external URL ${JSON.stringify(location)}. ` + + 'To redirect to an external URL, pass `{ external: true }` or an allowlist of permitted URLs as the third argument to `redirect`' + + BROWSER + + (BROWSER ? window.location.href : try_get_request_store()?.event.request.url) + : 'Cannot redirect to external URL unless explicitly allowed' + ); + } + + if (external === true) { + if (is_javascript_location(location)) { + throw new Error( + DEV + ? `Cannot redirect to ${JSON.stringify(location)} with \`{ external: true }\`. ` + + 'JavaScript URLs must be explicitly listed in the `external` allowlist' + : 'Cannot redirect to external URL unless explicitly allowed' + ); + } + + return; + } + + if (Array.isArray(external)) { + if (!external.some((allowed) => matches_external_allowlist_entry(location, allowed))) { + throw new Error( + DEV + ? `Cannot redirect to ${JSON.stringify(location)}: URL is not included in the \`external\` allowlist` + : 'Cannot redirect to external URL unless explicitly allowed' + ); + } + + return; + } + + throw new Error( + DEV + ? '`redirect` options.external must be `true` or an array of allowed URLs' + : 'Invalid redirect options.external value' + ); +} + /** * @param {string} path * @param {import('types').TrailingSlash} trailing_slash diff --git a/packages/kit/src/utils/url.spec.js b/packages/kit/src/utils/url.spec.js index 4ad5244833f2..511e72202410 100644 --- a/packages/kit/src/utils/url.spec.js +++ b/packages/kit/src/utils/url.spec.js @@ -1,5 +1,13 @@ import { assert, describe } from 'vitest'; -import { resolve, normalize_path, make_trackable, disable_search } from './url.js'; +import { + resolve, + normalize_path, + make_trackable, + disable_search, + is_external_location, + matches_external_allowlist_entry, + validate_redirect_location +} from './url.js'; describe('resolve', (test) => { test('resolves a root-relative path', () => { @@ -67,6 +75,51 @@ describe('resolve', (test) => { }); }); +describe('is_absolute_location', (test) => { + test('detects absolute URLs', () => { + assert.equal(is_external_location('https://example.com'), true); + assert.equal(is_external_location('//example.com/foo'), true); + assert.equal(is_external_location('mailto:hello@svelte.dev'), true); + assert.equal(is_external_location('javascript:alert(1)'), true); + }); + + test('detects relative URLs', () => { + assert.equal(is_external_location('/foo'), false); + assert.equal(is_external_location('./foo'), false); + assert.equal(is_external_location('foo'), false); + assert.equal(is_external_location('#hash'), false); + assert.equal(is_external_location('?query'), false); + }); +}); + +describe('matches_external_allowlist_entry', (test) => { + test('matches allowed origins and paths', () => { + assert.equal(matches_external_allowlist_entry('https://google.de', 'https://google.de'), true); + assert.equal( + matches_external_allowlist_entry('https://google.de/search', 'https://google.de'), + true + ); + assert.equal( + matches_external_allowlist_entry('https://google.de.evil.com', 'https://google.de'), + false + ); + assert.equal(matches_external_allowlist_entry('https://evil.com', 'https://google.de'), false); + }); +}); + +describe('validate_redirect_location', (test) => { + test('allows relative locations without options', () => { + validate_redirect_location('/foo'); + }); + + test('requires permission for absolute locations', () => { + assert.throws( + () => validate_redirect_location('https://google.de'), + /Cannot redirect to external URL "https:\/\/google\.de"/ + ); + }); +}); + describe('normalize_path', (test) => { test('normalizes paths', () => { /** @type {Record} */ diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js index 0b5f120e8035..4d4ee81c5f1f 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js @@ -2,5 +2,9 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect(301, `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`); + redirect( + 301, + `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`, + { external: true } + ); } diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect-malicious/+page.js b/packages/kit/test/prerendering/basics/src/routes/redirect-malicious/+page.js index 2f8cbe6b9caa..4755f05dc23a 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect-malicious/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect-malicious/+page.js @@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect(301, 'https://example.com/alert("pwned")'); + redirect(301, 'https://example.com/alert("pwned")', { external: true }); } diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect-server/+page.server.js b/packages/kit/test/prerendering/basics/src/routes/redirect-server/+page.server.js index c61c65b8d644..fc04229e84a8 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect-server/+page.server.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect-server/+page.server.js @@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect(301, 'https://example.com/redirected'); + redirect(301, 'https://example.com/redirected', { external: true }); } diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect/+page.js b/packages/kit/test/prerendering/basics/src/routes/redirect/+page.js index c61c65b8d644..fc04229e84a8 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect/+page.js @@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect(301, 'https://example.com/redirected'); + redirect(301, 'https://example.com/redirected', { external: true }); } diff --git a/packages/kit/test/prerendering/basics/src/routes/spa-shell/+page.js b/packages/kit/test/prerendering/basics/src/routes/spa-shell/+page.js index 55a527fff567..6714fee4a995 100644 --- a/packages/kit/test/prerendering/basics/src/routes/spa-shell/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/spa-shell/+page.js @@ -6,5 +6,5 @@ export const ssr = false; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect(301, 'https://example.com/redirected'); + redirect(301, 'https://example.com/redirected', { external: true }); } From 675af3bdd61546a1b7f37eb5f67c34ab2f6acd27 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 29 Jun 2026 22:16:54 +0200 Subject: [PATCH 2/9] tweak --- packages/kit/src/utils/url.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index e4bc039f2818..da3c117fa94d 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -29,17 +29,17 @@ export function is_root_relative(path) { } /** - * Whether a redirect location is absolute, i.e. not a root-relative or path-relative URL. + * Whether a redirect location is absolute, i.e. not a root-relative or path-relative URL, + * and not pointing to the same origin as we're currently on (if determineable). * @param {string} location */ export function is_external_location(location) { const is_absolute = (location[0] === '/' && location[1] === '/') || SCHEME.test(location); if (is_absolute) { - // TODO we need base path here but that fails right now because apparently other imports - // not using Vite end up here. Ideally we also can determine that a relative link is - // not part of the app and therefore also external, for which we would need to check this - // all the time + // Ideally we could be more strict here with checking base path, but that is impossible because + // we can't retrieve the base path here, as all of this code needs to be runnable in pure Node without Vite. + // As a result, we check origins only, which is already plenty enough. if (BROWSER) { if (matches_external_allowlist_entry(location, window.location.origin)) return false; } else { From 3b529bfd6002beca318359e09d610ff1361c25b1 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:21:28 +0200 Subject: [PATCH 3/9] changeset --- .changeset/some-groups-return.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/some-groups-return.md diff --git a/.changeset/some-groups-return.md b/.changeset/some-groups-return.md new file mode 100644 index 000000000000..a7ee2cb52233 --- /dev/null +++ b/.changeset/some-groups-return.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': major +--- + +breaking: forbid external redirects by default From 79fad00d099f0985553ddffec291ffd546c29120 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 30 Jun 2026 14:06:43 +0200 Subject: [PATCH 4/9] fix --- packages/kit/src/utils/url.js | 4 +--- .../basics/src/routes/redirect-encoded/+page.js | 8 +++----- packages/kit/types/index.d.ts | 7 +++++-- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index da3c117fa94d..e06c1bf20e6d 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -105,9 +105,7 @@ export function validate_redirect_location(location, options) { throw new Error( DEV ? `Cannot redirect to external URL ${JSON.stringify(location)}. ` + - 'To redirect to an external URL, pass `{ external: true }` or an allowlist of permitted URLs as the third argument to `redirect`' + - BROWSER + - (BROWSER ? window.location.href : try_get_request_store()?.event.request.url) + 'To redirect to an external URL, pass `{ external: true }` or an allowlist of permitted URLs as the third argument to `redirect`' : 'Cannot redirect to external URL unless explicitly allowed' ); } diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js index 4d4ee81c5f1f..abd27ca9f03f 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js @@ -2,9 +2,7 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect( - 301, - `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`, - { external: true } - ); + redirect(301, `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`, { + external: true + }); } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 75775f8ffabb..fe2d8629432e 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2893,10 +2893,13 @@ declare module '@sveltejs/kit' { * * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). Must be in the range 300-308. * @param location The location to redirect to. + * @param options To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted URLs. * @throws {import('./public.js').Redirect} This error instructs SvelteKit to redirect to the specified location. - * @throws {Error} If the provided status is invalid or the location cannot be used as a header value. + * @throws {Error} If the provided status is invalid, the location cannot be used as a header value, or the location is an external URL without permission. * */ - export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; + export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL, options?: { + external?: boolean | string[]; + }): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. From 07c55e8d504c768c42f27117a92fa760520645d0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 30 Jun 2026 14:04:28 +0000 Subject: [PATCH 5/9] chore: autofix lint --- .../basics/src/routes/redirect-encoded/+page.js | 8 +++----- packages/kit/types/index.d.ts | 7 +++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js index 4d4ee81c5f1f..abd27ca9f03f 100644 --- a/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js +++ b/packages/kit/test/prerendering/basics/src/routes/redirect-encoded/+page.js @@ -2,9 +2,7 @@ import { redirect } from '@sveltejs/kit'; /** @type {import('@sveltejs/kit').Load} */ export function load() { - redirect( - 301, - `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`, - { external: true } - ); + redirect(301, `https://example.com/redirected?returnTo=${encodeURIComponent('/foo?bar=baz')}`, { + external: true + }); } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 75775f8ffabb..fe2d8629432e 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2893,10 +2893,13 @@ declare module '@sveltejs/kit' { * * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). Must be in the range 300-308. * @param location The location to redirect to. + * @param options To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted URLs. * @throws {import('./public.js').Redirect} This error instructs SvelteKit to redirect to the specified location. - * @throws {Error} If the provided status is invalid or the location cannot be used as a header value. + * @throws {Error} If the provided status is invalid, the location cannot be used as a header value, or the location is an external URL without permission. * */ - export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL): never; + export function redirect(status: 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number), location: string | URL, options?: { + external?: boolean | string[]; + }): never; /** * Checks whether this is a redirect thrown by {@link redirect}. * @param e The object to check. From 5c5bcc62e11684615b04f17313c3a16fe09980f7 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 1 Jul 2026 00:09:01 +0200 Subject: [PATCH 6/9] harden --- packages/kit/src/exports/index.spec.js | 24 ++++++++++++++++++++++++ packages/kit/src/utils/url.js | 23 ++++++++++++++++++----- packages/kit/src/utils/url.spec.js | 23 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js index 56ccda654ca5..87b8d282ade7 100644 --- a/packages/kit/src/exports/index.spec.js +++ b/packages/kit/src/exports/index.spec.js @@ -102,6 +102,23 @@ describe('redirect', () => { ); }); + it('throws a descriptive error for redirect locations that parse as external', () => { + assert.throws( + () => redirect(307, ' https://google.de'), + /Cannot redirect to external URL " https:\/\/google\.de"/ + ); + + assert.throws( + () => redirect(307, '\\\\google.de'), + /Cannot redirect to external URL "\\\\\\\\google\.de"/ + ); + + assert.throws( + () => redirect(307, 'x:foo'), + /Cannot redirect to external URL "x:foo"/ + ); + }); + it('throws a descriptive error for javascript URLs with external: true', () => { assert.throws( () => redirect(307, 'javascript:alert(1)', { external: true }), @@ -109,6 +126,13 @@ describe('redirect', () => { ); }); + it('throws a descriptive error for normalized javascript URLs with external: true', () => { + assert.throws( + () => redirect(307, 'java\tscript:alert(1)', { external: true }), + /Cannot redirect to "java\\tscript:alert\(1\)" with `{ external: true }`/ + ); + }); + it('throws Redirect for allowlisted javascript URLs', () => { try { redirect(307, 'javascript:alert(1)', { external: ['javascript:'] }); diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index e06c1bf20e6d..40ecbb0ff2f0 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -5,7 +5,10 @@ import { try_get_request_store } from '../exports/internal/event.js'; * Matches a URI scheme. See https://www.rfc-editor.org/rfc/rfc3986#section-3.1 * @type {RegExp} */ -export const SCHEME = /^[a-z][a-z\d+\-.]+:/i; +export const SCHEME = /^[a-z][a-z\d+\-.]*:/i; +const REDIRECT_PROTOCOL_RELATIVE = /^[\\/]{2}/; +const REDIRECT_TRIM_CHARS = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; +const REDIRECT_REMOVE_CHARS = /[\t\n\r]/g; const internal = new URL('a://'); @@ -28,23 +31,33 @@ export function is_root_relative(path) { return path[0] === '/' && path[1] !== '/'; } +/** + * Normalize redirect locations the same way the URL parser does before deciding whether + * the location points outside the current app. + * @param {string} location + */ +function normalize_redirect_location(location) { + return location.replace(REDIRECT_TRIM_CHARS, '').replace(REDIRECT_REMOVE_CHARS, ''); +} + /** * Whether a redirect location is absolute, i.e. not a root-relative or path-relative URL, * and not pointing to the same origin as we're currently on (if determineable). * @param {string} location */ export function is_external_location(location) { - const is_absolute = (location[0] === '/' && location[1] === '/') || SCHEME.test(location); + const normalized = normalize_redirect_location(location); + const is_absolute = REDIRECT_PROTOCOL_RELATIVE.test(normalized) || SCHEME.test(normalized); if (is_absolute) { // Ideally we could be more strict here with checking base path, but that is impossible because // we can't retrieve the base path here, as all of this code needs to be runnable in pure Node without Vite. // As a result, we check origins only, which is already plenty enough. if (BROWSER) { - if (matches_external_allowlist_entry(location, window.location.origin)) return false; + if (matches_external_allowlist_entry(normalized, window.location.origin)) return false; } else { const event = try_get_request_store(); - if (event && matches_external_allowlist_entry(location, event?.event.url.origin)) { + if (event && matches_external_allowlist_entry(normalized, event?.event.url.origin)) { return false; } } @@ -57,7 +70,7 @@ export function is_external_location(location) { * @param {string} location */ function is_javascript_location(location) { - return /^javascript:/i.test(location); + return /^javascript:/i.test(normalize_redirect_location(location)); } /** diff --git a/packages/kit/src/utils/url.spec.js b/packages/kit/src/utils/url.spec.js index 511e72202410..988b9e472b31 100644 --- a/packages/kit/src/utils/url.spec.js +++ b/packages/kit/src/utils/url.spec.js @@ -81,6 +81,12 @@ describe('is_absolute_location', (test) => { assert.equal(is_external_location('//example.com/foo'), true); assert.equal(is_external_location('mailto:hello@svelte.dev'), true); assert.equal(is_external_location('javascript:alert(1)'), true); + assert.equal(is_external_location(' https://example.com'), true); + assert.equal(is_external_location('\thttps://example.com'), true); + assert.equal(is_external_location('java\tscript:alert(1)'), true); + assert.equal(is_external_location('\\\\example.com/foo'), true); + assert.equal(is_external_location('/\\\\example.com/foo'), true); + assert.equal(is_external_location('x:foo'), true); }); test('detects relative URLs', () => { @@ -118,6 +124,23 @@ describe('validate_redirect_location', (test) => { /Cannot redirect to external URL "https:\/\/google\.de"/ ); }); + + test('requires permission for locations that parse as absolute after URL normalization', () => { + assert.throws( + () => validate_redirect_location(' https://google.de'), + /Cannot redirect to external URL " https:\/\/google\.de"/ + ); + + assert.throws( + () => validate_redirect_location('\\\\google.de'), + /Cannot redirect to external URL "\\\\\\\\google\.de"/ + ); + + assert.throws( + () => validate_redirect_location('x:foo'), + /Cannot redirect to external URL "x:foo"/ + ); + }); }); describe('normalize_path', (test) => { From 0f9e612439eaadb1e4cf898243d0b4c84d9b929d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 1 Jul 2026 00:15:15 +0200 Subject: [PATCH 7/9] lint --- packages/kit/src/exports/index.spec.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js index 87b8d282ade7..6a65d44fd81d 100644 --- a/packages/kit/src/exports/index.spec.js +++ b/packages/kit/src/exports/index.spec.js @@ -113,10 +113,7 @@ describe('redirect', () => { /Cannot redirect to external URL "\\\\\\\\google\.de"/ ); - assert.throws( - () => redirect(307, 'x:foo'), - /Cannot redirect to external URL "x:foo"/ - ); + assert.throws(() => redirect(307, 'x:foo'), /Cannot redirect to external URL "x:foo"/); }); it('throws a descriptive error for javascript URLs with external: true', () => { From c83687e86e351dd7b78aac2f8d30e5f379632375 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:15:34 +0200 Subject: [PATCH 8/9] Update packages/kit/src/utils/url.js Co-authored-by: Rich Harris --- packages/kit/src/utils/url.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 40ecbb0ff2f0..3c0c87166be9 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -84,6 +84,7 @@ export function matches_external_allowlist_entry(location, allowed) { const loc = new URL(location); const allow = new URL(allowed); + // this is stricter than `loc.origin !== allow.origin`, which can fail in `blob:` cases if (loc.protocol !== allow.protocol || loc.host !== allow.host) { return false; } From cc927b93da87abc3c8a92581badd52655a1c0fa1 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 1 Jul 2026 12:05:59 +0200 Subject: [PATCH 9/9] simplify/tweak --- packages/kit/src/exports/index.js | 2 +- packages/kit/src/exports/index.spec.js | 2 +- packages/kit/src/utils/url.js | 72 +++++++------------------- packages/kit/src/utils/url.spec.js | 11 +++- packages/kit/types/index.d.ts | 2 +- 5 files changed, 33 insertions(+), 56 deletions(-) diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index ff6805ea488a..f68ef11770d3 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -93,7 +93,7 @@ export function isHttpError(e, status) { * * @param {300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | ({} & number)} status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). Must be in the range 300-308. * @param {string | URL} location The location to redirect to. - * @param {{ external?: boolean | string[] }} [options] To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted URLs. + * @param {{ external?: boolean | string[] }} [options] To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted origins. * @throws {import('./public.js').Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid, the location cannot be used as a header value, or the location is an external URL without permission. * @return {never} diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js index 6a65d44fd81d..35575da47045 100644 --- a/packages/kit/src/exports/index.spec.js +++ b/packages/kit/src/exports/index.spec.js @@ -147,7 +147,7 @@ describe('redirect', () => { it('throws a descriptive error for disallowed external locations', () => { assert.throws( () => redirect(307, 'https://evil.com', { external: ['https://google.de'] }), - /Cannot redirect to "https:\/\/evil\.com": URL is not included in the `external` allowlist/ + /Cannot redirect to "https:\/\/evil\.com": URL origin is not included in the `external` allowlist/ ); }); diff --git a/packages/kit/src/utils/url.js b/packages/kit/src/utils/url.js index 3c0c87166be9..a375cc4c3a78 100644 --- a/packages/kit/src/utils/url.js +++ b/packages/kit/src/utils/url.js @@ -6,9 +6,8 @@ import { try_get_request_store } from '../exports/internal/event.js'; * @type {RegExp} */ export const SCHEME = /^[a-z][a-z\d+\-.]*:/i; -const REDIRECT_PROTOCOL_RELATIVE = /^[\\/]{2}/; -const REDIRECT_TRIM_CHARS = /^[\u0000-\u0020]+|[\u0000-\u0020]+$/g; -const REDIRECT_REMOVE_CHARS = /[\t\n\r]/g; +// See https://datatracker.ietf.org/doc/html/rfc2606 - no domains under the .invalid TLD can be registered +const REDIRECT_BASE = 'https://sveltekit-redirect.invalid'; const internal = new URL('a://'); @@ -31,46 +30,30 @@ export function is_root_relative(path) { return path[0] === '/' && path[1] !== '/'; } -/** - * Normalize redirect locations the same way the URL parser does before deciding whether - * the location points outside the current app. - * @param {string} location - */ -function normalize_redirect_location(location) { - return location.replace(REDIRECT_TRIM_CHARS, '').replace(REDIRECT_REMOVE_CHARS, ''); -} - /** * Whether a redirect location is absolute, i.e. not a root-relative or path-relative URL, * and not pointing to the same origin as we're currently on (if determineable). * @param {string} location */ export function is_external_location(location) { - const normalized = normalize_redirect_location(location); - const is_absolute = REDIRECT_PROTOCOL_RELATIVE.test(normalized) || SCHEME.test(normalized); - - if (is_absolute) { - // Ideally we could be more strict here with checking base path, but that is impossible because - // we can't retrieve the base path here, as all of this code needs to be runnable in pure Node without Vite. - // As a result, we check origins only, which is already plenty enough. - if (BROWSER) { - if (matches_external_allowlist_entry(normalized, window.location.origin)) return false; - } else { - const event = try_get_request_store(); - if (event && matches_external_allowlist_entry(normalized, event?.event.url.origin)) { - return false; - } - } - } + const origin = BROWSER ? window.location.origin : try_get_request_store()?.event.url.origin; - return is_absolute; + try { + return !matches_external_allowlist_entry(location, origin ?? REDIRECT_BASE); + } catch { + return true; + } } /** * @param {string} location */ function is_javascript_location(location) { - return /^javascript:/i.test(normalize_redirect_location(location)); + try { + return new URL(location, REDIRECT_BASE).protocol === 'javascript:'; + } catch { + return false; + } } /** @@ -81,26 +64,11 @@ export function matches_external_allowlist_entry(location, allowed) { if (location === allowed) return true; try { - const loc = new URL(location); const allow = new URL(allowed); + const loc = new URL(location, allow); - // this is stricter than `loc.origin !== allow.origin`, which can fail in `blob:` cases - if (loc.protocol !== allow.protocol || loc.host !== allow.host) { - return false; - } - - const allow_path = allow.pathname; - - if (allow.pathname === '/' || allow.pathname === '' /* happens in case of 'javascript:' */) { - return true; - } - - const loc_path = loc.pathname; - - if (loc_path === allow_path) return true; - - const prefix = allow_path.endsWith('/') ? allow_path : allow_path + '/'; - return loc_path.startsWith(prefix); + // this is stricter than `loc.origin === allow.origin`, which can fail in `blob:` cases + return loc.protocol === allow.protocol && loc.host === allow.host; } catch { return false; } @@ -119,7 +87,7 @@ export function validate_redirect_location(location, options) { throw new Error( DEV ? `Cannot redirect to external URL ${JSON.stringify(location)}. ` + - 'To redirect to an external URL, pass `{ external: true }` or an allowlist of permitted URLs as the third argument to `redirect`' + 'To redirect to an external URL, pass `{ external: true }` or an allowlist of permitted origins as the third argument to `redirect`' : 'Cannot redirect to external URL unless explicitly allowed' ); } @@ -129,7 +97,7 @@ export function validate_redirect_location(location, options) { throw new Error( DEV ? `Cannot redirect to ${JSON.stringify(location)} with \`{ external: true }\`. ` + - 'JavaScript URLs must be explicitly listed in the `external` allowlist' + 'The `:javascript` protocol must be explicitly listed in the `external` allowlist' : 'Cannot redirect to external URL unless explicitly allowed' ); } @@ -141,7 +109,7 @@ export function validate_redirect_location(location, options) { if (!external.some((allowed) => matches_external_allowlist_entry(location, allowed))) { throw new Error( DEV - ? `Cannot redirect to ${JSON.stringify(location)}: URL is not included in the \`external\` allowlist` + ? `Cannot redirect to ${JSON.stringify(location)}: URL origin is not included in the \`external\` allowlist` : 'Cannot redirect to external URL unless explicitly allowed' ); } @@ -151,7 +119,7 @@ export function validate_redirect_location(location, options) { throw new Error( DEV - ? '`redirect` options.external must be `true` or an array of allowed URLs' + ? '`redirect` options.external must be `true` or an array of allowed origins' : 'Invalid redirect options.external value' ); } diff --git a/packages/kit/src/utils/url.spec.js b/packages/kit/src/utils/url.spec.js index 988b9e472b31..4ea102c5aed8 100644 --- a/packages/kit/src/utils/url.spec.js +++ b/packages/kit/src/utils/url.spec.js @@ -87,6 +87,7 @@ describe('is_absolute_location', (test) => { assert.equal(is_external_location('\\\\example.com/foo'), true); assert.equal(is_external_location('/\\\\example.com/foo'), true); assert.equal(is_external_location('x:foo'), true); + assert.equal(is_external_location('blob:https://sveltekit-redirect.invalid/id'), true); }); test('detects relative URLs', () => { @@ -99,16 +100,24 @@ describe('is_absolute_location', (test) => { }); describe('matches_external_allowlist_entry', (test) => { - test('matches allowed origins and paths', () => { + test('matches allowed origins', () => { assert.equal(matches_external_allowlist_entry('https://google.de', 'https://google.de'), true); assert.equal( matches_external_allowlist_entry('https://google.de/search', 'https://google.de'), true ); + assert.equal( + matches_external_allowlist_entry('https://google.de/news', 'https://google.de/search'), + true + ); assert.equal( matches_external_allowlist_entry('https://google.de.evil.com', 'https://google.de'), false ); + assert.equal( + matches_external_allowlist_entry('blob:https://google.de/id', 'https://google.de'), + false + ); assert.equal(matches_external_allowlist_entry('https://evil.com', 'https://google.de'), false); }); }); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index fe2d8629432e..70d193237e9c 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2893,7 +2893,7 @@ declare module '@sveltejs/kit' { * * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#redirection_messages). Must be in the range 300-308. * @param location The location to redirect to. - * @param options To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted URLs. + * @param options To redirect to an external URL, you must pass `{ external: true }` to allow any external URL except `javascript:` URLs, or `{ external: [...] }` with an allowlist of permitted origins. * @throws {import('./public.js').Redirect} This error instructs SvelteKit to redirect to the specified location. * @throws {Error} If the provided status is invalid, the location cannot be used as a header value, or the location is an external URL without permission. * */