Files
tessera-ctl/apps/web/src/lib/safe-next.test.ts
T
schalli f85c91be01 fix(web): Steuerzeichen in safe-next.ts als Unicode-Escapes statt Rohbytes
FORBIDDEN_CHARS_RE in sanitizeNextPath bestand aus drei rohen Steuerbytes
(NUL, US, DEL) statt Escapes - jeder Editor, Formatierer oder Minifier in
der Kette kann solche Bytes stillschweigend verschlucken. Umgeschrieben auf
Unicode-Escapes fuer den Bereich U+0000 bis U+001F und U+007F. Zeichenmenge
ueber alle 65536 Codepunkte aus U+0000 bis U+FFFF als unveraendert
nachgewiesen (54 abgewiesene Codepunkte, Bitmap-SHA-256
3d58108b87e4641e506602cc701a66d11ec19cf20551937fada1826f50ecbe64 vor und
nach dem Umbau identisch). Kommentar korrigiert: die vorherige Behauptung,
Escapes seien "am Edge" noetig, war falsch - zwischen den beiden
Escape-Schreibweisen gibt es zur Laufzeit keinen Unterschied.

Quick-Vorgang 260921-i8x, Task 1/3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
2026-09-21 13:26:25 +02:00

128 lines
4.6 KiB
TypeScript

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('/');
});
});