Anmeldung über einen generischen OIDC-Anbieter (Ziel authentik) mit Discovery, PKCE, state und nonce, dazu ein lokaler Login mit argon2 als Notfallzugang. Die Rechte stehen in einer Tabelle, die Prüfung liegt ausschließlich serverseitig, und Rolle wie Aktiv-Status werden bei jeder Anfrage aus der Datenbank gelesen — eine entzogene Rolle greift damit sofort, ein abgeschaltetes Konto kommt nicht mit gültigem Cookie noch zwölf Stunden weiter. Kein Auth.js: v5 steht dauerhaft auf Beta, das Projekt ist im Wartungsmodus und sein Prisma-Adapter deckt Prisma 7 nicht ab. Stattdessen openid-client und jose, wie in ProxLedger. Die API von openid-client 6 wurde vorher gegen die Typdefinitionen im Paket geprüft, nicht aus dem Gedächtnis geschrieben. Ein unabhängiger Review hat einen Blocker und acht weitere Befunde gefunden, alle behoben. Der wichtigste war eine offene Weiterleitung: die Prüfung des Rücksprungziels verglich Präfixe, und "/<TAB>/fremd.example" besteht alle drei Bedingungen. Browser entfernen Tabulator, Zeilenumbruch und Wagenrücklauf beim Verarbeiten einer URL, übrig bleibt "//fremd.example" und damit ein fremder Host — genau das Phishing, das die Prüfung abwehren sollte. Sie löst das Ziel jetzt gegen eine Wegwerf-Basis auf und vergleicht die Herkunft. Weitere Befunde: jede OIDC-Anmeldung hätte ohne konfigurierte Gruppen die Rolle still auf LESER gesetzt, bei einem einzigen Administrator ohne Rückweg; die Verknüpfung über die E-Mail-Adresse geschah ohne email_verified und wäre der Weg gewesen, den lokalen Notfall-Administrator zu übernehmen; der letzte aktive Administrator konnte sich selbst herabstufen. Drei Fehler waren meine eigenen. Der Platzhalter-Hash gegen den Zeitkanal war erfunden und hätte sofort geworfen statt Zeit zu kosten, die Zusage wäre also falsch gewesen. nextUrl.origin lieferte im Test localhost statt 127.0.0.1 und wäre hinter einem Reverse Proxy der interne Host; alle Umleitungen gehen jetzt über APP_URL. Und overflow-x: hidden auf body hat die eigene Mobil-Prüfung blind gemacht — auf der Benutzerseite war bei 390 px ein Knopf abgeschnitten, ohne dass scrollWidth es zeigen konnte. 274 Tests, darunter die vollständige Rechtematrix, die Angriffe auf das Rücksprungziel, die Trennung von Sitzungs- und Kurzzeit-Token und ein struktureller Nachweis, dass jede Server Action mit einer Rechteprüfung beginnt. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
5.6 KiB
TypeScript
156 lines
5.6 KiB
TypeScript
#!/usr/bin/env tsx
|
||
/**
|
||
* Erzeugt die Screenshots reproduzierbar neu – fuer die Meilenstein-Belege unter
|
||
* docs/screenshots/<meilenstein>/ und fuer die Anleitung unter docs/anleitung/bilder/.
|
||
*
|
||
* Bilder werden nie von Hand nachbearbeitet. Aendert sich die Oberflaeche, laeuft
|
||
* dieses Skript erneut.
|
||
*
|
||
* pnpm docs:screenshots alle Seiten nach docs/anleitung/bilder/
|
||
* pnpm docs:screenshots --ziel m0 nach docs/screenshots/m0/
|
||
* pnpm docs:screenshots --nur uebersicht nur diese Seite
|
||
*
|
||
* Voraussetzung: die App laeuft (pnpm start oder pnpm dev). Adresse ueber
|
||
* E2E_APP_URL, Standard http://127.0.0.1:3000.
|
||
*/
|
||
import { mkdir } from "node:fs/promises";
|
||
import { join } from "node:path";
|
||
import { chromium, type Browser, type Cookie } from "@playwright/test";
|
||
|
||
const APP_URL = process.env["E2E_APP_URL"] ?? "http://127.0.0.1:3000";
|
||
|
||
const ANSICHTEN = [
|
||
{ name: "desktop", breite: 1440, hoehe: 900 },
|
||
{ name: "handy", breite: 390, hoehe: 844 },
|
||
] as const;
|
||
|
||
type Seite = {
|
||
readonly name: string;
|
||
readonly pfad: string;
|
||
/** Oeffentliche Seiten werden ohne Anmeldung aufgenommen. */
|
||
readonly ohneAnmeldung?: boolean;
|
||
};
|
||
|
||
/** Seiten, die abgebildet werden. Waechst mit jedem Meilenstein. */
|
||
const SEITEN: readonly Seite[] = [
|
||
{ name: "anmelden", pfad: "/anmelden", ohneAnmeldung: true },
|
||
{ name: "uebersicht", pfad: "/" },
|
||
{ name: "admin-benutzer", pfad: "/admin/benutzer" },
|
||
];
|
||
|
||
function argument(name: string): string | undefined {
|
||
const index = process.argv.indexOf(`--${name}`);
|
||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||
}
|
||
|
||
async function warteAufApp(): Promise<void> {
|
||
for (let versuch = 1; versuch <= 30; versuch += 1) {
|
||
try {
|
||
const antwort = await fetch(APP_URL, { signal: AbortSignal.timeout(2000) });
|
||
if (antwort.ok) return;
|
||
} catch {
|
||
// noch nicht da
|
||
}
|
||
await new Promise((f) => setTimeout(f, 1000));
|
||
}
|
||
throw new Error(`Die App antwortet nicht auf ${APP_URL}. Erst "pnpm start" laufen lassen.`);
|
||
}
|
||
|
||
/**
|
||
* Meldet sich mit dem Seed-Administrator an und gibt den Sitzungs-Cookie zurueck.
|
||
*
|
||
* Geht ueber das echte Formular statt ueber einen gesetzten Cookie: so faellt
|
||
* sofort auf, wenn die Anmeldung selbst kaputt ist.
|
||
*/
|
||
async function anmelden(browser: Browser): Promise<Cookie[]> {
|
||
const email = process.env["SEED_ADMIN_EMAIL"];
|
||
const passwort = process.env["SEED_ADMIN_PASSWORD"];
|
||
if (!email || !passwort) {
|
||
throw new Error(
|
||
"SEED_ADMIN_EMAIL und SEED_ADMIN_PASSWORD werden gebraucht, um die geschuetzten Seiten " +
|
||
"aufzunehmen. Siehe .env.example.",
|
||
);
|
||
}
|
||
|
||
const kontext = await browser.newContext({ baseURL: APP_URL });
|
||
const tab = await kontext.newPage();
|
||
await tab.goto("/anmelden", { waitUntil: "load" });
|
||
await tab.fill('input[name="email"]', email);
|
||
await tab.fill('input[name="passwort"]', passwort);
|
||
await Promise.all([tab.waitForURL("**/"), tab.click('button[type="submit"]')]);
|
||
|
||
const kekse = await kontext.cookies();
|
||
if (!kekse.some((keks) => keks.name === "lz_sitzung")) {
|
||
throw new Error("Die Anmeldung hat kein Sitzungscookie gesetzt.");
|
||
}
|
||
await kontext.close();
|
||
return kekse;
|
||
}
|
||
|
||
/** Erzeugt die PWA-Kacheln aus dem SVG, damit keine Bilddatei von Hand entsteht. */
|
||
async function kachelnErzeugen(browser: Browser): Promise<void> {
|
||
const svg = new URL(`${APP_URL}/icon-kachel.svg`).toString();
|
||
for (const kante of [192, 512]) {
|
||
const seite = await browser.newPage({ viewport: { width: kante, height: kante } });
|
||
await seite.goto(svg, { waitUntil: "load" });
|
||
await seite.screenshot({ path: join("public", `icon-${kante}.png`), omitBackground: false });
|
||
await seite.close();
|
||
console.log(` public/icon-${kante}.png`);
|
||
}
|
||
}
|
||
|
||
const zielMeilenstein = argument("ziel");
|
||
const nur = argument("nur");
|
||
const ziel = zielMeilenstein
|
||
? join("docs", "screenshots", zielMeilenstein)
|
||
: join("docs", "anleitung", "bilder");
|
||
|
||
await warteAufApp();
|
||
await mkdir(ziel, { recursive: true });
|
||
|
||
const browser = await chromium.launch();
|
||
try {
|
||
await kachelnErzeugen(browser);
|
||
|
||
const auswahl = SEITEN.filter((s) => !nur || s.name === nur);
|
||
const kekse = auswahl.some((s) => !s.ohneAnmeldung) ? await anmelden(browser) : [];
|
||
|
||
for (const seite of auswahl) {
|
||
for (const ansicht of ANSICHTEN) {
|
||
const kontext = await browser.newContext({
|
||
viewport: { width: ansicht.breite, height: ansicht.hoehe },
|
||
colorScheme: "dark",
|
||
locale: "de-DE",
|
||
timezoneId: "Europe/Berlin",
|
||
deviceScaleFactor: 2,
|
||
});
|
||
if (!seite.ohneAnmeldung) await kontext.addCookies(kekse);
|
||
const tab = await kontext.newPage();
|
||
await tab.goto(`${APP_URL}${seite.pfad}`, { waitUntil: "load" });
|
||
// Schriften muessen geladen sein, sonst wackelt das Bild zwischen Laeufen.
|
||
await tab.evaluate(() => document.fonts.ready);
|
||
await tab.waitForLoadState("domcontentloaded");
|
||
|
||
const datei = join(ziel, `${seite.name}-${ansicht.name}.png`);
|
||
await tab.screenshot({ path: datei, fullPage: ansicht.name === "desktop" });
|
||
|
||
// Am Handy darf nie horizontal gescrollt werden – hier gleich mitgeprueft.
|
||
if (ansicht.name === "handy") {
|
||
const masse = await tab.evaluate(() => ({
|
||
scroll: document.documentElement.scrollWidth,
|
||
sichtbar: document.documentElement.clientWidth,
|
||
}));
|
||
if (masse.scroll > masse.sichtbar) {
|
||
throw new Error(`${seite.pfad} scrollt am Handy horizontal: ${masse.scroll} > ${masse.sichtbar}`);
|
||
}
|
||
}
|
||
|
||
console.log(` ${datei}`);
|
||
await kontext.close();
|
||
}
|
||
}
|
||
} finally {
|
||
await browser.close();
|
||
}
|
||
console.log("Screenshots erzeugt.");
|