Fix #20: Befunde der Sicherheitsprüfung #21
@@ -24,6 +24,17 @@ APP_PORT=3000
|
||||
# Wer den Zugang verliert, setzt den Wert auf false und startet neu.
|
||||
AUTH_ENABLED=true
|
||||
|
||||
# Steht ein Reverse Proxy davor, der `X-Forwarded-For` selbst setzt?
|
||||
# false — Vorgabe. Die Kopfzeile wird nicht gelesen. Ohne Proxy koennte
|
||||
# sie jeder Aufrufer selbst waehlen; eine Bremse, die sich so
|
||||
# umgehen laesst, waere eine Behauptung.
|
||||
# true — Nur setzen, wenn wirklich ein Proxy davorsteht und die Kopfzeile
|
||||
# ueberschreibt. Sonst schenkst du jedem Aufrufer beliebig viele
|
||||
# Anmeldeversuche.
|
||||
# Unabhaengig davon gelten immer: die Wartezeit je Konto und die Obergrenze
|
||||
# fuer die ganze Installation.
|
||||
TRUST_PROXY=false
|
||||
|
||||
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||
# grosse Werte machen die Sicherung entsprechend schwer.
|
||||
|
||||
@@ -291,7 +291,7 @@ sie eintragen.
|
||||
| --- | --- | --- |
|
||||
| `X-Forwarded-Proto` | erkennt, dass die Verbindung über HTTPS läuft | Der Sitzungs-Cookie wird ohne `Secure` gesetzt und ginge auch über eine unverschlüsselte Verbindung mit |
|
||||
| `X-Forwarded-Host` | die aufgerufene Domain — für mehrere Domains und für die Anmeldung | Kurz-URLs unter Zweitdomains werden nicht zugeordnet |
|
||||
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Alle Aufrufe zählen als derselbe Absender |
|
||||
| `X-Forwarded-For` | Absender für die Bremse gegen Durchprobieren | Die Bremse je Absender entfällt; Wartezeit je Konto und Obergrenze der Instanz greifen weiter |
|
||||
|
||||
Für ein reines nginx (ohne NPM) heißt das:
|
||||
|
||||
@@ -305,6 +305,14 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
|
||||
von sich aus.
|
||||
|
||||
> **`TRUST_PROXY=true` erst setzen, wenn der Proxy wirklich steht.** Die
|
||||
> Anwendung liest `X-Forwarded-For` nur mit dieser Angabe. Ohne Proxy könnte
|
||||
> die Kopfzeile jeder Aufrufer selbst wählen und sich damit beliebig viele
|
||||
> Anmeldeversuche verschaffen — eine Bremse, die sich so umgehen lässt, wäre
|
||||
> eine Behauptung. Zwei Bremsen wirken unabhängig davon immer: die Wartezeit
|
||||
> je Konto und die Obergrenze für die ganze Installation. Näheres in
|
||||
> [docs/sicherheit.md](docs/sicherheit.md).
|
||||
|
||||
### Größe der Uploads
|
||||
|
||||
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
||||
|
||||
@@ -40,6 +40,9 @@ services:
|
||||
# Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich
|
||||
# AUTH_ENABLED=false setzt — siehe .env.example.
|
||||
AUTH_ENABLED: ${AUTH_ENABLED:-true}
|
||||
# Nur einschalten, wenn wirklich ein Proxy davorsteht — siehe
|
||||
# .env.example.
|
||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||
|
||||
+107
-27
@@ -231,6 +231,105 @@ 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.
|
||||
|
||||
### 15. Die Kontosperre konnte den Besitzer aussperren
|
||||
|
||||
**Befund:** Acht Fehlversuche sperrten ein Konto für 15 Minuten — auch gegen
|
||||
das **richtige** Passwort. Wer die Adresse eines Administrators kannte,
|
||||
konnte die Sperre alle 15 Minuten erneuern und die Verwaltung praktisch
|
||||
dauerhaft schließen. Ein Nachweis, dass der Anfragende der Kontoinhaber ist,
|
||||
war dafür nicht nötig.
|
||||
|
||||
**Behoben:** Aus der Sperre wurde eine **Wartezeit**. Die ersten vier
|
||||
Fehlversuche bleiben folgenlos, danach verdoppelt sie sich bis zu einer
|
||||
Minute:
|
||||
|
||||
| Fehlversuche | Wartezeit |
|
||||
| --- | --- |
|
||||
| bis 4 | keine |
|
||||
| 5 | 1 s |
|
||||
| 7 | 4 s |
|
||||
| 10 | 32 s |
|
||||
| ab 11 | 60 s |
|
||||
|
||||
Für den Besitzer heißt das im schlimmsten Fall eine Minute statt einer
|
||||
Viertelstunde Aussperrung. Für jemanden, der Passwörter durchprobiert, einen
|
||||
Versuch pro Minute — das ist die eigentliche Aufgabe der Bremse, und die
|
||||
erfüllt sie weiterhin.
|
||||
|
||||
Die Wartezeit wird **vor** der Passwortprüfung ausgewertet. Ein Versuch, der
|
||||
ohnehin abgewiesen wird, kostet damit keine 128 MiB.
|
||||
|
||||
### 16. Die Bremsen ließen sich über eine Kopfzeile umgehen
|
||||
|
||||
**Befund:** Der Absender wurde aus `X-Forwarded-For` gelesen. Ohne
|
||||
vorgeschalteten Proxy — und die mitgelieferte Compose-Konfiguration
|
||||
veröffentlicht den Port direkt — wählt ihn jeder Aufrufer selbst und umgeht
|
||||
damit Login- wie Feedback-Bremse.
|
||||
|
||||
**Behoben:** Drei Stufen statt einer, in `src/lib/auth/throttle.ts`:
|
||||
|
||||
1. **Je Absender** — nur noch, wenn `TRUST_PROXY=true` gesetzt ist. Ohne
|
||||
diese Angabe wird die Kopfzeile gar nicht gelesen. Eine Bremse, die sich
|
||||
mit einem selbst gewählten Wert umgehen lässt, ist keine; dann lieber
|
||||
ehrlich keine.
|
||||
2. **Je Konto** — die Wartezeit aus Punkt 15. Sie hängt an der Kennung des
|
||||
Kontos, nicht an einer Kopfzeile, und ist deshalb nicht zu umgehen.
|
||||
3. **Für die ganze Installation** — eine Obergrenze von 60 Passwortprüfungen
|
||||
je Minute, und 120 Rückmeldungen je Minute. Das ist die Stufe, die den
|
||||
Speicher schützt: Ohne sie könnte jemand mit genügend gleichzeitigen
|
||||
Anfragen den Server allein über die Hash-Berechnung lahmlegen.
|
||||
|
||||
Was bleibt: Die Zähler liegen im Arbeitsspeicher eines Prozesses. Bei
|
||||
mehreren Instanzen bräuchte es einen gemeinsamen Zähler — siehe „Offen“.
|
||||
|
||||
### 17. Skripte liefen ohne Content-Security-Policy
|
||||
|
||||
**Befund:** Die Policy bestand aus `frame-ancestors 'none'`. Gegen
|
||||
eingeschleusten Skriptcode wirkte sie nicht.
|
||||
|
||||
**Behoben:** Eine vollständige Policy mit **Nonce je Anfrage**. Skripte
|
||||
laufen nur, wenn sie den Nonce dieser einen Anfrage tragen oder von einem
|
||||
Skript geladen wurden, das selbst einen hatte (`strict-dynamic`). Ein
|
||||
Skript, das über eine Lücke in die Seite gerät, hat keinen — und läuft
|
||||
nicht.
|
||||
|
||||
Dazu `default-src 'self'`, `object-src 'none'`, `base-uri 'self'`,
|
||||
`form-action 'self'`. `frame-src` nennt ausschließlich die beiden
|
||||
Video-Anbieter, die der Typ „Video“ einbetten kann.
|
||||
|
||||
**Zwei bewusste Lockerungen**, damit sie nicht als Versehen gelesen werden:
|
||||
|
||||
- `style-src` erlaubt weiterhin `'unsafe-inline'`. Ein Nonce wirkt nur auf
|
||||
`<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen; die
|
||||
Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen. Über
|
||||
ein Stilattribut lässt sich kein Code ausführen, der Gewinn wäre gering.
|
||||
- `media-src` erlaubt jede `https:`-Quelle. Der Typ „Video“ nimmt
|
||||
ausdrücklich auch einen Direktlink auf eine Videodatei entgegen, und wo
|
||||
die liegt, weiß nur der, der ihn einträgt.
|
||||
|
||||
`upgrade-insecure-requests` steht nur, wenn `BASE_URL` auf `https` zeigt —
|
||||
auf einer bewusst über HTTP betriebenen Installation würde die Regel die
|
||||
eigenen Ressourcen hochstufen und alles zerlegen.
|
||||
|
||||
### 18. Sicherheitsereignisse standen nirgends
|
||||
|
||||
**Befund:** Fehlversuche, OIDC-Verknüpfungen und die Vergabe von
|
||||
Administratorrechten hinterließen keine Spur.
|
||||
|
||||
**Behoben:** `src/lib/auth/protokoll.ts` schreibt je Ereignis eine Zeile als
|
||||
JSON auf die Standardausgabe — damit landet sie in `docker compose logs` und
|
||||
lässt sich von dort weiterreichen, ohne dass die Anwendung ein Werkzeug
|
||||
dafür kennen muss.
|
||||
|
||||
Protokolliert werden: fehlgeschlagene Anmeldungen samt Zahl der Versuche und
|
||||
Wartezeit, abgelehnte OIDC-Verknüpfungen mit Grund, erfolgte Verknüpfungen
|
||||
und jede Vergabe von Administratorrechten.
|
||||
|
||||
**Was ausdrücklich nicht hineingehört:** Passwörter, Sitzungstoken und
|
||||
vollständige E-Mail-Adressen. Konten stehen mit ihrer Id da. Ein Protokoll
|
||||
wird kopiert und verschickt und ist selten so gut geschützt wie die
|
||||
Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## Was schon vorher stimmte
|
||||
@@ -286,34 +385,15 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
||||
|
||||
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.
|
||||
**Die Zähler gelten je Prozess.** Bremsen und Wartezeiten liegen im
|
||||
Arbeitsspeicher und sind nach einem Neustart weg. Bei **einer** Instanz — dem
|
||||
Regelfall dieser Anwendung — ist das verschmerzbar: Die Wartezeit je Konto
|
||||
steht in der Datenbank und übersteht den Neustart, nur die Zähler je Absender
|
||||
und die Obergrenze der Instanz beginnen von vorn.
|
||||
|
||||
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.
|
||||
Wer mehrere Instanzen hinter einem Lastverteiler betreibt, braucht einen
|
||||
gemeinsamen, atomaren Zähler — üblicherweise Redis. Das ist ein weiterer
|
||||
Dienst und lohnt sich erst, wenn es die zweite Instanz wirklich gibt.
|
||||
|
||||
**Prisma hängt an einer Advisory.** `npm audit --omit=dev` meldet
|
||||
[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx) in
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type OidcIdentity,
|
||||
} from "@/lib/auth/oidc";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getBaseUrl } from "@/lib/env";
|
||||
|
||||
@@ -100,6 +101,12 @@ async function findeOderLegeAn(
|
||||
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||
// übernehmen.
|
||||
if (!identity.emailVerified) {
|
||||
// Der Versuch gehört ins Protokoll: Wenn er gehäuft auftritt, stimmt
|
||||
// entweder die Anbieterkonfiguration nicht — oder jemand probiert es.
|
||||
sicherheitsereignis("oidc-verknuepfung-abgelehnt", {
|
||||
konto: nachEmail.id,
|
||||
grund: "email-nicht-bestaetigt",
|
||||
});
|
||||
return {
|
||||
fehler:
|
||||
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
||||
@@ -116,6 +123,12 @@ async function findeOderLegeAn(
|
||||
...(istAdmin === undefined ? {} : { isInstanceAdmin: istAdmin }),
|
||||
},
|
||||
});
|
||||
// Eine Verknüpfung verschiebt eine Vertrauensgrenze: Ab jetzt kommt man
|
||||
// über den Anbieter in dieses Konto. Das soll nachvollziehbar sein.
|
||||
sicherheitsereignis("oidc-konto-verknuepft", {
|
||||
konto: verknuepft.id,
|
||||
administrator: verknuepft.isInstanceAdmin,
|
||||
});
|
||||
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||
}
|
||||
|
||||
@@ -153,6 +166,13 @@ async function findeOderLegeAn(
|
||||
{ isolationLevel: "Serializable" },
|
||||
);
|
||||
|
||||
if (angelegt.isInstanceAdmin) {
|
||||
sicherheitsereignis("administrator-vergeben", {
|
||||
konto: angelegt.id,
|
||||
weg: istAdmin === undefined ? "erster-benutzer" : "gruppe-des-anbieters",
|
||||
});
|
||||
}
|
||||
|
||||
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||
}
|
||||
|
||||
|
||||
+57
-22
@@ -17,18 +17,31 @@ import {
|
||||
} from "@/lib/auth/session";
|
||||
import {
|
||||
clearAttempts,
|
||||
kontoWartezeit,
|
||||
requestOrigin,
|
||||
teureRechnungErlaubt,
|
||||
tooManyAttempts,
|
||||
verbleibendeKontowartezeit,
|
||||
} from "@/lib/auth/throttle";
|
||||
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
import { QrRequestError } from "@/lib/qr/type-module";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
||||
const MAX_ATTEMPTS = 8;
|
||||
const LOCK_MINUTES = 15;
|
||||
/**
|
||||
* Wartezeit statt Sperre.
|
||||
*
|
||||
* Früher stand hier eine Sperre von 15 Minuten nach acht Fehlversuchen. Die
|
||||
* hatte einen Haken: Sie wies auch das **richtige** Passwort ab. Wer die
|
||||
* Adresse eines Administrators kannte, konnte ihn damit alle 15 Minuten neu
|
||||
* aussperren und die Verwaltung praktisch dauerhaft schließen.
|
||||
*
|
||||
* Jetzt wächst nur die Wartezeit zwischen zwei Versuchen, bis zu einer
|
||||
* Minute — siehe `kontoWartezeit`. Der Besitzer kommt spätestens nach einer
|
||||
* Minute hinein, ein Fremder bekommt einen Versuch pro Minute.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
||||
@@ -248,14 +261,31 @@ async function handleSetup(
|
||||
|
||||
async function handleLogin(
|
||||
input: z.infer<typeof loginSchema>,
|
||||
absender: string,
|
||||
/** Absender laut vertrauenswürdigem Proxy — `null`, wenn keiner davorsteht. */
|
||||
absender: string | null,
|
||||
): Promise<Response> {
|
||||
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
||||
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||
if (wartezeit !== null) {
|
||||
// Wer viele Konten durchprobiert, löst nie eine Wartezeit für ein
|
||||
// einzelnes Konto aus — dagegen hilft nur eine Bremse je Absender. Sie
|
||||
// greift nur hinter einem Proxy, dem ausdrücklich vertraut wird; sonst
|
||||
// wählte der Aufrufer seine Kennung selbst.
|
||||
if (absender !== null) {
|
||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
||||
if (wartezeit !== null) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Der Deckel der ganzen Installation. Er steht vor jeder Passwortprüfung,
|
||||
// auch vor der gegen den Attrappen-Hash: Beide kosten gleich viel Speicher,
|
||||
// und wer sie auslösen darf, könnte den Server sonst allein damit
|
||||
// lahmlegen. Das ist die einzige Bremse, die sich nicht über Kopfzeilen
|
||||
// umgehen lässt.
|
||||
if (!teureRechnungErlaubt()) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Anmeldeversuche. Bitte warte ${Math.ceil(wartezeit / 60)} Minute${wartezeit > 60 ? "n" : ""}.`,
|
||||
"Zurzeit gehen sehr viele Anmeldeversuche ein. Bitte versuche es in einer Minute erneut.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -275,12 +305,15 @@ async function handleLogin(
|
||||
throw invalid;
|
||||
}
|
||||
|
||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
||||
const minutes = Math.ceil(
|
||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
||||
);
|
||||
// Wartezeit des Kontos — **vor** der Passwortprüfung, damit ein Versuch,
|
||||
// der ohnehin abgewiesen wird, keine 128 MiB kostet.
|
||||
const kontoWarten = verbleibendeKontowartezeit(
|
||||
user.failedLogins,
|
||||
user.lockedUntil,
|
||||
);
|
||||
if (kontoWarten > 0) {
|
||||
throw new QrRequestError(
|
||||
`Zu viele Fehlversuche. Bitte versuche es in ${minutes} Minute${minutes === 1 ? "" : "n"} erneut.`,
|
||||
`Zu viele Fehlversuche. Bitte versuche es in ${kontoWarten} Sekunde${kontoWarten === 1 ? "" : "n"} erneut.`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
@@ -295,15 +328,17 @@ async function handleLogin(
|
||||
|
||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||
const failedLogins = user.failedLogins + 1;
|
||||
// `lockedUntil` heißt jetzt „frühestens wieder ab“: der Zeitpunkt des
|
||||
// Fehlversuchs. Wie lange das bedeutet, rechnet `kontoWartezeit` aus der
|
||||
// Zahl der Fehlversuche — so bleibt die Spalte, aber nicht die Sperre.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
failedLogins,
|
||||
lockedUntil:
|
||||
failedLogins >= MAX_ATTEMPTS
|
||||
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
||||
: null,
|
||||
},
|
||||
data: { failedLogins, lockedUntil: new Date() },
|
||||
});
|
||||
sicherheitsereignis("anmeldung-fehlgeschlagen", {
|
||||
konto: user.id,
|
||||
fehlversuche: failedLogins,
|
||||
wartezeit: kontoWartezeit(failedLogins),
|
||||
});
|
||||
throw invalid;
|
||||
}
|
||||
@@ -333,7 +368,7 @@ async function handleLogin(
|
||||
},
|
||||
});
|
||||
|
||||
clearAttempts(`login:${absender}`);
|
||||
if (absender !== null) clearAttempts(`login:${absender}`);
|
||||
await createSession(user.id);
|
||||
return Response.json({ ok: true, email: user.email });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requestOrigin } from "@/lib/auth/throttle";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||
|
||||
@@ -31,6 +32,31 @@ const FENSTER_MS = 60_000;
|
||||
const MAX_PRO_FENSTER = 10;
|
||||
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
||||
|
||||
/**
|
||||
* Obergrenze für die ganze Installation.
|
||||
*
|
||||
* Die Bremse je Absender greift nur hinter einem Proxy, dem vertraut wird —
|
||||
* sonst wählt der Aufrufer seine Kennung selbst. Dieser Deckel gilt immer
|
||||
* und begrenzt, wie schnell die Tabelle überhaupt wachsen kann.
|
||||
*
|
||||
* Großzügig bemessen: Selbst ein gut besuchter Aushang bekommt keine
|
||||
* hundertzwanzig Rückmeldungen in der Minute.
|
||||
*/
|
||||
const MAX_PRO_MINUTE_INSGESAMT = 120;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
let gesamtFenster = 0;
|
||||
let gesamtAnzahl = 0;
|
||||
|
||||
function insgesamtZuSchnell(): boolean {
|
||||
const jetzt = Date.now();
|
||||
if (jetzt - gesamtFenster > MINUTE_MS) {
|
||||
gesamtFenster = jetzt;
|
||||
gesamtAnzahl = 0;
|
||||
}
|
||||
gesamtAnzahl += 1;
|
||||
return gesamtAnzahl > MAX_PRO_MINUTE_INSGESAMT;
|
||||
}
|
||||
|
||||
function zuSchnell(schluessel: string): boolean {
|
||||
const jetzt = Date.now();
|
||||
const eintrag = zaehler.get(schluessel);
|
||||
@@ -58,12 +84,19 @@ export async function POST(request: NextRequest): Promise<Response> {
|
||||
return jsonError(firstZodMessage(parsed.error));
|
||||
}
|
||||
|
||||
const herkunft =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unbekannt";
|
||||
// Erst der Deckel für alle — er lässt sich nicht über Kopfzeilen umgehen.
|
||||
if (insgesamtZuSchnell()) {
|
||||
return jsonError(
|
||||
"Zurzeit gehen sehr viele Rückmeldungen ein. Bitte versuche es gleich noch einmal.",
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
if (zuSchnell(herkunft)) {
|
||||
// Dann die Bremse je Absender. Sie sagt nur etwas aus, wenn ein Proxy
|
||||
// davorsteht, dem ausdrücklich vertraut wird; sonst wählte der Aufrufer
|
||||
// seine Kennung selbst und die Bremse wäre eine Behauptung.
|
||||
const herkunft = requestOrigin(request.headers);
|
||||
if (herkunft !== null && zuSchnell(herkunft)) {
|
||||
return jsonError(
|
||||
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||
429,
|
||||
|
||||
+11
-1
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||
import { accentCss } from "@/lib/branding-colors";
|
||||
@@ -47,6 +48,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
const branding = await getBranding();
|
||||
|
||||
// Der Nonce dieser Anfrage, gesetzt in `src/proxy.ts`. Ohne ihn lehnt der
|
||||
// Browser die beiden eingebetteten Blöcke unten ab — sie sind die einzigen
|
||||
// der Anwendung.
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<html lang="de" className="h-full">
|
||||
<body className="flex min-h-full flex-col">
|
||||
@@ -55,7 +61,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||
`light-dark()` im CSS von allein.
|
||||
*/}
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
|
||||
/>
|
||||
{/*
|
||||
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
||||
@@ -64,6 +73,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||
die Voreinstellung zurück.
|
||||
*/}
|
||||
<style
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||
/>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Protokoll für Sicherheitsereignisse.
|
||||
*
|
||||
* Eine Zeile je Ereignis, als JSON, auf `stdout`. Damit landet sie in
|
||||
* `docker compose logs` und lässt sich von dort in jedes Werkzeug
|
||||
* weiterreichen, ohne dass die Anwendung eines davon kennen muss.
|
||||
*
|
||||
* **Was hier nicht hineingehört:** Passwörter, Sitzungstoken, vollständige
|
||||
* E-Mail-Adressen. Ein Protokoll wird kopiert, verschickt und selten so gut
|
||||
* geschützt wie die Datenbank. Konten stehen deshalb mit ihrer Id da, nicht
|
||||
* mit ihrer Adresse — wer die Zuordnung braucht, hat ohnehin Zugriff auf die
|
||||
* Verwaltung.
|
||||
*/
|
||||
|
||||
export type Sicherheitsereignis =
|
||||
| "anmeldung-fehlgeschlagen"
|
||||
| "anmeldung-gesperrt"
|
||||
| "oidc-konto-verknuepft"
|
||||
| "oidc-verknuepfung-abgelehnt"
|
||||
| "administrator-vergeben"
|
||||
| "instanz-ohne-anmeldung";
|
||||
|
||||
/**
|
||||
* Schreibt ein Ereignis.
|
||||
*
|
||||
* Bewusst `console.log` und kein Protokollrahmenwerk: Die Anwendung läuft in
|
||||
* einem Container, und dort ist die Standardausgabe der übliche Weg.
|
||||
*/
|
||||
export function sicherheitsereignis(
|
||||
ereignis: Sicherheitsereignis,
|
||||
daten: Record<string, string | number | boolean | null> = {},
|
||||
): void {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
art: "sicherheit",
|
||||
ereignis,
|
||||
zeit: new Date().toISOString(),
|
||||
...daten,
|
||||
}),
|
||||
);
|
||||
}
|
||||
+116
-14
@@ -1,16 +1,26 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* Grobe Bremse gegen automatisiertes Durchprobieren.
|
||||
* Bremsen gegen automatisiertes Durchprobieren.
|
||||
*
|
||||
* Ergänzt die Kontosperre, ersetzt sie aber nicht: Die Sperre schützt ein
|
||||
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
|
||||
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
|
||||
* Drei Stufen, die verschiedene Dinge schützen:
|
||||
*
|
||||
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
|
||||
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
|
||||
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
|
||||
* nirgends gespeichert.
|
||||
* 1. **Je Absender** (`tooManyAttempts`) — greift nur, wenn ein Proxy davor
|
||||
* steht, dem ausdrücklich vertraut wird. Sonst wählt der Aufrufer seine
|
||||
* Kennung selbst und die Bremse wäre eine Behauptung.
|
||||
* 2. **Je Konto** (`kontoWartezeit`) — eine ansteigende Wartezeit zwischen
|
||||
* zwei Versuchen. Sie ersetzt die frühere harte Sperre: Wer das Passwort
|
||||
* kennt, kommt nach höchstens einer Minute hinein; wer es nicht kennt,
|
||||
* bekommt einen Versuch pro Minute.
|
||||
* 3. **Für die ganze Installation** (`teureRechnungErlaubt`) — eine
|
||||
* Obergrenze für Passwortprüfungen. Sie ist die einzige Stufe, die sich
|
||||
* nicht über Kopfzeilen umgehen lässt, und schützt den Speicher: Eine
|
||||
* Prüfung kostet rund 128 MiB.
|
||||
*
|
||||
* Alles im Arbeitsspeicher und damit je Container. Bei einer selbst
|
||||
* gehosteten Anwendung dieser Größe ist das angemessen; ein verteilter
|
||||
* Zähler bräuchte einen weiteren Dienst. Adressen dienen nur als Schlüssel
|
||||
* und werden nirgends gespeichert.
|
||||
*/
|
||||
|
||||
type Eintrag = { anzahl: number; bis: number };
|
||||
@@ -53,17 +63,109 @@ export function clearAttempts(schluessel: string): void {
|
||||
zaehler.delete(schluessel);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Proxy-Vertrauen */
|
||||
|
||||
/**
|
||||
* Absender einer Anfrage, so gut es hinter einem Proxy geht.
|
||||
* Steht ein Proxy davor, dem zu trauen ist?
|
||||
*
|
||||
* `X-Forwarded-For` ist fälschbar, wenn kein Proxy davorsteht — deshalb ist
|
||||
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
|
||||
* aber die Kontosperre greift weiterhin.
|
||||
* `X-Forwarded-For` kann jeder Aufrufer selbst setzen. Nur wenn ein Proxy
|
||||
* davorsteht, der die Kopfzeile **überschreibt**, sagt sie etwas aus. Das
|
||||
* kann die Anwendung nicht erkennen — Next.js-Routen kommen an die Adresse
|
||||
* der Gegenstelle gar nicht heran —, also muss es eingestellt werden.
|
||||
*
|
||||
* Ohne Angabe: kein Vertrauen. Eine Bremse, die sich mit einer selbst
|
||||
* gewählten Kopfzeile umgehen lässt, ist keine.
|
||||
*/
|
||||
export function requestOrigin(headers: Headers): string {
|
||||
export function trustsProxy(): boolean {
|
||||
return (process.env.TRUST_PROXY ?? "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Absender einer Anfrage — oder `null`, wenn er sich nicht feststellen lässt.
|
||||
*
|
||||
* `null` heißt nicht „egal“, sondern „unbekannt“: Der Aufrufer bekommt dann
|
||||
* keine eigene Bremse, wohl aber die Obergrenze der ganzen Installation zu
|
||||
* spüren.
|
||||
*/
|
||||
export function requestOrigin(headers: Headers): string | null {
|
||||
if (!trustsProxy()) return null;
|
||||
return (
|
||||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
headers.get("x-real-ip") ||
|
||||
"unbekannt"
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------- Wartezeit je Konto */
|
||||
|
||||
/**
|
||||
* Wartezeit zwischen zwei Anmeldeversuchen desselben Kontos, in Sekunden.
|
||||
*
|
||||
* Ersetzt die frühere Sperre von 15 Minuten. Der Unterschied ist der, auf
|
||||
* den es ankommt: Eine Sperre weist auch das **richtige** Passwort ab —
|
||||
* wer die Adresse eines Administrators kennt, konnte ihn damit dauerhaft
|
||||
* aussperren. Eine Wartezeit verzögert nur.
|
||||
*
|
||||
* Die ersten Fehlversuche bleiben ohne Folge; danach verdoppelt sich die
|
||||
* Wartezeit bis zu einer Minute. Für den Besitzer heißt das im schlimmsten
|
||||
* Fall eine Minute, für jemanden, der Passwörter durchprobiert, einen
|
||||
* Versuch pro Minute.
|
||||
*/
|
||||
const FREIVERSUCHE = 4;
|
||||
const MAX_WARTEZEIT_S = 60;
|
||||
|
||||
export function kontoWartezeit(fehlversuche: number): number {
|
||||
if (fehlversuche <= FREIVERSUCHE) return 0;
|
||||
return Math.min(2 ** (fehlversuche - FREIVERSUCHE - 1), MAX_WARTEZEIT_S);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange ein Konto noch warten muss, in Sekunden — oder 0.
|
||||
*
|
||||
* `letzterVersuch` ist der Zeitpunkt des letzten Fehlversuchs.
|
||||
*/
|
||||
export function verbleibendeKontowartezeit(
|
||||
fehlversuche: number,
|
||||
letzterVersuch: Date | null,
|
||||
): number {
|
||||
if (!letzterVersuch) return 0;
|
||||
const noetig = kontoWartezeit(fehlversuche);
|
||||
if (noetig === 0) return 0;
|
||||
const vergangen = (Date.now() - letzterVersuch.getTime()) / 1000;
|
||||
return Math.max(0, Math.ceil(noetig - vergangen));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- Obergrenze der Instanz */
|
||||
|
||||
/**
|
||||
* Obergrenze für Passwortprüfungen in der ganzen Installation.
|
||||
*
|
||||
* Eine Prüfung kostet rund 128 MiB Arbeitsspeicher (scrypt mit N = 2¹⁷).
|
||||
* Ohne Deckel könnte jemand mit genügend gleichzeitigen Anfragen den Server
|
||||
* allein dadurch lahmlegen — unabhängig davon, welche Kopfzeilen er schickt.
|
||||
*
|
||||
* Das ist die einzige Stufe, die sich nicht umgehen lässt. Sie ist bewusst
|
||||
* großzügig: Im normalen Betrieb meldet sich niemand sechzigmal in der
|
||||
* Minute an.
|
||||
*/
|
||||
const TEURE_RECHNUNGEN_PRO_MINUTE = 60;
|
||||
const MINUTE_MS = 60 * 1000;
|
||||
|
||||
let fensterBeginn = 0;
|
||||
let fensterAnzahl = 0;
|
||||
|
||||
/**
|
||||
* Fragt eine teure Rechnung an. `false` heißt: gerade nicht, bitte später.
|
||||
*
|
||||
* Wird **vor** der Passwortprüfung aufgerufen, nicht danach — sonst wäre der
|
||||
* Speicher schon belegt, wenn der Deckel greift.
|
||||
*/
|
||||
export function teureRechnungErlaubt(): boolean {
|
||||
const jetzt = Date.now();
|
||||
if (jetzt - fensterBeginn > MINUTE_MS) {
|
||||
fensterBeginn = jetzt;
|
||||
fensterAnzahl = 0;
|
||||
}
|
||||
fensterAnzahl += 1;
|
||||
return fensterAnzahl <= TEURE_RECHNUNGEN_PRO_MINUTE;
|
||||
}
|
||||
|
||||
+72
-8
@@ -26,17 +26,68 @@ import { isSameOrigin } from "@/lib/http";
|
||||
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
/**
|
||||
* Schutzheader.
|
||||
* Woher eingebettete Videos kommen dürfen.
|
||||
*
|
||||
* Bewusst ohne vollständige `Content-Security-Policy` für Skripte: Next.js
|
||||
* bringt eingebettete Skripte mit, dafür bräuchte es Nonces durch die ganze
|
||||
* Anwendung. Eine halbherzige Policy mit `unsafe-inline` sähe nur nach
|
||||
* Schutz aus. Was hier steht, wirkt dagegen sofort.
|
||||
* Der Video-Typ bettet YouTube und Vimeo ein — beide brauchen einen eigenen
|
||||
* Eintrag, sonst bliebe der Rahmen nach dem Klick leer. Mehr steht hier
|
||||
* nicht: Was nicht eingebettet wird, gehört auch nicht in die Liste.
|
||||
*/
|
||||
const VIDEO_QUELLEN = [
|
||||
"https://www.youtube-nocookie.com",
|
||||
"https://www.youtube.com",
|
||||
"https://player.vimeo.com",
|
||||
].join(" ");
|
||||
|
||||
/**
|
||||
* Die Content-Security-Policy dieser Anfrage.
|
||||
*
|
||||
* Skripte laufen nur mit dem Nonce dieser einen Anfrage oder weil ein Skript
|
||||
* sie geladen hat, das selbst eines hatte (`strict-dynamic`). Ein Skript,
|
||||
* das über eine Lücke in die Seite gerät, hat keines — und läuft nicht.
|
||||
*
|
||||
* **Warum `style-src` weiter `unsafe-inline` erlaubt:** Ein Nonce wirkt nur
|
||||
* auf `<style>`-Blöcke, nicht auf `style="…"` an einzelnen Elementen. Die
|
||||
* Oberfläche benutzt solche Attribute an rund einem Dutzend Stellen, etwa
|
||||
* für die gewählte Akzentfarbe. Sie zu verbieten hieße, sie vorher alle
|
||||
* aufzulösen; der Gewinn wäre gering, denn über ein Stilattribut lässt sich
|
||||
* kein Code ausführen. Das ist eine bewusste Grenze, keine Nachlässigkeit.
|
||||
*
|
||||
* `upgrade-insecure-requests` nur bei HTTPS: Auf einer Installation, die
|
||||
* bewusst über HTTP läuft, würde die Regel die eigenen Ressourcen auf HTTPS
|
||||
* hochstufen und damit alles zerlegen.
|
||||
*/
|
||||
function contentSecurityPolicy(nonce: string): string {
|
||||
const entwicklung = process.env.NODE_ENV === "development";
|
||||
const ueberHttps = (process.env.BASE_URL ?? "").startsWith("https://");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
"base-uri 'self'",
|
||||
"object-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
// `unsafe-eval` braucht React nur in der Entwicklung, für die
|
||||
// Fehleraufbereitung im Browser.
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${entwicklung ? " 'unsafe-eval'" : ""}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
// `data:` für die Vorschau des QR-Codes, `blob:` für den Download.
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
"connect-src 'self'",
|
||||
`frame-src ${VIDEO_QUELLEN}`,
|
||||
// Videos duerfen als Direktlink irgendwo liegen — der Typ „Video" nimmt
|
||||
// ausdruecklich auch eine Adresse auf eine MP4-Datei entgegen. `https:`
|
||||
// statt einer Liste: Welche Adresse jemand eintraegt, weiss nur er.
|
||||
"media-src 'self' https:",
|
||||
...(ueberHttps ? ["upgrade-insecure-requests"] : []),
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/** Schutzheader, die unabhängig von der Anfrage gelten. */
|
||||
const HEADER: Record<string, string> = {
|
||||
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
|
||||
// einer harmlos aussehenden Seite verstecken und fernsteuern.
|
||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
||||
// einer harmlos aussehenden Seite verstecken und fernsteuern. Steht auch
|
||||
// in der CSP; dieser Header erreicht ältere Browser.
|
||||
"X-Frame-Options": "DENY",
|
||||
// Der Browser soll den Inhaltstyp nicht raten.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
@@ -58,7 +109,20 @@ export function proxy(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const response = NextResponse.next();
|
||||
// Ein frischer Nonce je Anfrage. Next.js liest ihn aus der CSP der
|
||||
// **Anfrage** und hängt ihn an seine eigenen Skripte — deshalb muss die
|
||||
// Kopfzeile an beiden Stellen stehen, an der Anfrage und an der Antwort.
|
||||
const nonce = crypto.randomUUID().replaceAll("-", "");
|
||||
const csp = contentSecurityPolicy(nonce);
|
||||
|
||||
const anfrage = new Headers(request.headers);
|
||||
anfrage.set("x-nonce", nonce);
|
||||
anfrage.set("Content-Security-Policy", csp);
|
||||
|
||||
const response = NextResponse.next({ request: { headers: anfrage } });
|
||||
if (!response.headers.has("Content-Security-Policy")) {
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
}
|
||||
for (const [name, wert] of Object.entries(HEADER)) {
|
||||
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
|
||||
// Dateien —, sollen sie behalten.
|
||||
|
||||
Reference in New Issue
Block a user