Files
tessera-ctl/apps/web/src/lib/safe-next.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

80 lines
2.7 KiB
TypeScript

/**
* safe-next — Ruecksprung nach Anmeldung (quick-260917-gyd).
*
* Reine Funktionen ohne Node- oder DOM-APIs: die Datei wird sowohl von der
* Edge-Middleware (middleware.ts) als auch vom Client (login/page.tsx,
* header.tsx) importiert.
*
* `sanitizeNextPath()` ist der Open-Redirect-Schutz (T-gyd-01): sie laesst
* ausschliesslich sichere relative Pfade durch. Schema-Adressen wie
* `https://…` oder `javascript:…` scheitern bereits an der Regel "erstes
* Zeichen muss `/` sein" — eine zusaetzliche Liste bekannter Schemata ist
* deshalb nicht noetig und wird bewusst nicht gepflegt.
*/
const MAX_NEXT_PATH_LENGTH = 2048;
// Backslash, jegliches Whitespace, die Steuerzeichen U+0000 bis U+001F
// sowie U+007F. Die Datei wird von der Edge-Middleware und vom Client
// importiert und darf deshalb weder Node- noch DOM-APIs verwenden.
// biome-ignore lint/suspicious/noControlCharactersInRegex: Die Regel beanstandet diesen Bereich auch als Unicode-Escape; genau dieser Bereich ist der Zweck der Zeichenklasse, sie traegt den Weiterleitungsschutz T-gyd-01.
const FORBIDDEN_CHARS_RE = /[\\\s\u0000-\u001f\u007f]/;
/**
* Erzeugt den `next`-Wert aus Pfad und Query fuer die Login-Umleitung.
* Entfernt `_rsc` (Next.js haengt den Parameter an RSC-Navigationsanfragen
* an, er hat in der Login-URL nichts verloren). Liefert `null` fuer `/`
* und fuer die Anmeldeseite selbst (`/login`, `/login/...`) — dorthin gibt
* es keinen sinnvollen Ruecksprung.
*/
export function buildNextParam(
pathname: string,
search: string,
): string | null {
if (
pathname === '/' ||
pathname === '/login' ||
pathname.startsWith('/login/')
) {
return null;
}
const params = new URLSearchParams(search);
params.delete('_rsc');
const query = params.toString();
return query ? `${pathname}?${query}` : pathname;
}
/**
* Prueft einen aus der URL gelesenen `next`-Wert. Liefert ihn unveraendert
* zurueck, wenn er ein sicherer relativer Pfad ist, sonst `'/'`.
*/
export function sanitizeNextPath(raw: unknown): string {
if (
typeof raw !== 'string' ||
raw.length === 0 ||
raw.length > MAX_NEXT_PATH_LENGTH
) {
return '/';
}
// Erstes Zeichen muss `/` sein (schliesst Schema-Adressen wie
// `https://…`/`javascript:…` automatisch aus); zweites Zeichen darf
// weder `/` noch `\` sein (protokoll-relative Adressen wie `//host` und
// die Backslash-Variante, die Browser ebenfalls als Slash lesen).
if (raw[0] !== '/' || raw[1] === '/' || raw[1] === '\\') {
return '/';
}
if (FORBIDDEN_CHARS_RE.test(raw)) {
return '/';
}
const pathPart = raw.split(/[?#]/, 1)[0];
if (pathPart === '/login' || pathPart.startsWith('/login/')) {
return '/';
}
return raw;
}