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:
+7
-4
@@ -14,12 +14,15 @@ BASE_URL=http://localhost:3000
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
|
|
||||||
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
|
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
|
||||||
# Administrator an.
|
# Administrator an. Das ist die Vorgabe, auch wenn die Zeile fehlt.
|
||||||
|
# false — jeder, der die App erreicht, darf alles: Verwaltung, Uploads,
|
||||||
|
# saemtliche Codes. Nur fuer Einzelplatz- oder LAN-Betrieb, und
|
||||||
|
# nur, wenn niemand Fremdes an den Rechner kommt. Der Container
|
||||||
|
# warnt beim Start deutlich, solange das so steht.
|
||||||
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
|
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
|
||||||
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
|
# Wer den Zugang verliert, setzt den Wert auf false und startet neu.
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||||
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||||
|
|||||||
+3
-1
@@ -37,7 +37,9 @@ services:
|
|||||||
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
# Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich
|
||||||
|
# AUTH_ENABLED=false setzt — siehe .env.example.
|
||||||
|
AUTH_ENABLED: ${AUTH_ENABLED:-true}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||||
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||||
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||||
|
|||||||
@@ -5,5 +5,17 @@ set -e
|
|||||||
echo "→ Datenbankmigrationen werden angewendet …"
|
echo "→ Datenbankmigrationen werden angewendet …"
|
||||||
node /app/node_modules/prisma/build/index.js migrate deploy
|
node /app/node_modules/prisma/build/index.js migrate deploy
|
||||||
|
|
||||||
|
# Eine Anwendung ohne Anmeldung sieht genauso aus wie eine mit — deshalb
|
||||||
|
# muss sie es sagen. Wer das hier im Protokoll sieht und nicht erwartet hat,
|
||||||
|
# soll sofort wissen, woran er ist.
|
||||||
|
if [ "$(printf '%s' "${AUTH_ENABLED:-}" | tr '[:upper:]' '[:lower:]')" = "false" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo " !! ACHTUNG: AUTH_ENABLED=false — die Anwendung laeuft OHNE Anmeldung." >&2
|
||||||
|
echo " !! Jeder, der sie erreicht, darf alles: Verwaltung, Uploads, saemtliche" >&2
|
||||||
|
echo " !! Codes anlegen, aendern und loeschen. Das ist nur fuer Einzelplatz-" >&2
|
||||||
|
echo " !! oder LAN-Betrieb gedacht und gehoert nicht ins offene Netz." >&2
|
||||||
|
echo "" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "→ Anwendung wird gestartet …"
|
echo "→ Anwendung wird gestartet …"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+112
-1
@@ -1,7 +1,9 @@
|
|||||||
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
||||||
|
|
||||||
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
||||||
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
|
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026, ergänzt am 24.08.2026
|
||||||
|
um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei
|
||||||
|
offenen Punkte am Ende).
|
||||||
|
|
||||||
Geprüft gegen:
|
Geprüft gegen:
|
||||||
|
|
||||||
@@ -163,6 +165,72 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
|
|||||||
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
||||||
wird beiläufig aufgeräumt.
|
wird beiläufig aufgeräumt.
|
||||||
|
|
||||||
|
### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt
|
||||||
|
|
||||||
|
**Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein
|
||||||
|
lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des
|
||||||
|
Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das
|
||||||
|
prüfte der Code auch, nur war die Bestätigung falsch berechnet:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
emailVerified: payload.email_verified !== false
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender
|
||||||
|
Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der
|
||||||
|
`email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —,
|
||||||
|
hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen
|
||||||
|
kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines
|
||||||
|
Administrators.
|
||||||
|
|
||||||
|
**Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur
|
||||||
|
bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die
|
||||||
|
Verknüpfung unterbleibt.
|
||||||
|
|
||||||
|
**Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das
|
||||||
|
ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und
|
||||||
|
Nonce bestehen.
|
||||||
|
|
||||||
|
### 12. Eine fehlende Angabe schaltete die Anmeldung ab
|
||||||
|
|
||||||
|
**Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß
|
||||||
|
„aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine
|
||||||
|
Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die
|
||||||
|
Compose-Vorgabe stand zusätzlich auf `false`.
|
||||||
|
|
||||||
|
**Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false`
|
||||||
|
setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml`
|
||||||
|
und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der
|
||||||
|
Container beim Start eine mehrzeilige Warnung ins Protokoll.
|
||||||
|
|
||||||
|
Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht
|
||||||
|
und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt.
|
||||||
|
|
||||||
|
### 13. Wettlauf beim ersten Administrator über OIDC
|
||||||
|
|
||||||
|
**Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei
|
||||||
|
gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und
|
||||||
|
beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung
|
||||||
|
sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion.
|
||||||
|
|
||||||
|
**Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer
|
||||||
|
Transaktion mit `isolationLevel: "Serializable"`.
|
||||||
|
|
||||||
|
### 14. Die Herkunftsprüfung verglich das Schema nicht
|
||||||
|
|
||||||
|
**Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt
|
||||||
|
damit als eigen, obwohl die Anwendung unter `https://example.com` läuft.
|
||||||
|
|
||||||
|
**Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der
|
||||||
|
einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt.
|
||||||
|
|
||||||
|
**Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw.
|
||||||
|
`X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht
|
||||||
|
zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst
|
||||||
|
gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen
|
||||||
|
CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das
|
||||||
|
verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Was schon vorher stimmte
|
## Was schon vorher stimmte
|
||||||
@@ -218,6 +286,49 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
|||||||
|
|
||||||
Bewusst nicht umgesetzt, mit Begründung.
|
Bewusst nicht umgesetzt, mit Begründung.
|
||||||
|
|
||||||
|
**Die Kontosperre kann den Besitzer aussperren.** Acht falsche Passwörter
|
||||||
|
sperren ein Konto für 15 Minuten, ohne dass der Anfragende belegen muss, wer
|
||||||
|
er ist. Wer die Adresse des Administrators kennt, kann die Sperre alle 15
|
||||||
|
Minuten erneuern und die Verwaltung praktisch dauerhaft blockieren.
|
||||||
|
|
||||||
|
Der naheliegende Ausweg — auch während der Sperre das Passwort prüfen und ein
|
||||||
|
richtiges durchlassen — wurde geprüft und **verworfen**: Eine Prüfung kostet
|
||||||
|
hier rund **128 MiB Arbeitsspeicher** (scrypt mit N = 2¹⁷). Wer sie beliebig
|
||||||
|
oft auslösen darf, legt damit den Server lahm. Die Sperre gegen ein Konto
|
||||||
|
einzutauschen wäre also eine Sperre gegen alle.
|
||||||
|
|
||||||
|
Was es stattdessen braucht, ist ein eigener Entwurf: eine ansteigende
|
||||||
|
Verzögerung, die keinen Speicher hält, oder eine billige Vorprüfung vor dem
|
||||||
|
teuren Hash — zusammen mit dem nächsten Punkt zu entscheiden.
|
||||||
|
|
||||||
|
**Die Bremsen lassen sich über `X-Forwarded-For` umgehen.** Der Absender wird
|
||||||
|
aus diesem Header gelesen. Steht kein Proxy davor — und die mitgelieferte
|
||||||
|
Compose-Konfiguration veröffentlicht den Port direkt —, wählt ihn jeder
|
||||||
|
Aufrufer selbst und umgeht damit Login-Bremse und Feedback-Bremse. Die Zähler
|
||||||
|
liegen zudem im Arbeitsspeicher eines Prozesses und sind nach einem Neustart
|
||||||
|
weg.
|
||||||
|
|
||||||
|
Die Anwendung läuft in Next.js-Routen und kommt an die Adresse der
|
||||||
|
Gegenstelle gar nicht heran — „im Zweifel die Socket-Adresse nehmen“ ist hier
|
||||||
|
also keine Möglichkeit. Nötig ist eine ausdrückliche Einstellung, welchem
|
||||||
|
Proxy zu trauen ist, und ein gemeinsamer Zähler, wenn mehr als eine Instanz
|
||||||
|
läuft. Beides gehört mit dem Punkt davor in einen Entwurf und nicht in eine
|
||||||
|
schnelle Änderung.
|
||||||
|
|
||||||
|
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
|
||||||
|
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx) in
|
||||||
|
`deepmerge-ts@7.1.5`, mitgeschleppt über `@prisma/config`. Es geht um
|
||||||
|
Stapelüberlauf beim Zusammenführen rekursiver Objekte.
|
||||||
|
|
||||||
|
Aus HTTP-Eingaben entsteht hier keine Prisma-Konfiguration, die praktische
|
||||||
|
Ausnutzbarkeit ist also gering. Die Prisma-CLI liegt aber im Laufzeit-Abbild
|
||||||
|
und läuft bei jedem Containerstart für die Migrationen — deshalb steht der
|
||||||
|
Punkt hier und nicht unter „egal“. Eine Fassung ohne die Advisory gibt es
|
||||||
|
noch nicht: 7.9.1 ist die neueste und hängt weiterhin an `deepmerge-ts@7.1.5`.
|
||||||
|
`npm audit fix --force` würde auf Prisma 6 zurückstufen und ist deshalb keine
|
||||||
|
Lösung. Zu tun: beobachten und aktualisieren, sobald es eine passende Fassung
|
||||||
|
gibt.
|
||||||
|
|
||||||
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||||||
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||||||
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||||||
|
|||||||
@@ -128,9 +128,15 @@ async function findeOderLegeAn(
|
|||||||
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||||
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||||
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||||
const istErster = (await prisma.user.count()) === 0;
|
//
|
||||||
|
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
|
||||||
const angelegt = await prisma.user.create({
|
// 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: {
|
data: {
|
||||||
email: identity.email,
|
email: identity.email,
|
||||||
name: identity.name,
|
name: identity.name,
|
||||||
@@ -143,6 +149,9 @@ async function findeOderLegeAn(
|
|||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
{ isolationLevel: "Serializable" },
|
||||||
|
);
|
||||||
|
|
||||||
return { id: angelegt.id, isActive: angelegt.isActive };
|
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ export type Actor =
|
|||||||
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
|
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
|
||||||
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
|
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
|
||||||
* `AUTH_ENABLED=false` in der .env und startet neu.
|
* `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 {
|
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> {
|
export async function getActor(): Promise<Actor> {
|
||||||
|
|||||||
@@ -326,7 +326,14 @@ export async function exchangeCode(options: {
|
|||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
email,
|
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,
|
name,
|
||||||
groups: readGroups(payload, config.groupsClaim),
|
groups: readGroups(payload, config.groupsClaim),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export function isSameOrigin(headers: Headers): boolean {
|
|||||||
return false;
|
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");
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||||
return Boolean(host) && herkunft.host === host;
|
return Boolean(host) && herkunft.host === host;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user