Bremsen, Sperren und CSP: die offenen Punkte schliessen

S3: Aus der Kontosperre wird eine Wartezeit. Die alte Sperre wies auch das
richtige Passwort ab — wer die Adresse eines Administrators kannte, konnte
ihn alle 15 Minuten neu aussperren. Jetzt bleiben vier Fehlversuche
folgenlos, danach verdoppelt sich die Wartezeit bis zu einer Minute. Der
Besitzer kommt spaetestens nach einer Minute hinein, ein Fremder bekommt
einen Versuch pro Minute. Ausgewertet wird sie vor der Passwortpruefung,
damit ein ohnehin abgewiesener Versuch keine 128 MiB kostet.

S4: Drei Stufen statt einer. Die Bremse je Absender liest
X-Forwarded-For nur noch mit TRUST_PROXY=true — ohne Proxy waehlt den Wert
jeder Aufrufer selbst, und eine so umgehbare Bremse ist eine Behauptung.
Dazu die Wartezeit je Konto, die an der Datenbank haengt, und eine
Obergrenze fuer die ganze Installation: 60 Passwortpruefungen und 120
Rueckmeldungen je Minute. Die letzte Stufe ist die einzige, die sich nicht
ueber Kopfzeilen umgehen laesst, und sie schuetzt den Speicher.

CSP: Eine vollstaendige Policy mit Nonce je Anfrage, strict-dynamic,
default-src self, object-src none, base-uri und form-action self. Skripte
ohne den Nonce dieser Anfrage laufen nicht mehr. Zwei Lockerungen bleiben
und stehen begruendet in der Doku: style-src erlaubt unsafe-inline, weil
ein Nonce keine style-Attribute deckt, und media-src erlaubt https, weil
der Typ Video ausdruecklich Direktlinks annimmt.

Protokoll: Sicherheitsereignisse als JSON auf die Standardausgabe —
Fehlversuche, OIDC-Verknuepfungen samt abgelehnten, Vergabe von
Administratorrechten. Ohne Passwoerter, Token und Adressen.

Refs #20
This commit is contained in:
Florian Wilde
2026-08-24 01:43:00 +02:00
parent 1e0d04d0db
commit 367d9c540b
11 changed files with 487 additions and 78 deletions
+20
View File
@@ -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
View File
@@ -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 });
}
+38 -5
View File
@@ -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
View File
@@ -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}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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.