Fix #38: Sicherheitsprüfung abarbeiten (v1.11.0) #39

Merged
florian.wilde merged 10 commits from fix/38-sicherheitsaudit into main 2026-08-24 10:35:20 +00:00
6 changed files with 153 additions and 23 deletions
Showing only changes of commit 5b54d7bc5f - Show all commits
+17 -4
View File
@@ -156,6 +156,7 @@ const w = n => b(n).replace(/[+/=]/g,'x');
console.log('AUTH_SECRET=' + b(32)); console.log('AUTH_SECRET=' + b(32));
console.log('ENC_KEY_V1=' + b(32)); console.log('ENC_KEY_V1=' + b(32));
console.log('INDEX_KEY=' + b(32)); console.log('INDEX_KEY=' + b(32));
console.log('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
console.log('POSTGRES_PASSWORD=' + w(24)); console.log('POSTGRES_PASSWORD=' + w(24));
console.log('SICHERUNG_PASSWORT=' + w(24)); console.log('SICHERUNG_PASSWORT=' + w(24));
console.log('S3_ACCESS_KEY_ID=' + w(12)); console.log('S3_ACCESS_KEY_ID=' + w(12));
@@ -192,7 +193,15 @@ POSTGRES_PASSWORD=<aus Schritt 2>
# --- Anmeldung --- # --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2> AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
AUTH_TRUST_HOST=true
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen. Absicht:
# sonst steht die Einrichtungsseite bis zum ersten Anlegen fuer jeden offen.
ERSTEINRICHTUNG_GEHEIMNIS=<aus Schritt 2>
# Ein Reverse Proxy steht davor (Schritt 7). Nur damit wird der
# weitergereichte Kopf geglaubt, aus dem die Anmeldesperre den Anschluss
# liest. Ohne die Zeile zaehlt sie nur nach E-Mail-Adresse.
VERTRAUE_PROXY=true
# --- Verschlüsselung --- # --- Verschlüsselung ---
ENC_KEY_V1=<aus Schritt 2> ENC_KEY_V1=<aus Schritt 2>
@@ -258,12 +267,16 @@ chmod 600 .env
```bash ```bash
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte. # Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \ grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ERSTEINRICHTUNG_GEHEIMNIS|VERTRAUE_PROXY|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
| sed 's/=.*/ = gesetzt/' | sed 's/=.*/ = gesetzt/'
``` ```
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht — Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche. und sagt dir beim Start auch, welche. Zwei Ausnahmen: Fehlt
`ERSTEINRICHTUNG_GEHEIMNIS`, startet die Anwendung, aber der erste Zugang
lässt sich nicht anlegen. Fehlt `VERTRAUE_PROXY`, läuft alles — die
Anmeldesperre zählt dann nur nach E-Mail-Adresse und schreibt beim ersten
Mal eine Zeile ins Log.
--- ---
+8
View File
@@ -112,6 +112,14 @@ async function ersterZugang() {
}, },
}) })
// Die Ersteinrichtung ist damit erledigt. Ohne diese Zeile stuende die
// Einrichtungsseite nach dem Seed weiter offen.
await db.ersteinrichtung.upsert({
where: { id: 'einmalig' },
update: {},
create: { id: 'einmalig', userId: benutzer.id },
})
console.log(`Zugang ${email} angelegt.`) console.log(`Zugang ${email} angelegt.`)
if (devSecret) { if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.') console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
+1
View File
@@ -18,6 +18,7 @@ AUTH_SECRET=${b64(32)}
ENC_KEY_V1=${b64(32)} ENC_KEY_V1=${b64(32)}
ENC_KEY_AKTIV=1 ENC_KEY_AKTIV=1
INDEX_KEY=${b64(32)} INDEX_KEY=${b64(32)}
ERSTEINRICHTUNG_GEHEIMNIS=${b64(24)}
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')} POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen. # WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
+9 -1
View File
@@ -21,8 +21,16 @@ export const dynamic = 'force-dynamic'
export default async function ErsteinrichtungSeite() { export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } }) const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// Die Sperrzeile ist die eigentliche Auskunft darueber, ob eingerichtet
// wurde. Der Benutzerzaehler allein genuegt nicht: Liegt der erste Zugang
// im Papierkorb, stuende Schritt 1 sonst wieder offen.
const sperre = await db.ersteinrichtung.findUnique({
where: { id: 'einmalig' },
select: { id: true },
})
// --- Schritt 1: Es gibt noch keinen Zugang ------------------------------- // --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) { if (anzahlBenutzer === 0 && !sperre) {
return ( return (
<> <>
<Card> <Card>
@@ -61,6 +61,23 @@ export function ZugangFormular() {
/> />
</Feld> </Feld>
<Feld
id="geheimnis"
beschriftung="Einrichtungsgeheimnis"
erklaerung="Der Wert von ERSTEINRICHTUNG_GEHEIMNIS aus der .env. Er verhindert, dass jemand
anders diesen Zugang anlegt, solange die Seite noch offensteht."
fehler={zustand.felder?.geheimnis}
pflicht
>
<Input
name="geheimnis"
type="password"
autoComplete="off"
spellCheck={false}
required
/>
</Feld>
<Feld <Feld
id="passwort" id="passwort"
beschriftung="Passwort" beschriftung="Passwort"
+93 -10
View File
@@ -1,25 +1,49 @@
'use server' 'use server'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { createHash, timingSafeEqual } from 'node:crypto'
import { z } from 'zod' import { z } from 'zod'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { env } from '@/lib/env'
import { passwortHashen } from '@/lib/crypto/hash' import { passwortHashen } from '@/lib/crypto/hash'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
/** /**
* Anlegen des allerersten Zugangs. * Anlegen des allerersten Zugangs.
* *
* Diese Aktion ist nur so lange erreichbar, wie es noch keinen Benutzer * Diese Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
* gibt. Danach sperrt sie sich selbst - sonst koennte sich jemand von * Anmeldung etwas schreiben darf. Entsprechend eng ist sie gebaut - zwei
* aussen einen Administratorzugang anlegen. * Dinge sichern sie ab:
*
* ## 1. Ein Geheimnis aus der Umgebung
*
* Vorher galt: "Wenn es noch keinen Benutzer gibt, darf jeder einen
* anlegen." Zwischen dem ersten Start und dem Anlegen des Zugangs stand die
* Seite damit offen - wer sie in diesem Fenster fand, wurde Administrator
* einer Anwendung mit Gesundheits- und Finanzdaten. Das Fenster ist kurz,
* aber Zufall ist keine Zugriffskontrolle. Jetzt muss ERSTEINRICHTUNG_
* GEHEIMNIS aus der .env mit eingegeben werden.
*
* ## 2. Eine Sperre in der Datenbank
*
* Die Pruefung "gibt es schon einen Benutzer?" und das Anlegen waren zwei
* getrennte Schritte. Zwei Anfragen gleichzeitig kamen beide durch die
* Pruefung, bevor eine von ihnen schrieb - und legten beide einen
* Administrator an. Jetzt entscheidet der Primaerschluessel der Tabelle
* ersteinrichtung, und der laesst genau einen Gewinner zu.
*/ */
const PASSWORT_MINDESTLAENGE = 12 const PASSWORT_MINDESTLAENGE = 12
/** Fester Schluessel der einen Zeile, die die Sperre bildet. */
const SPERRE = 'einmalig'
const ZugangSchema = z const ZugangSchema = z
.object({ .object({
name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120), name: z.string().trim().min(2, 'Bitte deinen Namen angeben.').max(120),
email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'), email: z.string().trim().toLowerCase().email('Bitte eine gültige E-Mail-Adresse angeben.'),
geheimnis: z.string().min(1, 'Bitte das Einrichtungsgeheimnis aus der .env eingeben.'),
passwort: z passwort: z
.string() .string()
.min( .min(
@@ -37,22 +61,54 @@ const ZugangSchema = z
export type ZugangZustand = { export type ZugangZustand = {
fehler?: string fehler?: string
felder?: Partial<Record<'name' | 'email' | 'passwort' | 'passwortWiederholung', string>> felder?: Partial<
Record<'name' | 'email' | 'passwort' | 'passwortWiederholung' | 'geheimnis', string>
>
werte?: { name?: string; email?: string } werte?: { name?: string; email?: string }
} }
/**
* Vergleicht zwei Zeichenketten in gleichbleibender Zeit.
*
* Ueber die Laenge wird gehasht, nicht abgeschnitten: timingSafeEqual will
* gleich lange Puffer, und ein vorgeschalteter Laengenvergleich verriete
* die Laenge des Geheimnisses.
*/
function gleich(eingabe: string, erwartet: string): boolean {
const a = createHash('sha256').update(eingabe).digest()
const b = createHash('sha256').update(erwartet).digest()
return timingSafeEqual(a, b)
}
export async function erstenZugangAnlegen( export async function erstenZugangAnlegen(
_vorher: ZugangZustand, _vorher: ZugangZustand,
formular: FormData, formular: FormData,
): Promise<ZugangZustand> { ): Promise<ZugangZustand> {
const vorhandene = await db.user.count() const begonnen = Date.now()
if (vorhandene > 0) {
// Ist die Einrichtung schon gelaufen, ist hier Schluss - unabhaengig
// davon, ob der Benutzer inzwischen im Papierkorb liegt.
const bereitsErledigt = await db.ersteinrichtung.findUnique({
where: { id: SPERRE },
select: { id: true },
})
if (bereitsErledigt || (await db.user.count()) > 0) {
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' } return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
} }
if (env.ersteinrichtungGeheimnis === '') {
return {
fehler:
'Die Ersteinrichtung ist gesperrt, weil ERSTEINRICHTUNG_GEHEIMNIS in der .env fehlt. ' +
'Trage dort einen zufälligen Wert ein („npm run schluessel“ erzeugt passende), starte ' +
'die Anwendung neu und gib ihn hier ein.',
}
}
const gelesen = ZugangSchema.safeParse({ const gelesen = ZugangSchema.safeParse({
name: formular.get('name'), name: formular.get('name'),
email: formular.get('email'), email: formular.get('email'),
geheimnis: formular.get('geheimnis'),
passwort: formular.get('passwort'), passwort: formular.get('passwort'),
passwortWiederholung: formular.get('passwortWiederholung'), passwortWiederholung: formular.get('passwortWiederholung'),
}) })
@@ -64,6 +120,7 @@ export async function erstenZugangAnlegen(
felder: { felder: {
name: felder.name?.[0], name: felder.name?.[0],
email: felder.email?.[0], email: felder.email?.[0],
geheimnis: felder.geheimnis?.[0],
passwort: felder.passwort?.[0], passwort: felder.passwort?.[0],
passwortWiederholung: felder.passwortWiederholung?.[0], passwortWiederholung: felder.passwortWiederholung?.[0],
}, },
@@ -74,9 +131,26 @@ export async function erstenZugangAnlegen(
} }
} }
const { name, email, passwort } = gelesen.data const { name, email, passwort, geheimnis } = gelesen.data
const benutzer = await db.user.create({ if (!gleich(geheimnis, env.ersteinrichtungGeheimnis)) {
// Dieselbe Mindestdauer wie am Anmeldeformular: Sonst verraet die
// Antwortzeit, ob das Geheimnis stimmte und erst das Anlegen scheiterte.
await aufMindestdauerWarten(begonnen)
return {
fehler: 'Das Einrichtungsgeheimnis stimmt nicht.',
felder: { geheimnis: 'Stimmt nicht mit ERSTEINRICHTUNG_GEHEIMNIS überein.' },
werte: { name, email },
}
}
// Anlegen und Sperren in einem Zug. Kommt eine zweite Anfrage gleichzeitig
// hier an, scheitert sie am Primaerschluessel der Sperrzeile - und zwar
// bevor ein zweiter Administrator entstanden ist.
let benutzerId: string
try {
const ergebnis = await db.$transaction(async (tx) => {
const benutzer = await tx.user.create({
data: { data: {
name, name,
email, email,
@@ -85,13 +159,22 @@ export async function erstenZugangAnlegen(
rolle: 'ADMIN', rolle: 'ADMIN',
}, },
}) })
await tx.ersteinrichtung.create({ data: { id: SPERRE, userId: benutzer.id } })
return benutzer
})
benutzerId = ergebnis.id
} catch {
// Der haeufigste Fall ist die verlorene Wettlauf-Anfrage. Für den, der
// davorsitzt, ist die Auskunft dieselbe wie oben.
return { fehler: 'Es gibt bereits einen Zugang. Bitte melde dich an.' }
}
await protokollieren({ await protokollieren({
aktion: 'ERSTELLT', aktion: 'ERSTELLT',
entitaet: 'User', entitaet: 'User',
entitaetId: benutzer.id, entitaetId: benutzerId,
bezeichnung: `${name} (erster Zugang)`, bezeichnung: `${name} (erster Zugang)`,
userId: benutzer.id, userId: benutzerId,
userEmail: email, userEmail: email,
userName: name, userName: name,
}) })