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
This commit is contained in:
Florian Wilde
2026-08-24 01:02:03 +02:00
parent 4e2d222e86
commit 1e0d04d0db
8 changed files with 180 additions and 22 deletions
+23 -14
View File
@@ -128,21 +128,30 @@ async function findeOderLegeAn(
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
const istErster = (await prisma.user.count()) === 0;
const angelegt = await prisma.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
//
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
// jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung
// dazwischenkommen, und beide würden sich für die erste halten. Am Ende
// stünden zwei Administratoren da, von denen einer ungebeten ist.
const angelegt = await prisma.$transaction(
async (tx) => {
const istErster = (await tx.user.count()) === 0;
return tx.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: null,
oidcSubject: identity.subject,
isInstanceAdmin: istAdmin ?? istErster,
lastLoginAt: new Date(),
memberships: standardgruppe
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
: undefined,
},
});
},
});
{ isolationLevel: "Serializable" },
);
return { id: angelegt.id, isActive: angelegt.isActive };
}
+6 -1
View File
@@ -23,9 +23,14 @@ export type Actor =
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
* `AUTH_ENABLED=false` in der .env und startet neu.
*
* **Abgeschaltet wird nur auf ausdrückliche Ansage.** Ein fehlender oder
* leerer Wert bedeutet „an“. Vorher war es umgekehrt: Wer die Variable
* vergaß, bekam eine Anwendung, in der jeder Besucher alles darf — und
* merkte es nicht, weil nichts danach aussah.
*/
export function isAuthEnabled(): boolean {
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() !== "false";
}
export async function getActor(): Promise<Actor> {
+8 -1
View File
@@ -326,7 +326,14 @@ export async function exchangeCode(options: {
return {
subject,
email,
emailVerified: payload.email_verified !== false,
// Nur ein ausdrückliches `true` zählt. Ein fehlender Claim, `null`, `0`
// oder die Zeichenkette „true" gelten als **nicht** bestätigt.
//
// Vorher stand hier `!== false`, und damit war das Gegenteil der Fall:
// Ein Anbieter, der den Claim gar nicht schickt, hätte die Verknüpfung
// mit einem bestehenden Konto erlaubt — genau das, was die Prüfung in
// der Callback-Route verhindern soll.
emailVerified: payload.email_verified === true,
name,
groups: readGroups(payload, config.groupsClaim),
};
+9
View File
@@ -26,6 +26,15 @@ export function isSameOrigin(headers: Headers): boolean {
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;
}