From f521c9ed9572b8ddedc75c5610d15b265dfca2e8 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 18 Jun 2026 12:25:24 +0200 Subject: [PATCH 1/2] fix(security): validate redirect_uri in external token grant flow The external-application API-token grant page took redirect_uri verbatim from the query string and navigated window.location to it with the freshly minted token spliced in, allowing an attacker to exfiltrate a live token to an arbitrary origin via a crafted consent link. Add isValidTokenRedirectUri() which allows custom application schemes and loopback http(s) targets but rejects remote origins and dangerous schemes (javascript:/data:/...). Validate redirect_uri at parse time and encodeURIComponent the token when building the redirect target. --- src/lib/utils/url.test.ts | 32 +++++++++++++ src/lib/utils/url.ts | 45 +++++++++++++++++++ .../settings/api-tokens/new/+page.svelte | 14 +++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/src/lib/utils/url.test.ts b/src/lib/utils/url.test.ts index 8619aaa4..90a566db 100644 --- a/src/lib/utils/url.test.ts +++ b/src/lib/utils/url.test.ts @@ -63,6 +63,7 @@ const { isShortLinkOrigin, isValidRedirectURL, isValidRedirectParam, + isValidTokenRedirectUri, sanitizeRedirectSearchParam, gotoQueryRedirectOrFallback, REDIRECT_QUERY_PARAM, @@ -698,3 +699,34 @@ describe('isValidRedirectParam — additional', () => { expect(isValidRedirectParam('/\\evil.com')).toBe(false); }); }); + +describe('isValidTokenRedirectUri', () => { + it('accepts a custom application scheme', () => { + expect(isValidTokenRedirectUri('shockosc://callback?token=%')).toBe(true); + }); + + it('accepts http(s) loopback targets', () => { + expect(isValidTokenRedirectUri('http://localhost:1234/cb?t=%')).toBe(true); + expect(isValidTokenRedirectUri('https://127.0.0.1/cb?t=%')).toBe(true); + expect(isValidTokenRedirectUri('http://[::1]:8080/cb?t=%')).toBe(true); + }); + + it('rejects remote http(s) origins', () => { + expect(isValidTokenRedirectUri('https://evil.com/grab?t=%')).toBe(false); + expect(isValidTokenRedirectUri('http://localhost.evil.com/?t=%')).toBe(false); + expect(isValidTokenRedirectUri('https://localhost@evil.com/?t=%')).toBe(false); + }); + + it('rejects dangerous schemes', () => { + expect(isValidTokenRedirectUri('javascript:alert(1)')).toBe(false); + expect(isValidTokenRedirectUri('data:text/html,')).toBe(false); + expect(isValidTokenRedirectUri('vbscript:msgbox(1)')).toBe(false); + expect(isValidTokenRedirectUri('file:///etc/passwd')).toBe(false); + expect(isValidTokenRedirectUri('blob:https://localhost/abc')).toBe(false); + }); + + it('rejects non-URL values', () => { + expect(isValidTokenRedirectUri('not a url')).toBe(false); + expect(isValidTokenRedirectUri('')).toBe(false); + }); +}); diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts index 0cc50628..42c4dec9 100644 --- a/src/lib/utils/url.ts +++ b/src/lib/utils/url.ts @@ -182,6 +182,51 @@ export function isValidRedirectParam(value: string): boolean { } } +/** + * URL schemes that must never be used as a token redirect target. Navigating + * `window.location` to any of these can execute script or render + * attacker-controlled content in the page's origin. + */ +const DANGEROUS_REDIRECT_SCHEMES = new Set(['javascript:', 'data:', 'vbscript:', 'file:', 'blob:']); + +/** Loopback hostnames permitted for local `http(s)` token redirect targets. */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); + +/** + * Validates a `redirect_uri` for the external-application API-token grant flow. + * + * In that flow a freshly minted token is handed to an external app by + * navigating the browser to its redirect URI, so this is deliberately more + * permissive than {@link isValidRedirectParam} — it allows custom application + * schemes (e.g. `shockosc://callback`). To prevent the token from being + * exfiltrated to an attacker it rejects: + * - dangerous schemes that can execute script or render HTML + * (`javascript:`, `data:`, …) + * - remote `http(s)` origins — a token must never be shipped to an arbitrary + * web origin; only loopback addresses are allowed (for local apps) + * + * @param value - The raw `redirect_uri` query parameter + * @returns Whether the value is a safe token redirect target + */ +export function isValidTokenRedirectUri(value: string): boolean { + let url: URL; + try { + url = new URL(value); + } catch { + return false; + } + + if (DANGEROUS_REDIRECT_SCHEMES.has(url.protocol)) return false; + + // Web schemes may only target loopback — a token must never leave to a remote + // origin. Non-web (custom application) schemes are allowed through. + if (url.protocol === 'http:' || url.protocol === 'https:') { + return LOOPBACK_HOSTNAMES.has(url.hostname); + } + + return true; +} + /** * Strips an invalid redirect query parameter from the current URL bar. * diff --git a/src/routes/(app)/settings/api-tokens/new/+page.svelte b/src/routes/(app)/settings/api-tokens/new/+page.svelte index 7143ce57..003d797c 100644 --- a/src/routes/(app)/settings/api-tokens/new/+page.svelte +++ b/src/routes/(app)/settings/api-tokens/new/+page.svelte @@ -46,6 +46,7 @@ import * as Card from '$lib/components/ui/card/index.js'; import { handleApiError } from '$lib/errorhandling/apiErrorHandling'; import { registerBreadcrumbs } from '$lib/state/breadcrumbs-state.svelte'; + import { isValidTokenRedirectUri } from '$lib/utils/url'; import CircleCheck from '@lucide/svelte/icons/circle-check'; import KeyRound from '@lucide/svelte/icons/key-round'; import { toast } from 'svelte-sonner'; @@ -97,6 +98,15 @@ redirectUri = params.get('redirect_uri'); const external = redirectUri !== null; + // Reject redirect targets that could exfiltrate the freshly minted token to + // an attacker (remote http(s) origins, javascript:/data: schemes, …). + if (redirectUri !== null && !isValidTokenRedirectUri(redirectUri)) { + redirectUri = null; + parseError = + 'The provided redirect_uri is not allowed. Only loopback addresses or custom application schemes are permitted.'; + return; + } + const qName = params.get('name'); const qPermissions = params.get('permissions'); @@ -174,7 +184,9 @@ function redirectBack() { if (!tokenSecret || !redirectUri) return; - const target = redirectUri.replace('%', tokenSecret); + // redirectUri was validated in parseQueryParams; encode the token so it + // can't alter the URL structure of the (trusted) redirect target. + const target = redirectUri.replace('%', encodeURIComponent(tokenSecret)); window.location.href = target; } From 349997972a78a658999b3f5483c4eb3485d50881 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Thu, 18 Jun 2026 19:03:24 +0200 Subject: [PATCH 2/2] a --- src/lib/utils/url.test.ts | 17 +++++++++++------ src/lib/utils/url.ts | 17 ++++++++++------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/src/lib/utils/url.test.ts b/src/lib/utils/url.test.ts index 90a566db..cdc56165 100644 --- a/src/lib/utils/url.test.ts +++ b/src/lib/utils/url.test.ts @@ -705,16 +705,21 @@ describe('isValidTokenRedirectUri', () => { expect(isValidTokenRedirectUri('shockosc://callback?token=%')).toBe(true); }); - it('accepts http(s) loopback targets', () => { + it('accepts http loopback targets', () => { expect(isValidTokenRedirectUri('http://localhost:1234/cb?t=%')).toBe(true); - expect(isValidTokenRedirectUri('https://127.0.0.1/cb?t=%')).toBe(true); expect(isValidTokenRedirectUri('http://[::1]:8080/cb?t=%')).toBe(true); }); - it('rejects remote http(s) origins', () => { - expect(isValidTokenRedirectUri('https://evil.com/grab?t=%')).toBe(false); - expect(isValidTokenRedirectUri('http://localhost.evil.com/?t=%')).toBe(false); - expect(isValidTokenRedirectUri('https://localhost@evil.com/?t=%')).toBe(false); + it('accepts remote https origins', () => { + expect(isValidTokenRedirectUri('https://127.0.0.1/cb?t=%')).toBe(true); + expect(isValidTokenRedirectUri('https://shockalarmclock.app/cb?t=%')).toBe(true); + expect(isValidTokenRedirectUri('https://evil.com/grab?t=%')).toBe(true); + expect(isValidTokenRedirectUri('https://localhost@evil.com/?t=%')).toBe(true); + }); + + it('rejects remote cleartext http origins', () => { + expect(isValidTokenRedirectUri('http://localhost.insecure.com/?t=%')).toBe(false); + expect(isValidTokenRedirectUri('http://insecure.com/cb?t=%')).toBe(false); }); it('rejects dangerous schemes', () => { diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts index 42c4dec9..0e174068 100644 --- a/src/lib/utils/url.ts +++ b/src/lib/utils/url.ts @@ -198,12 +198,14 @@ const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); * In that flow a freshly minted token is handed to an external app by * navigating the browser to its redirect URI, so this is deliberately more * permissive than {@link isValidRedirectParam} — it allows custom application - * schemes (e.g. `shockosc://callback`). To prevent the token from being - * exfiltrated to an attacker it rejects: + * schemes (e.g. `shockosc://callback`) and remote `https` origins (for + * web-based integrations). To prevent the token from being exfiltrated it + * rejects: * - dangerous schemes that can execute script or render HTML * (`javascript:`, `data:`, …) - * - remote `http(s)` origins — a token must never be shipped to an arbitrary - * web origin; only loopback addresses are allowed (for local apps) + * - remote cleartext `http` origins — a token must never travel unencrypted + * to a remote origin; `http` is only allowed for loopback addresses (for + * local apps) * * @param value - The raw `redirect_uri` query parameter * @returns Whether the value is a safe token redirect target @@ -218,9 +220,10 @@ export function isValidTokenRedirectUri(value: string): boolean { if (DANGEROUS_REDIRECT_SCHEMES.has(url.protocol)) return false; - // Web schemes may only target loopback — a token must never leave to a remote - // origin. Non-web (custom application) schemes are allowed through. - if (url.protocol === 'http:' || url.protocol === 'https:') { + // Cleartext http may only target loopback — a token must never travel + // unencrypted to a remote origin. https and custom (application) schemes are + // allowed through, since https keeps the token confidential in transit. + if (url.protocol === 'http:') { return LOOPBACK_HOSTNAMES.has(url.hostname); }