Skip to content

Commit 259c5ff

Browse files
fix(auth): harden post-login redirect validation (#125)
* fix(auth): validate post-login redirect to prevent open redirect The post-login `redirect` query param was passed straight to SvelteKit's `redirect()` (server load) and `goto()` (client) without validation, allowing an attacker to craft `?redirect=https://evil.example` or the protocol-relative `?redirect=//evil.example` to bounce a logged-in user off-site (open redirect). Add a shared `safeRedirect()` helper that only accepts same-origin, path-absolute targets (must start with a single `/`), rejecting absolute URLs, protocol-relative `//host` and the `/\host` backslash variant that browsers normalise to it, falling back to `/portal/dashboard`. Wire it into both the server load and `handleSuccess()`, with unit tests. Refs #123 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(auth): reject control chars in redirect target Browsers strip C0 control characters (TAB/LF/CR) from URLs before navigating, so `/\t/evil.example` and `/\n//evil.example` slipped past the slash checks and then normalised to a protocol-relative, off-origin URL. Reject any C0 control char (U+0000-U+001F) up front, before the slash checks. Add regression tests for the TAB/LF/CR variants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 096beaa commit 259c5ff

4 files changed

Lines changed: 92 additions & 3 deletions

File tree

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { redirect } from '@sveltejs/kit';
22
import type { PageServerLoad } from './$types';
3+
import { safeRedirect } from './safe-redirect';
34

45
export const load: PageServerLoad = async ({ locals, url }) => {
56
if (locals.session?.userType === 'org') {
6-
redirect(303, url.searchParams.get('redirect') ?? '/portal/dashboard');
7+
redirect(303, safeRedirect(url.searchParams.get('redirect')));
78
}
89
};

src/routes/auth/login/+page.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,16 @@
88
import { goto } from '$app/navigation';
99
import { page } from '$app/state';
1010
import { resolve } from '$app/paths';
11+
import { safeRedirect } from './safe-redirect';
1112
import type { PageData } from './$types';
1213
1314
let { data }: { data: PageData } = $props();
1415
15-
const redirectTo = $derived(page.url.searchParams.get('redirect') ?? '/portal/dashboard');
16+
const redirectTo = $derived(page.url.searchParams.get('redirect'));
1617
1718
function handleSuccess() {
18-
goto(resolve(redirectTo as '/portal/dashboard'));
19+
// The `redirect` param is attacker-controlled — validate it before navigating.
20+
goto(resolve(safeRedirect(redirectTo) as '/portal/dashboard'));
1921
}
2022
</script>
2123

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// The post-login `redirect` query param is attacker-controlled, so it must
2+
// never be handed to `redirect()`/`goto()` without validation — otherwise it
3+
// is an open redirect (an attacker can send `?redirect=https://evil.example`
4+
// or the protocol-relative `?redirect=//evil.example` to bounce a logged-in
5+
// user off-site). Only same-origin, path-absolute targets are allowed.
6+
7+
export const DEFAULT_REDIRECT = '/portal/dashboard';
8+
9+
/**
10+
* True if `value` contains any C0 control character (U+0000–U+001F, incl.
11+
* TAB/LF/CR). Browsers strip these from URLs before navigating, so a value
12+
* like `/\t/evil.example` or `/\n//evil.example` would slip past the slash
13+
* checks below and then normalise to a protocol-relative, off-origin URL.
14+
* Strip-then-parse is the classic open-redirect bypass, so we reject any such
15+
* value up front. (Expressed as a char-code scan to keep literal control
16+
* characters out of the source.)
17+
*/
18+
function hasControlChar(value: string): boolean {
19+
for (let i = 0; i < value.length; i++) {
20+
if (value.charCodeAt(i) <= 0x1f) return true;
21+
}
22+
return false;
23+
}
24+
25+
/**
26+
* Return a safe post-login redirect target. A value is only accepted when it
27+
* begins with a single `/` (a same-origin absolute path). Everything else —
28+
* empty/missing values, values containing control characters, absolute URLs
29+
* (`https://…`), protocol-relative URLs (`//host`) and the backslash variant
30+
* browsers normalise to them (`/\host`) — falls back to {@link DEFAULT_REDIRECT}.
31+
*/
32+
export function safeRedirect(target: string | null | undefined): string {
33+
if (!target) return DEFAULT_REDIRECT;
34+
if (hasControlChar(target)) return DEFAULT_REDIRECT;
35+
if (!target.startsWith('/')) return DEFAULT_REDIRECT;
36+
// Reject protocol-relative URLs. Browsers treat `\` as `/`, so `/\host`
37+
// escapes the origin just like `//host` does.
38+
if (target[1] === '/' || target[1] === '\\') return DEFAULT_REDIRECT;
39+
return target;
40+
}

tests/unit/safe-redirect.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { safeRedirect, DEFAULT_REDIRECT } from '../../src/routes/auth/login/safe-redirect';
3+
4+
describe('safeRedirect', () => {
5+
it('accepts same-origin absolute paths', () => {
6+
expect(safeRedirect('/portal/dashboard')).toBe('/portal/dashboard');
7+
expect(safeRedirect('/portal/settings')).toBe('/portal/settings');
8+
expect(safeRedirect('/')).toBe('/');
9+
expect(safeRedirect('/a?b=c#d')).toBe('/a?b=c#d');
10+
});
11+
12+
it('falls back for missing values', () => {
13+
expect(safeRedirect(null)).toBe(DEFAULT_REDIRECT);
14+
expect(safeRedirect(undefined)).toBe(DEFAULT_REDIRECT);
15+
expect(safeRedirect('')).toBe(DEFAULT_REDIRECT);
16+
});
17+
18+
it('rejects values that do not start with a slash', () => {
19+
expect(safeRedirect('portal/dashboard')).toBe(DEFAULT_REDIRECT);
20+
expect(safeRedirect('https://evil.example')).toBe(DEFAULT_REDIRECT);
21+
expect(safeRedirect('http://evil.example/path')).toBe(DEFAULT_REDIRECT);
22+
expect(safeRedirect('javascript:alert(1)')).toBe(DEFAULT_REDIRECT);
23+
expect(safeRedirect(' /portal/dashboard')).toBe(DEFAULT_REDIRECT);
24+
});
25+
26+
it('rejects protocol-relative URLs', () => {
27+
expect(safeRedirect('//evil.example')).toBe(DEFAULT_REDIRECT);
28+
expect(safeRedirect('//evil.example/path')).toBe(DEFAULT_REDIRECT);
29+
});
30+
31+
it('rejects the backslash variant browsers normalise to protocol-relative', () => {
32+
expect(safeRedirect('/\\evil.example')).toBe(DEFAULT_REDIRECT);
33+
expect(safeRedirect('/\\/evil.example')).toBe(DEFAULT_REDIRECT);
34+
});
35+
36+
it('rejects control chars browsers strip before navigating (TAB/LF/CR)', () => {
37+
// Browsers drop these, so `/\t/evil.example` etc. would normalise to
38+
// `//evil.example` (off-origin) if they slipped past the slash checks.
39+
expect(safeRedirect('/\t/evil.example')).toBe(DEFAULT_REDIRECT);
40+
expect(safeRedirect('/\n/evil.example')).toBe(DEFAULT_REDIRECT);
41+
expect(safeRedirect('/\r/evil.example')).toBe(DEFAULT_REDIRECT);
42+
expect(safeRedirect('/\t\\evil.example')).toBe(DEFAULT_REDIRECT);
43+
expect(safeRedirect('\t//evil.example')).toBe(DEFAULT_REDIRECT);
44+
expect(safeRedirect('/foo\nbar')).toBe(DEFAULT_REDIRECT);
45+
});
46+
});

0 commit comments

Comments
 (0)