Der Einstieg, fuer den die Anwendung gebaut ist: scannen, sehen was zu tun ist,
loslegen. `/t/<tagCode>` kennt drei Faelle — registriertes Geraet, vorgedruckter
Tag, unbekannter Code —, die Geraeteseite zeigt Zuweisungen, Schnellstart und
Historie, und beides ist am Handy bedienbar.
Im QR steht ausschliesslich `{APP_URL}/t/{tagCode}`: kein Name, keine
Seriennummer, kein Besitzer. Ein Aufkleber haengt jahrelang sichtbar am Geraet
und wird von jedem Telefon gelesen; was darauf steht, ist veroeffentlicht. Die
Masse sind gemessen, nicht behauptet: ein echtes PDF liefert 62 x 29 mm und A4,
und ein bei 300 dpi gerastertes Etikett wird mit demselben Decoder
zurueckgelesen, den auch der Scanner benutzt.
Drei Defekte zeigte erst der gebaute Stand, alle an den Naehten zwischen den
parallel gebauten Teilen: „Starten“ setzte IN_ARBEIT und sprang nicht, „Vorlage
zuweisen“ bot Vorlagen an, die der Dienst danach ablehnte, und die Geraeteliste
verlinkte nichts. Der unabhaengige Review fand drei Blocker — darunter, dass der
Scanner nie die native BarcodeDetector-API benutzte und dass ein Unterpfad in
APP_URL aus dem gedruckten QR herausfiel.
Closes #14
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
6.5 KiB
TypeScript
174 lines
6.5 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 – 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<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();
|
||
// 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");
|
||
});
|
||
});
|