S1 (hoch): Die OIDC-Rueckkehr verknuepft eine Identitaet mit einem bestehenden Konto nur bei bestaetigter E-Mail — geprueft wurde das auch, nur war die Bestaetigung als `email_verified !== false` berechnet. Damit galt auch ein fehlender Claim als bestaetigt, und genau den schicken manche Anbieter gar nicht. Jetzt zaehlt allein ein ausdrueckliches `true`. S2 (hoch): `isAuthEnabled()` verlangte den Wert "true"; alles andere hiess "aus". Eine vergessene Variable ergab eine Anwendung, in der jeder Besucher alles darf, und nichts sah danach aus. Umgedreht: Abgeschaltet wird nur, wer ausdruecklich "false" setzt. Die Vorgaben in Compose und .env.example stehen auf true, und der Container warnt beim Start mehrzeilig, solange die Anmeldung aus ist. S5 (mittel): Der OIDC-Bootstrap las `user.count()` und legte danach an — zwei gleichzeitige erste Anmeldungen konnten beide Administrator werden. Jetzt in einer serialisierbaren Transaktion, wie die lokale Ersteinrichtung es laengst tut. S6 (niedrig): Die Herkunftspruefung verglich nur den Host, nicht das Schema. Das Schema kommt jetzt aus BASE_URL — die eine Angabe zum Ursprung, ueber die der Aufrufer nicht mitbestimmt. S3, S4 und S7 bleiben offen und stehen begruendet in docs/sicherheit.md. Bei S3 wurde der naheliegende Ausweg geprueft und verworfen: Waehrend der Sperre weiter Passwoerter zu pruefen kostet 128 MiB je Versuch und tauschte eine Sperre gegen ein Konto gegen eine gegen alle. Refs #20
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { QrRequestError } from "@/lib/qr/type-module";
|
|
|
|
/**
|
|
* Kommt diese Anfrage von der eigenen Oberfläche?
|
|
*
|
|
* Zweite Verteidigungslinie neben `SameSite=Lax` am Sitzungs-Cookie. Das
|
|
* OWASP-Merkblatt zu CSRF sagt ausdrücklich, dass `SameSite` allein nicht
|
|
* genügt — es wirkt auf der Ebene der registrierbaren Domain, nicht des
|
|
* Ursprungs.
|
|
*
|
|
* Verglichen wird gegen den Host der Anfrage, nicht gegen `BASE_URL`: Die
|
|
* Anwendung ist absichtlich unter mehreren Domains erreichbar.
|
|
*
|
|
* Fehlt der `Origin`-Header, gilt die Anfrage als eigen: Browser setzen ihn
|
|
* bei fremden Zugriffen immer, Skripte und Kommandozeilenwerkzeuge oft gar
|
|
* nicht. Ein Abweisen träfe nur die Falschen.
|
|
*/
|
|
export function isSameOrigin(headers: Headers): boolean {
|
|
const origin = headers.get("origin");
|
|
if (!origin) return true;
|
|
|
|
let herkunft: URL;
|
|
try {
|
|
herkunft = new URL(origin);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
// Das Schema kommt aus `BASE_URL`, nicht aus der Anfrage: Es ist die eine
|
|
// Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. Ohne diesen
|
|
// Vergleich gälte `http://example.com` als eigen, obwohl die Anwendung nur
|
|
// unter `https://example.com` läuft.
|
|
const erwartetesSchema = (process.env.BASE_URL ?? "").startsWith("http://")
|
|
? "http:"
|
|
: "https:";
|
|
if (herkunft.protocol !== erwartetesSchema) return false;
|
|
|
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
|
return Boolean(host) && herkunft.host === host;
|
|
}
|
|
|
|
export function assertSameOrigin(headers: Headers): void {
|
|
if (!isSameOrigin(headers)) {
|
|
throw new QrRequestError(
|
|
"Diese Anfrage kam von einer fremden Seite und wurde abgewiesen.",
|
|
403,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function jsonError(message: string, status = 400): Response {
|
|
return Response.json({ error: message }, { status });
|
|
}
|
|
|
|
/**
|
|
* Erkennt Fehler, deren Meldung an den Aufrufer gehen darf.
|
|
*
|
|
* Bewusst über eine Eigenschaft statt über `instanceof`: die Fehlerklassen
|
|
* stehen in einem Modul, das sowohl im Server- als auch im Client-Bündel
|
|
* landet. Dann existieren zwei Klassenidentitäten, und ein
|
|
* `instanceof`-Vergleich würde eine saubere 400er-Antwort stillschweigend zu
|
|
* einem 500er machen.
|
|
*/
|
|
function asUserFacing(
|
|
error: unknown,
|
|
): { message: string; status: number } | null {
|
|
if (
|
|
typeof error !== "object" ||
|
|
error === null ||
|
|
(error as { userFacing?: unknown }).userFacing !== true
|
|
) {
|
|
return null;
|
|
}
|
|
const message = (error as { message?: unknown }).message;
|
|
if (typeof message !== "string") return null;
|
|
|
|
const status = (error as { status?: unknown }).status;
|
|
return { message, status: typeof status === "number" ? status : 400 };
|
|
}
|
|
|
|
/**
|
|
* Übersetzt eine Ausnahme in eine Antwort mit deutscher Fehlermeldung.
|
|
* Unerwartete Fehler werden protokolliert, nach außen aber generisch
|
|
* gemeldet.
|
|
*/
|
|
export function errorResponse(error: unknown): Response {
|
|
const userFacing = asUserFacing(error);
|
|
if (userFacing) {
|
|
return jsonError(userFacing.message, userFacing.status);
|
|
}
|
|
|
|
console.error("Unerwarteter Fehler in einer API-Route:", error);
|
|
return jsonError(
|
|
"Es ist ein unerwarteter Fehler aufgetreten. Bitte versuche es erneut.",
|
|
500,
|
|
);
|
|
}
|
|
|
|
/** Erste Fehlermeldung eines fehlgeschlagenen Zod-Parsers. */
|
|
export function firstZodMessage(error: {
|
|
issues: ReadonlyArray<{ message: string }>;
|
|
}): string {
|
|
return error.issues[0]?.message ?? "Die Anfrage ist ungültig.";
|
|
}
|