import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { buildNextParam, sanitizeNextPath } from './safe-next'; // Die 54 Codepunkte, die `sanitizeNextPath` bei der Planung von quick-260921-i8x // ueber alle 65536 Codepunkte aus U+0000..U+FFFF gemessen abweist (SHA-256 der // Bitmap: 3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64). // Hart hinterlegt, nicht aus der Regel abgeleitet (D-01): eine aus derselben // Regel erzeugte Erwartung wuerde jede Aenderung der Regel mitmachen und // bewiese damit nichts. const EXPECTED_REJECTED_CODEPOINTS: number[] = [ ...Array.from({ length: 0x20 }, (_, i) => i), // 0x00-0x1f 0x20, 0x5c, 0x7f, 0xa0, 0x1680, ...Array.from({ length: 0x200a - 0x2000 + 1 }, (_, i) => 0x2000 + i), // 0x2000-0x200a 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, 0xfeff, ]; /** * safe-next.test — Ruecksprung nach Anmeldung (quick-260917-gyd). * * Reine Funktionen ohne DOM-/Node-Abhaengigkeit: `buildNextParam` baut den * `next`-Wert aus Pfad + Query, `sanitizeNextPath` prueft ihn beim Absenden * der Anmeldeseite gegen Open-Redirect-Versuche. */ describe('buildNextParam', () => { it('Test 1: Pfad ohne Query bleibt unveraendert', () => { expect(buildNextParam('/settings/general/desktop', '')).toBe( '/settings/general/desktop', ); }); it('Test 2: _rsc wird entfernt, andere Parameter bleiben', () => { expect(buildNextParam('/modules/tender-radar', '?tab=alerts&_rsc=1abc')).toBe( '/modules/tender-radar?tab=alerts', ); }); it('Test 3: leere Query nach Entfernen von _rsc bleibt ohne "?"', () => { expect(buildNextParam('/modules/tender-radar', '?_rsc=1abc')).toBe( '/modules/tender-radar', ); }); it('Test 4: Startseite und Anmeldeseite liefern null (kein Ruecksprung auf sich selbst)', () => { expect(buildNextParam('/', '')).toBeNull(); expect(buildNextParam('/login', '?next=%2Fx')).toBeNull(); expect(buildNextParam('/login/', '')).toBeNull(); }); }); describe('sanitizeNextPath', () => { it('Test 5: gueltige relative Pfade bleiben unveraendert', () => { expect(sanitizeNextPath('/settings/general/desktop')).toBe( '/settings/general/desktop', ); expect(sanitizeNextPath('/modules/x?tab=1')).toBe('/modules/x?tab=1'); }); it('Test 6: fehlende oder falsch typisierte Werte fallen auf "/" zurueck', () => { expect(sanitizeNextPath(null)).toBe('/'); expect(sanitizeNextPath(undefined)).toBe('/'); expect(sanitizeNextPath('')).toBe('/'); expect(sanitizeNextPath(42)).toBe('/'); }); it('Test 7: protokoll-relative und Schema-Adressen fallen auf "/" zurueck', () => { expect(sanitizeNextPath('//evil.example')).toBe('/'); expect(sanitizeNextPath('/\\evil.example')).toBe('/'); expect(sanitizeNextPath('https://evil.example/x')).toBe('/'); expect(sanitizeNextPath('javascript:alert(1)')).toBe('/'); }); it('Test 8: fehlender Slash, Steuerzeichen und Ueberlaenge fallen auf "/" zurueck', () => { expect(sanitizeNextPath('settings')).toBe('/'); expect(sanitizeNextPath('/foo\nbar')).toBe('/'); expect(sanitizeNextPath('/a b')).toBe('/'); expect(sanitizeNextPath('/x'.padEnd(3000, 'y'))).toBe('/'); }); it('Test 9: die Anmeldeseite selbst ist kein gueltiges Ziel, "/loginhistory" schon', () => { expect(sanitizeNextPath('/login')).toBe('/'); expect(sanitizeNextPath('/login?next=/x')).toBe('/'); expect(sanitizeNextPath('/login/')).toBe('/'); expect(sanitizeNextPath('/loginhistory')).toBe('/loginhistory'); }); it('Test A: die abgewiesene Zeichenmenge ist und bleibt genau diese 54 Codepunkte (T-i8x-01)', () => { const rejected: number[] = []; for (let cp = 0; cp <= 0xffff; cp++) { const input = `/a${String.fromCharCode(cp)}b`; if (sanitizeNextPath(input) === '/') { rejected.push(cp); } } expect(rejected).toEqual(EXPECTED_REJECTED_CODEPOINTS); expect(rejected).toHaveLength(54); }); it('Test B: die Datei enthaelt kein rohes Steuerzeichen', () => { const filePath = join(import.meta.dirname, 'safe-next.ts'); const bytes = readFileSync(filePath); let count = 0; for (const byte of bytes) { if ((byte < 0x20 && byte !== 0x0a) || byte === 0x7f) { count++; } } expect(count).toBe(0); }); it('Test C: der Zwei-Zeichen-Praefixtest bleibt scharf', () => { expect(sanitizeNextPath('//host')).toBe('/'); expect(sanitizeNextPath('/\\host')).toBe('/'); expect(sanitizeNextPath(`/${String.fromCharCode(0x00)}`)).toBe('/'); }); });