f85c91be01
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
128 lines
4.6 KiB
TypeScript
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('/');
|
|
});
|
|
});
|