import { describe, it, expect } from 'vitest'; // Mirrors safeNext() in apps/web/src/pages/LoginPage.tsx — the post-login redirect guard. `next` // rides in the login URL and is therefore attacker-supplied, so an open redirect here lands a // freshly authenticated user on a phishing page. Lives in the API suite because the web workspace // has no test runner configured; keep the two copies in step. function safeNext(raw: string | null): string { const FALLBACK = '/app'; if (!raw) return FALLBACK; const cleaned = raw.replace(/[\t\n\r]/g, ''); if (!cleaned.startsWith('/') || cleaned.startsWith('//')) return FALLBACK; try { const probe = 'https://redirect-guard.invalid'; const url = new URL(cleaned, probe); if (url.origin !== probe) return FALLBACK; return `${url.pathname}${url.search}${url.hash}`; } catch { return FALLBACK; } } const PROBE = 'https://redirect-guard.invalid'; const staysInternal = (value: string) => new URL(value, PROBE).origin === PROBE; describe('safeNext — post-login open-redirect guard', () => { it('passes through legitimate internal destinations', () => { expect(safeNext('/app/cases')).toBe('/app/cases'); expect(safeNext('/app?tab=open#row')).toBe('/app?tab=open#row'); expect(safeNext('/admin')).toBe('/admin'); }); it('falls back when nothing was requested', () => { expect(safeNext(null)).toBe('/app'); expect(safeNext('')).toBe('/app'); }); it('rejects absolute URLs to another origin', () => { expect(safeNext('https://evil.com')).toBe('/app'); expect(safeNext('http://evil.com/path')).toBe('/app'); }); it('rejects protocol-relative URLs', () => { expect(safeNext('//evil.com')).toBe('/app'); expect(safeNext('////evil.com')).toBe('/app'); }); it('rejects backslash variants that browsers normalise into an authority', () => { // URL parsing rewrites \ as / for special schemes, so each of these would otherwise become // protocol-relative and point off-origin. The origin check is what catches them. for (const attack of ['/\\\\evil.com', '/\\/evil.com', '\\\\evil.com', '/\\\\\\evil.com']) { expect(safeNext(attack)).toBe('/app'); } }); it('rejects schemes smuggled past the leading-slash check with whitespace', () => { expect(safeNext('\tjavascript:alert(1)')).toBe('/app'); expect(safeNext('\njavascript:alert(1)')).toBe('/app'); expect(safeNext('javascript:alert(1)')).toBe('/app'); expect(safeNext('data:text/html,')).toBe('/app'); }); it('never returns a value that resolves off-origin', () => { const attacks = [ '//evil.com', '/\\evil.com', '/\\\\evil.com', 'https://evil.com', '\tjavascript:alert(1)', '////evil.com', '/%5cevil.com', '/%2f%2fevil.com', '/\t/evil.com', 'data:text/html,', ]; for (const attack of attacks) { expect(staysInternal(safeNext(attack))).toBe(true); } }); });