diff --git a/src/lib/utils/url.test.ts b/src/lib/utils/url.test.ts index 8619aaa4..cdc56165 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,39 @@ 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 loopback targets', () => { + expect(isValidTokenRedirectUri('http://localhost:1234/cb?t=%')).toBe(true); + expect(isValidTokenRedirectUri('http://[::1]:8080/cb?t=%')).toBe(true); + }); + + 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', () => { + 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..0e174068 100644 --- a/src/lib/utils/url.ts +++ b/src/lib/utils/url.ts @@ -182,6 +182,54 @@ 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`) 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 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 + */ +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; + + // 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); + } + + 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; }