feat(security): Haertung nach OWASP Top 10 (#024)

Drei echte Luecken gefunden und geschlossen:

1. Die 27 Server Actions verliessen sich allein auf proxy.ts. Jede prueft die
   Sitzung jetzt selbst und schlaegt gegen die Datenbank nach, ob das Konto
   noch gilt - damit lassen sich Sitzungen ueberhaupt erst widerrufen.
2. Der Anmelde-Endpunkt prueft die Herkunft des Formulars; vorher war
   Login-CSRF moeglich.
3. Content-Security-Policy mit Nonce je Anfrage und strict-dynamic.

Dazu: npm audit ist ohne Befund (mysql2 und deepmerge-ts per overrides
angehoben statt Prisma zurueckzustufen), eine Benutzerverwaltung fuer die
Kommandozeile und docs/sicherheit.md mit allem, was bewusst offen bleibt -
keine Rollen, keine Zwei-Faktor-Anmeldung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 23:45:35 +02:00
co-authored by Claude Opus 5
parent 94f1eb4e6f
commit 7775b6211f
35 changed files with 525 additions and 48 deletions
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";
import { herkunftStimmt } from "@/app/api/auth/anmelden/route";
import type { GeleseneSitzung } from "@/lib/sitzung";
import { sitzungBewerten } from "@/server/auth/sitzungspflicht";
const sitzung = (ausgestelltAm: Date): GeleseneSitzung => ({
benutzerId: "b1",
email: "admin@example.de",
name: "Administrator",
herkunft: "passwort",
ausgestelltAm,
});
const anfrage = (kopfzeilen: Record<string, string>) =>
new Request("https://proxledger.example/api/auth/anmelden", { method: "POST", headers: kopfzeilen });
describe("Sitzungsprüfung", () => {
const jetzt = new Date("2026-09-18T12:00:00Z");
const aktiv = { aktiv: true, sitzungenUngueltigAb: null };
it("lässt eine gültige Sitzung eines aktiven Kontos durch", () => {
expect(sitzungBewerten(sitzung(jetzt), aktiv)).toEqual({ gueltig: true });
});
it("weist ab, wenn gar keine Sitzung da ist", () => {
expect(sitzungBewerten(null, aktiv).gueltig).toBe(false);
});
it("weist ein gesperrtes Konto ab, obwohl das Token noch gilt", () => {
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: false, sitzungenUngueltigAb: null });
expect(urteil).toEqual({ gueltig: false, grund: "Dieses Konto ist gesperrt." });
});
it("weist ein gelöschtes Konto ab", () => {
expect(sitzungBewerten(sitzung(jetzt), null).gueltig).toBe(false);
});
it("beendet Sitzungen, die vor dem Widerruf ausgestellt wurden", () => {
const widerruf = new Date("2026-09-18T13:00:00Z");
const urteil = sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf });
expect(urteil.gueltig).toBe(false);
});
it("lässt Sitzungen gelten, die nach dem Widerruf entstanden sind", () => {
const widerruf = new Date("2026-09-18T11:00:00Z");
expect(sitzungBewerten(sitzung(jetzt), { aktiv: true, sitzungenUngueltigAb: widerruf })).toEqual({
gueltig: true,
});
});
});
describe("Herkunft der Anmeldung", () => {
it("lässt das eigene Formular durch", () => {
expect(herkunftStimmt(anfrage({ origin: "https://proxledger.example", host: "proxledger.example" }))).toBe(true);
});
it("weist eine fremde Seite ab", () => {
expect(herkunftStimmt(anfrage({ origin: "https://boese.example", host: "proxledger.example" }))).toBe(false);
});
it("weist einen unbrauchbaren Origin ab", () => {
expect(herkunftStimmt(anfrage({ origin: "kein-url", host: "proxledger.example" }))).toBe(false);
});
it("lässt Anfragen ohne Origin zu (curl, alte Browser)", () => {
expect(herkunftStimmt(anfrage({ host: "proxledger.example" }))).toBe(true);
});
it("erkennt den Host hinter einem Reverse Proxy", () => {
expect(
herkunftStimmt(anfrage({ origin: "https://dashboard.kunde.de", "x-forwarded-host": "dashboard.kunde.de" })),
).toBe(true);
});
});