Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/lib/utils/url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const {
isShortLinkOrigin,
isValidRedirectURL,
isValidRedirectParam,
isValidTokenRedirectUri,
sanitizeRedirectSearchParam,
gotoQueryRedirectOrFallback,
REDIRECT_QUERY_PARAM,
Expand Down Expand Up @@ -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,<script>1</script>')).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);
});
});
48 changes: 48 additions & 0 deletions src/lib/utils/url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
14 changes: 13 additions & 1 deletion src/routes/(app)/settings/api-tokens/new/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
Expand Down
Loading