161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
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");
|
||
});
|
||
});
|