OIDC mit Discovery, PKCE, state und nonce; lokaler Login mit argon2 als Notfallzugang; Rechte in einer Tabelle, Prüfung ausschließlich serverseitig, Rolle und Aktiv-Status pro Anfrage aus der Datenbank. Ein unabhängiger Review fand eine offene Weiterleitung: die Präfix-Prüfung des Rücksprungziels ließ /<TAB>/fremd.example durch, woraus der Browser //fremd.example macht. Das Ziel wird jetzt aufgelöst und die Herkunft verglichen. Dazu acht weitere Befunde, alle behoben. 274 Tests. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
174 lines
6.3 KiB
TypeScript
174 lines
6.3 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.
|
||
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);
|
||
|
||
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;
|
||
if (!rumpf?.includes("rechtErforderlich(")) {
|
||
befunde.push(`${name}: ${funktion} prueft kein Recht`);
|
||
}
|
||
}
|
||
}
|
||
|
||
expect(befunde).toEqual([]);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|