import { readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { SignJWT } from "jose"; import { sicheresZiel } from "@/server/auth/anmeldung"; import { istOeffentlich } from "@/proxy"; import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token"; import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung"; /** * Die Zugangswege in die Anwendung (#007). * * Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als * Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des * Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server * Action mit einer Rechtepruefung beginnt. */ const UMGEBUNG = { ...process.env }; afterEach(() => { process.env = { ...UMGEBUNG }; }); describe("Ruecksprungziel gegen Steuerzeichen", () => { // Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim // Verarbeiten einer URL. Aus "//fremd.example" wird dabei // "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe // erkennt das nicht. const angriffe = [ "/\t/fremd.example", "/\n//fremd.example", "/\r/fremd.example", "/\t\t//fremd.example", "//fremd.example", "/\\fremd.example", "/..//fremd.example", "/../..//fremd.example", "https://fremd.example", "//FREMD.example", "javascript:alert(1)", ]; it.each(angriffe)("weist %j ab", (angriff) => { const ergebnis = sicheresZiel(angriff); expect(ergebnis).toBe("/"); }); it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => { for (const angriff of angriffe) { const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example"); expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example"); } }); it("laesst gewoehnliche Ziele unveraendert durch", () => { expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q"); expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2"); }); it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => { // %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad // auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator. const ergebnis = sicheresZiel("/%09/fremd.example"); expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example"); }); }); describe("Allowlist des Proxys", () => { it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => { for (const pfad of [ "/anmelden", "/api/auth/oidc/start", "/api/auth/oidc/rueckruf", "/api/auth/abmelden", "/api/gesundheit", "/manifest.webmanifest", "/icon.svg", ]) { expect(istOeffentlich(pfad), pfad).toBe(true); } }); it("laesst sich nicht durch einen angehaengten Namen austricksen", () => { // Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen. for (const pfad of [ "/anmeldenX", "/anmelden-geheim", "/api/authX", "/api/gesundheitlich", "/admin/benutzer", "/", "/laeufe", "/t/LZ-7K3F9Q", ]) { expect(istOeffentlich(pfad), pfad).toBe(false); } }); }); describe("Trennung von Sitzung und Kurzzeit-Token", () => { it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => { const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60); expect(await sitzungLesen(kurz)).toBeUndefined(); }); it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => { const sitzung = await sitzungErzeugen({ benutzerId: "b1", email: "a@b.example", name: "A", herkunft: "passwort", }); expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined(); }); it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => { const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60); expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined(); }); it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => { // Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das // Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen. const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? ""); const gefaelscht = await new SignJWT({ ziel: "/" }) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setIssuer("laufzettel") .setAudience("oidc-fluss") .setExpirationTime("60s") .sign(roh); expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined(); }); }); describe("Rechtepruefung an den Einstiegspunkten", () => { const verzeichnis = "src/server/actions"; /** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */ const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]); it("beginnt jede Server Action mit einer Rechtepruefung", () => { // Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele, // wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab. const befunde: string[] = []; for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) { const quelle = readFileSync(join(verzeichnis, name), "utf8"); expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true); for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) { const [, funktion, rumpf] = treffer; if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue; if (!rumpf?.includes("rechtErforderlich(")) { befunde.push(`${name}: ${funktion} prueft kein Recht`); } } } expect(befunde).toEqual([]); }); it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => { const aktionen = readdirSync(verzeichnis) .filter((n) => n.endsWith(".ts")) .flatMap((n) => [ ...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g), ]) .map((t) => t[1]); expect(aktionen.length).toBeGreaterThanOrEqual(4); }); });