Der Redakteur baut eine Vorlage jetzt vollstaendig in der Oberflaeche: Liste mit Suche und Filtern, Editor mit Abschnitten, Schritten und Variablen, Vorschau mit Beispielwerten, Veroeffentlichen mit Aenderungsnotiz, Versionsgeschichte. Sortiert wird mit dem Finger, der Maus, der Tastatur **und** zwei Knoepfen an jeder Zeile — ein Touch-Drag gelingt auf einem 390-px-Geraet nicht jedem, und die Reihenfolge ist fachlich zu wichtig fuer nur einen Weg. Schritte, Variablen und Abschnitte klappen zu: dieselbe Vorlage misst am Handy 3 482 statt 16 986 px. - src/lib/platzhalter.ts als einzige Quelle der eingebauten Platzhalter; der Editor warnt vor unbekannten, blockiert aber nichts - Hilfetexte als Markdown ohne rohes HTML, Bilder als Alternativtext - Erste Playwright-Tests: der Editor wird durch die Oberflaeche gefahren Wichtigster Befund des Meilensteins: `next dev` hydriert in dieser Umgebung ueberhaupt nicht — die Seiten sehen richtig aus, aber kein Knopf tut etwas (2 von 1302 Elementen tragen React-Knoten, im gebauten Stand 1281 von 1296). Nicht an diesem Code: auch ohne proxy.ts und ohne output: standalone. Ein Screenshot beweist damit nichts ueber Bedienbarkeit. Oberflaechen werden ab sofort gegen `pnpm build && pnpm start` abgenommen, und was nur mit angehaengtem React gelingt, gehoert in einen Playwright-Test. Der unabhaengige Review fand zwei Blocker: eine abgelaufene Anmeldung haette im Editor jede ungespeicherte Arbeit vernichtet (der Rechtefehler lief bis in Nexts Fehlergrenze), und das Speichern der Kopfdaten loeste eine archivierte Kundenzuordnung stillschweigend auf. Dazu sechs weitere Befunde, alle behoben. 496 Tests gruen (vorher 428), dazu 5 E2E-Tests. Schliesst #010 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
221 lines
8.2 KiB
TypeScript
221 lines
8.2 KiB
TypeScript
import { readFileSync, readdirSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { afterEach, describe, expect, it } from "vitest";
|
||
import { SignJWT } from "jose";
|
||
import { sicheresZiel } from "@/server/auth/anmeldung";
|
||
import { istOeffentlich } from "@/proxy";
|
||
import { kurzzeitTokenErzeugen, kurzzeitTokenLesen } from "@/lib/kurzzeit-token";
|
||
import { sitzungErzeugen, sitzungLesen } from "@/lib/sitzung";
|
||
|
||
/**
|
||
* Die Zugangswege in die Anwendung (#007).
|
||
*
|
||
* Diese Datei haelt die Zusicherungen fest, die ein unabhaengiger Review als
|
||
* Luecken benannt hat: das Ruecksprungziel gegen Steuerzeichen, die Allowlist des
|
||
* Proxys, die Trennung der beiden Token-Arten und dass wirklich jede Server
|
||
* Action mit einer Rechtepruefung beginnt.
|
||
*/
|
||
|
||
const UMGEBUNG = { ...process.env };
|
||
|
||
afterEach(() => {
|
||
process.env = { ...UMGEBUNG };
|
||
});
|
||
|
||
describe("Ruecksprungziel gegen Steuerzeichen", () => {
|
||
// Browser entfernen Tabulator, Zeilenumbruch und Wagenruecklauf beim
|
||
// Verarbeiten einer URL. Aus "/<TAB>/fremd.example" wird dabei
|
||
// "//fremd.example" – also ein fremder Host. Eine Pruefung auf Praefixe
|
||
// erkennt das nicht.
|
||
const angriffe = [
|
||
"/\t/fremd.example",
|
||
"/\n//fremd.example",
|
||
"/\r/fremd.example",
|
||
"/\t\t//fremd.example",
|
||
"//fremd.example",
|
||
"/\\fremd.example",
|
||
"/..//fremd.example",
|
||
"/../..//fremd.example",
|
||
"https://fremd.example",
|
||
"//FREMD.example",
|
||
"javascript:alert(1)",
|
||
];
|
||
|
||
it.each(angriffe)("weist %j ab", (angriff) => {
|
||
const ergebnis = sicheresZiel(angriff);
|
||
expect(ergebnis).toBe("/");
|
||
});
|
||
|
||
it("gibt nie etwas zurueck, das als Adresse einen fremden Host ergaebe", () => {
|
||
for (const angriff of angriffe) {
|
||
const aufgeloest = new URL(sicheresZiel(angriff), "http://laufzettel.example");
|
||
expect(aufgeloest.origin, `bei ${JSON.stringify(angriff)}`).toBe("http://laufzettel.example");
|
||
}
|
||
});
|
||
|
||
it("laesst gewoehnliche Ziele unveraendert durch", () => {
|
||
expect(sicheresZiel("/t/LZ-7K3F9Q")).toBe("/t/LZ-7K3F9Q");
|
||
expect(sicheresZiel("/laeufe?filter=offen#abschnitt-2")).toBe("/laeufe?filter=offen#abschnitt-2");
|
||
});
|
||
|
||
it("laesst prozentkodierte Steuerzeichen als Pfad stehen, ohne den Host zu wechseln", () => {
|
||
// %09 wird beim Aufloesen nicht dekodiert, bleibt also ein eigenartiger Pfad
|
||
// auf der eigenen Herkunft – harmlos, im Gegensatz zum echten Tabulator.
|
||
const ergebnis = sicheresZiel("/%09/fremd.example");
|
||
expect(new URL(ergebnis, "http://laufzettel.example").origin).toBe("http://laufzettel.example");
|
||
});
|
||
});
|
||
|
||
describe("Allowlist des Proxys", () => {
|
||
it("laesst genau die Pfade durch, die ohne Anmeldung gehen muessen", () => {
|
||
for (const pfad of [
|
||
"/anmelden",
|
||
"/api/auth/oidc/start",
|
||
"/api/auth/oidc/rueckruf",
|
||
"/api/auth/abmelden",
|
||
"/api/gesundheit",
|
||
"/manifest.webmanifest",
|
||
"/icon.svg",
|
||
]) {
|
||
expect(istOeffentlich(pfad), pfad).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("laesst sich nicht durch einen angehaengten Namen austricksen", () => {
|
||
// Ein reiner startsWith-Vergleich haette /anmeldenX durchgelassen.
|
||
for (const pfad of [
|
||
"/anmeldenX",
|
||
"/anmelden-geheim",
|
||
"/api/authX",
|
||
"/api/gesundheitlich",
|
||
"/admin/benutzer",
|
||
"/",
|
||
"/laeufe",
|
||
"/t/LZ-7K3F9Q",
|
||
]) {
|
||
expect(istOeffentlich(pfad), pfad).toBe(false);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Trennung von Sitzung und Kurzzeit-Token", () => {
|
||
it("laesst ein Kurzzeit-Token nicht als Sitzung durchgehen", async () => {
|
||
const kurz = await kurzzeitTokenErzeugen("oidc-fluss", { ziel: "/" }, 60);
|
||
expect(await sitzungLesen(kurz)).toBeUndefined();
|
||
});
|
||
|
||
it("laesst eine Sitzung nicht als Kurzzeit-Token durchgehen", async () => {
|
||
const sitzung = await sitzungErzeugen({
|
||
benutzerId: "b1",
|
||
email: "a@b.example",
|
||
name: "A",
|
||
herkunft: "passwort",
|
||
});
|
||
expect(await kurzzeitTokenLesen("oidc-fluss", sitzung)).toBeUndefined();
|
||
});
|
||
|
||
it("laesst ein Kurzzeit-Token fuer einen anderen Zweck nicht durchgehen", async () => {
|
||
const kurz = await kurzzeitTokenErzeugen("ganz-anderer-zweck", { ziel: "/" }, 60);
|
||
expect(await kurzzeitTokenLesen("oidc-fluss", kurz)).toBeUndefined();
|
||
});
|
||
|
||
it("weist ein Token ab, das mit dem rohen APP_SECRET signiert wurde", async () => {
|
||
// Das Kurzzeit-Modul leitet seinen Schluessel per scrypt ab. Wer nur das
|
||
// Geheimnis kennt, kann damit kein gueltiges Fluss-Token bauen.
|
||
const roh = new TextEncoder().encode(process.env["APP_SECRET"] ?? "");
|
||
const gefaelscht = await new SignJWT({ ziel: "/" })
|
||
.setProtectedHeader({ alg: "HS256" })
|
||
.setIssuedAt()
|
||
.setIssuer("laufzettel")
|
||
.setAudience("oidc-fluss")
|
||
.setExpirationTime("60s")
|
||
.sign(roh);
|
||
expect(await kurzzeitTokenLesen("oidc-fluss", gefaelscht)).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
describe("Rechtepruefung an den Einstiegspunkten", () => {
|
||
const verzeichnis = "src/server/actions";
|
||
|
||
/** Aktionen, die absichtlich ohne Anmeldung erreichbar sind. */
|
||
const OEFFENTLICHE_AKTIONEN = new Set(["anmeldenAktion"]);
|
||
|
||
it("beginnt jede Server Action mit einer Rechtepruefung", () => {
|
||
// Strukturell geprueft, weil eine vergessene Pruefung sonst erst auffiele,
|
||
// wenn jemand sie ausnutzt. Die Rechtematrix selbst deckt tests/rollen.test.ts ab.
|
||
//
|
||
// Anerkannt wird auch ein Helfer **in derselben Datei**, der seinerseits
|
||
// `rechtErforderlich` ruft: seit #010 fangen die Vorlagen-Aktionen den
|
||
// RechteFehler ab und geben eine Meldung zurueck, statt die Seite
|
||
// auszuhaengen — im Editor waere sonst jede ungespeicherte Arbeit verloren.
|
||
// Die Pruefung findet trotzdem statt, nur eine Ebene tiefer.
|
||
const befunde: string[] = [];
|
||
|
||
for (const name of readdirSync(verzeichnis).filter((n) => n.endsWith(".ts"))) {
|
||
const quelle = readFileSync(join(verzeichnis, name), "utf8");
|
||
expect(quelle.startsWith('"use server";'), `${name} fehlt "use server"`).toBe(true);
|
||
|
||
// Helfer, die selbst pruefen – Name und Rumpf aus derselben Datei.
|
||
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
|
||
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
|
||
.map((treffer) => treffer[1])
|
||
.filter((funktion): funktion is string => funktion !== undefined);
|
||
|
||
for (const treffer of quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)) {
|
||
const [, funktion, rumpf] = treffer;
|
||
if (!funktion || OEFFENTLICHE_AKTIONEN.has(funktion)) continue;
|
||
const prueft =
|
||
rumpf?.includes("rechtErforderlich(") === true ||
|
||
helfer.some((helfername) => rumpf?.includes(`${helfername}(`) === true);
|
||
if (!prueft) {
|
||
befunde.push(`${name}: ${funktion} prueft kein Recht`);
|
||
}
|
||
}
|
||
}
|
||
|
||
expect(befunde).toEqual([]);
|
||
});
|
||
|
||
it("erkennt eine Aktion ohne jede Pruefung", () => {
|
||
// Der Test oben ist nachsichtiger geworden; dieser hier zeigt, dass er
|
||
// trotzdem noch anschlaegt. Geprueft wird die Regel, nicht die Datei.
|
||
const quelle = [
|
||
'"use server";',
|
||
"async function mitPruefung() {",
|
||
' await rechtErforderlich("vorlagenBearbeiten");',
|
||
"}",
|
||
"",
|
||
"export async function gut() {",
|
||
" await mitPruefung();",
|
||
"}",
|
||
"",
|
||
"export async function schlecht() {",
|
||
" return 42;",
|
||
"}",
|
||
].join("\n");
|
||
|
||
const helfer = [...quelle.matchAll(/(?:async )?function (\w+)\(([\s\S]*?)\n\}/g)]
|
||
.filter((treffer) => treffer[2]?.includes("rechtErforderlich("))
|
||
.map((treffer) => treffer[1]);
|
||
const ohnePruefung = [...quelle.matchAll(/export async function (\w+)\(([\s\S]*?)\n\}/g)]
|
||
.filter(
|
||
([, , rumpf]) =>
|
||
rumpf?.includes("rechtErforderlich(") !== true &&
|
||
!helfer.some((name) => rumpf?.includes(`${name}(`) === true),
|
||
)
|
||
.map(([, funktion]) => funktion);
|
||
|
||
expect(ohnePruefung).toEqual(["schlecht"]);
|
||
});
|
||
|
||
it("findet ueberhaupt Aktionen, sonst prueft der Test oben nichts", () => {
|
||
const aktionen = readdirSync(verzeichnis)
|
||
.filter((n) => n.endsWith(".ts"))
|
||
.flatMap((n) => [
|
||
...readFileSync(join(verzeichnis, n), "utf8").matchAll(/export async function (\w+)/g),
|
||
])
|
||
.map((t) => t[1]);
|
||
expect(aktionen.length).toBeGreaterThanOrEqual(4);
|
||
});
|
||
});
|