import { describe, expect, it } from "vitest"; import { NextRequest } from "next/server"; import { istOeffentlich, proxy, sicherheitsrichtlinie } from "@/proxy"; /** * Die Content-Security-Policy (#005, korrigiert in #010). * * Sie war in beiden Betriebsarten gleich streng — und genau deshalb hydrierte * React im Entwicklungsmodus **nie**: `script-src` ohne `unsafe-eval` verbietet * das, was React und Turbopack dort tun. Die Seiten sahen richtig aus, aber kein * Knopf tat etwas, und ein Screenshot zeigte davon nichts. Gefunden erst, als der * Editor aus #010 im Browser bedient werden sollte. * * Diese Tests halten beide Seiten fest: dass die Lockerung im Entwicklungsmodus * da ist, und dass sie im ausgelieferten Stand fehlt. */ const NONCE = "abc123"; function teile(richtlinie: string): Map { return new Map( richtlinie.split("; ").map((eintrag) => { const [name, ...rest] = eintrag.split(" "); return [name ?? "", rest.join(" ")]; }), ); } describe("Ausgelieferter Stand", () => { const richtlinie = sicherheitsrichtlinie(NONCE, false); it("erlaubt kein unsafe-eval – wohl aber WebAssembly", () => { /* * `wasm-unsafe-eval` enthaelt die Zeichenkette „unsafe-eval“, ist aber etwas * ganz anderes: es erlaubt nur das Uebersetzen von WebAssembly, kein `eval` * auf JavaScript. Der Scanner (#014) braucht es, sonst lehnt Chrome den * QR-Leser ab. Geprueft wird deshalb auf die **Marke**, nicht auf das Wort. */ const script = teile(richtlinie).get("script-src") ?? ""; expect(script.split(" ")).not.toContain("'unsafe-eval'"); expect(script.split(" ")).toContain("'wasm-unsafe-eval'"); }); it("erlaubt keine WebSocket-Verbindungen", () => { expect(teile(richtlinie).get("connect-src")).toBe("'self'"); }); it("bindet Skripte an die Nonce dieser Anfrage", () => { expect(teile(richtlinie).get("script-src")).toBe( `'self' 'nonce-${NONCE}' 'strict-dynamic' 'wasm-unsafe-eval'`, ); }); it("verbietet das Einbetten in fremde Seiten und Objekte", () => { const werte = teile(richtlinie); expect(werte.get("frame-ancestors")).toBe("'none'"); expect(werte.get("object-src")).toBe("'none'"); expect(werte.get("base-uri")).toBe("'self'"); expect(werte.get("form-action")).toBe("'self'"); }); it("erlaubt Bilder als data: und blob: – QR-Codes und Kamerabilder", () => { expect(teile(richtlinie).get("img-src")).toBe("'self' data: blob:"); }); }); describe("Entwicklungsmodus", () => { const richtlinie = sicherheitsrichtlinie(NONCE, true); it("erlaubt unsafe-eval, sonst hydriert React nicht", () => { expect(teile(richtlinie).get("script-src")?.split(" ")).toContain("'unsafe-eval'"); }); it("erlaubt die WebSocket-Verbindung von Fast Refresh", () => { expect(teile(richtlinie).get("connect-src")).toBe("'self' ws: wss:"); }); it("lockert sonst nichts", () => { // Namentlich verglichen: was hier gleich bleiben soll, bleibt gleich. const streng = teile(sicherheitsrichtlinie(NONCE, false)); const locker = teile(richtlinie); for (const name of [...streng.keys()]) { if (name === "script-src" || name === "connect-src") continue; expect(locker.get(name), name).toBe(streng.get(name)); } expect([...locker.keys()]).toEqual([...streng.keys()]); }); }); describe("Oeffentliche Pfade", () => { it("laesst Anmeldung, Auth-Rueckruf und Healthcheck ohne Cookie durch", () => { for (const pfad of [ "/anmelden", "/api/auth/oidc/start", "/api/gesundheit", "/manifest.webmanifest", "/icon.svg", ]) { expect(istOeffentlich(pfad), pfad).toBe(true); } }); it("laesst nichts anderes durch", () => { for (const pfad of ["/", "/vorlagen", "/admin", "/api/laeufe/1/zustand", "/anmeldenX"]) { expect(istOeffentlich(pfad), pfad).toBe(false); } }); }); describe("Die Entscheidung im Proxy selbst", () => { /** * Die beiden Tests oben pruefen die Funktion mit einem Argument, das der Test * setzt — genau das hat der Review beanstandet: die eigentliche Entscheidung * `process.env.NODE_ENV !== "production"` blieb ungeprueft. Wer dort `true` * einsetzt, haette gruene Tests und lieferte `unsafe-eval` aus. * * Hier laeuft deshalb `proxy()` selbst, mit gesetztem NODE_ENV. */ function anfrageMitCookie(): NextRequest { const anfrage = new NextRequest(new URL("http://127.0.0.1:3000/vorlagen")); anfrage.cookies.set("lz_sitzung", "egal"); return anfrage; } function mitNodeEnv(wert: string, was: () => T): T { const vorher = process.env.NODE_ENV; // NODE_ENV ist in den Typen schreibgeschuetzt, zur Laufzeit ist es eine // gewoehnliche Variable – und genau die liest der Proxy. const umgebung = process.env as Record; umgebung["NODE_ENV"] = wert; try { return was(); } finally { umgebung["NODE_ENV"] = vorher; } } it("liefert im gebauten Stand keine Lockerung aus", () => { const richtlinie = mitNodeEnv("production", () => proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"), ); expect(richtlinie).toBeTruthy(); // Auf die Marke geprueft, nicht auf das Wort: `wasm-unsafe-eval` enthaelt es // und ist trotzdem keine Lockerung fuer JavaScript (#014). expect(richtlinie).not.toContain(" 'unsafe-eval'"); expect(richtlinie).toContain("'wasm-unsafe-eval'"); expect(richtlinie).toContain("connect-src 'self';"); expect(richtlinie).toContain("'strict-dynamic'"); }); it("lockert im Entwicklungsmodus", () => { const richtlinie = mitNodeEnv("development", () => proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"), ); expect(richtlinie).toContain("'unsafe-eval'"); expect(richtlinie).toContain("ws: wss:"); }); it("gibt jeder Antwort eine eigene Nonce", () => { // Eine wiederverwendete Nonce waere so gut wie keine. const erste = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"); const zweite = proxy(anfrageMitCookie()).headers.get("Content-Security-Policy"); expect(erste).not.toBe(zweite); }); it("schickt ohne Sitzungscookie auf die Anmeldeseite, mit dem Ziel im Gepäck", () => { const antwort = proxy(new NextRequest(new URL("http://127.0.0.1:3000/vorlagen?suche=pc"))); expect(antwort.status).toBe(307); const ziel = new URL(antwort.headers.get("location") ?? ""); expect(ziel.pathname).toBe("/anmelden"); expect(ziel.searchParams.get("ziel")).toBe("/vorlagen?suche=pc"); }); });