Files
Laufzettel/tests/sicherheitsrichtlinie.test.ts
T
florian.wilde e3d80e5982
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
2026-09-25 08:23:22 +00:00

161 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> {
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", () => {
expect(richtlinie).not.toContain("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'`);
});
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")).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<T>(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<string, string | undefined>;
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();
expect(richtlinie).not.toContain("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");
});
});