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);
|
||
});
|
||
});
|