Files
qrcode-generator/src/lib/http.ts
T
Florian Wilde 1e0d04d0db Befunde der Sicherheitspruefung abarbeiten
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
2026-08-24 01:02:03 +02:00

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.";
}