Files
tessera-ctl/apps/web/src/lib/safe-next.ts
T
schalli 4b279eac70 feat(quick-260917-gyd): Ruecksprung nach Anmeldung auf urspruenglich angeforderte Seite
- safe-next.ts: buildNextParam()/sanitizeNextPath() als reine, getestete Funktionen (Open-Redirect-Schutz)
- middleware.ts: haengt next-Parameter an beide Login-Umleitungen (fehlendes Cookie, ungueltige Signatur)
- login/page.tsx: springt nach erfolgreicher Anmeldung auf den bereinigten next-Wert

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:26:14 +02:00

78 lines
2.4 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 sowie die Steuerzeichen 0-31 und 127
// (als Unicode-Escapes, nicht \x, weil safe-next.ts auch am Edge laeuft).
const FORBIDDEN_CHARS_RE = /[\\\s-]/;
/**
* 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;
}