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 { OEFFENTLICH, 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", () => { /** * Jeder oeffentliche Pfad, einzeln aufgezaehlt — **und zwar absichtlich von * Hand.** Diese Liste ist die Behauptung „genau das soll ohne Anmeldung * gehen“; aus `OEFFENTLICH` abgeleitet wuerde sie nichts mehr behaupten. * Dass sie vollstaendig ist, prueft der Test darunter. */ const ERWARTET_OEFFENTLICH = [ "/anmelden", "/api/auth", "/api/gesundheit", // Der SCIM-Endpunkt (#029): ein Identitaetsanbieter hat kein Cookie, er // weist sich mit einem Bearer-Token aus, und jeder Handler darunter prueft // es selbst. Ohne diesen Eintrag leitet der Proxy jede Zustellung auf // /anmelden um, und das Provisioning hoert still auf zu arbeiten. "/api/scim", "/manifest.webmanifest", "/icon.svg", ] as const; it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => { for (const pfad of [ ...ERWARTET_OEFFENTLICH, // Und die Unterpfade, auf die es im Betrieb ankommt. "/api/auth/oidc/start", "/api/auth/oidc/rueckruf", "/api/auth/abmelden", "/api/scim/v2/Users", "/api/scim/v2/ServiceProviderConfig", ]) { expect(istOeffentlich(pfad), pfad).toBe(true); } }); it("hat keinen oeffentlichen Pfad, den dieser Test nicht kennt", () => { /* * **Der Test, der gefehlt hat.** `/api/scim` kam mit #029 in die Allowlist * und stand in keiner Pruefung — die Zeile liess sich entfernen, ohne dass * ein einziger Test rot wurde (Reviewbefund). Dabei ist der Eintrag beides: * noetig, damit das Provisioning ueberhaupt ankommt, und eine * Sicherheitsentscheidung, weil darunter jeder Handler sein Token selbst * pruefen **muss**. * * Deshalb hier der Vergleich in die andere Richtung: wer `OEFFENTLICH` * erweitert, muss es oben aufschreiben. Eine Liste, die nur in einer * Richtung geprueft wird, laeuft dem Code hinterher. */ expect([...OEFFENTLICH].sort()).toEqual([...ERWARTET_OEFFENTLICH].sort()); }); 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. // // Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits // `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den // RechteFehler ab und geben eine Meldung zurueck, statt die Seite // auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren. // Die Pruefung findet trotzdem statt, nur eine Ebene tiefer. 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); // Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei. const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)] .filter((treffer) => treffer[2]?.includes("rechtErforderlich(")) .map((treffer) => treffer[1]) .filter((funktion): funktion is string => funktion !== undefined); 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; const prueft = rumpf?.includes("rechtErforderlich(") === true || helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true); if (!prueft) { befunde.push(`${name}: ${funktion} prueft kein Recht`); } } } expect(befunde).toEqual([]); }); it("laesst keine Server Action auf dem Recht der Selbstauskunft stehen", () => { /* * Die Zusage „ein MITARBEITER kann nichts schreiben“ (#035) steht nicht in * einer einzelnen Pruefung, sondern in der **Abwesenheit** einer Aktion mit * diesem Recht. Das ist strukturell: `selbstauskunft` ist ein reines * Leserecht, und sobald eine Server Action es verlangte, haette diese Rolle * einen Schreibweg — ohne dass irgendein anderer Test rot wuerde. * * Deshalb hier und nicht in `tests/rollen.test.ts`: dort geht es um die * Tabelle, hier um das, was mit ihr gebaut wird. */ const befunde: string[] = []; for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) { const quelle = readFileSync(join(verzeichnis, name), "utf8"); if (quelle.includes('rechtErforderlich("selbstauskunft")')) { befunde.push(`${name} verlangt "selbstauskunft" – das ist ein reines Leserecht`); } } expect(befunde).toEqual([]); }); it("erkennt eine Aktion ohne jede Pruefung", () => { // Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er // trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei. const quelle = [ '"use server";', "async function mitPruefung() {", ' await rechtErforderlich("vorlagenBearbeiten");', "}", "", "export async function gut() {", " await mitPruefung();", "}", "", "export async function schlecht() {", " return 42;", "}", ].join("\n"); const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)] .filter((treffer) => treffer[2]?.includes("rechtErforderlich(")) .map((treffer) => treffer[1]); const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)] .filter( ([, , rumpf]) => rumpf?.includes("rechtErforderlich(") !== true && !helfer.some((name) => rumpf?.includes(`${name}(`) === true), ) .map(([, funktion]) => funktion); expect(ohnePruefung).toEqual(["schlecht"]); }); 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); }); });