5 Commits
Author SHA1 Message Date
florian.wilde 6743dc007d Merge #20: Befunde der Sicherheitsprüfung 2026-08-24 07:41:07 +00:00
Florian Wilde 367d9c540b 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
2026-08-24 01:43:00 +02:00
Florian Wilde 1e0d04d0db 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
2026-08-24 01:02:03 +02:00
florian.wilde 4e2d222e86 Merge: Version 1.7.1 — Bilder zu den neuen Funktionen 2026-08-21 10:23:58 +00:00
Florian Wilde fcc63ff69a Version 1.7.1: Bilder zu den neuen Funktionen
Die Bedienungsanleitung hatte zu jedem alten Abschnitt ein Bild und zu
keinem der sechs neuen. Sechs Aufnahmen kommen dazu: Logo im Code,
Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und der
Bericht einer geprueften Domain.

Das Skript dafuer lief seit einer Umbenennung ins Leere — es wartete auf
einen Dialogtitel "Ziel bearbeiten", der inzwischen "Code bearbeiten"
heisst. Damit sind auch die uebrigen sechzehn Bilder neu; sie stammten
noch aus 1.2.0 und zeigten eine aeltere Oberflaeche.

schuss() kann jetzt einen Ausschnitt aufnehmen statt der ganzen Seite —
fuer Dialoge und einzelne Karten, bei denen die volle Seite mehr verdeckt
als zeigt. Das Beispiellogo entsteht im Skript, damit kein ungepflegtes
Bild im Repo liegt.

In der Kopfdoku steht jetzt, dass BASE_URL der Instanz auf dieselbe
Adresse zeigen muss wie BASE — sonst erreicht die Anwendung sich beim
Domain-Check nicht selbst und das Bild zeigt einen Fehlschlag.
2026-08-21 12:23:15 +02:00
42 changed files with 829 additions and 77 deletions
+18 -4
View File
@@ -14,12 +14,26 @@ BASE_URL=http://localhost:3000
APP_PORT=3000
# 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
# 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.
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
AUTH_ENABLED=false
# 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
+8
View File
@@ -7,6 +7,14 @@ Versionsnummer in der Fußzeile.
> Diese Datei wird erzeugt: `npm run changelog`.
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
## 1.7.1 — 21.08.2026
Bilder zu den neuen Funktionen in der Bedienungsanleitung.
- Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.
- Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.
- Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.
## 1.7.0 — 21.08.2026
Logo im Code, Freigabe je Gruppe und ein Domain-Check.
+9 -1
View File
@@ -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
+6 -1
View File
@@ -37,7 +37,12 @@ services:
BASE_URL: ${BASE_URL:-http://localhost:3000}
DATABASE_URL: ${DATABASE_URL}
# 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}
# 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}
+12
View File
@@ -5,5 +5,17 @@ set -e
echo "→ Datenbankmigrationen werden angewendet …"
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 …"
exec "$@"
+11
View File
@@ -114,6 +114,8 @@ trennen kann.
### Ein Logo in die Mitte setzen
![Ein Logo in der Mitte des Codes](bilder/16-logo-im-code.png)
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
dann fragt die Anwendung, woher das Bild kommen soll:
@@ -158,6 +160,7 @@ Projekte nebeneinander.
liegen.
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
![Die Liste, eingeschränkt auf statische Codes](bilder/18-meine-codes-filter.png)
Steht er auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung
nach Scans mit ihr — bei einer Liste aus lauter Strichen sagt sie nichts.
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
@@ -173,6 +176,8 @@ Ein statischer Code ist mit dem Download eigentlich fertig — sein Inhalt
steckt in ihm. Der Knopf **In „Meine Codes“ ablegen** legt ihn trotzdem in die
Liste, damit du ihn wiederfindest und erneut herunterladen kannst.
![Warnung vor einem doppelt abgelegten Code](bilder/17-dublettenwarnung.png)
Liegt dort schon ein statischer Code mit **identischem Inhalt**, hält das
Ablegen an und zeigt, welcher das ist und wann er entstand. Der Knopf heißt
dann **Trotzdem ablegen** — es ist eine Warnung, keine Sperre. Verglichen wird
@@ -296,6 +301,8 @@ bearbeiten. Nur neue lassen sich nicht mehr anlegen.
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
![Freigabe der Typen für eine einzelne Gruppe](bilder/19-verwaltung-typen-gruppe.png)
| Auswahl | Wirkung |
| --- | --- |
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
@@ -346,9 +353,13 @@ Knopf **Kurzhilfe** über der Liste führt es in sechs Schritten auf — DNS,
Weiterleitung im Proxy samt der drei nötigen Kopfzeilen, Zertifikat,
Registrieren, Gruppenzuteilung, Nachprüfen.
![Die Kurzhilfe zum Hinzufügen einer Domain](bilder/20-domain-kurzhilfe.png)
**Prüfen, ob eine Domain wirklich hierher führt.** Bei jedem Eintrag steht ein
Knopf **Prüfen**. Er geht den Weg der Scanner nach und meldet vier Dinge:
![Der Bericht einer geprüften Domain](bilder/21-domain-check.png)
| Schritt | Was er sagt |
| --- | --- |
| DNS | ob der Name auflöst, und auf welche Adressen |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+159 -3
View File
@@ -7,6 +7,9 @@
* Voraussetzungen:
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
* Codes im Bild landen), erreichbar unter BASE
* - `BASE_URL` der Instanz muss auf **dieselbe** Adresse zeigen wie BASE.
* Sonst scheitert der Domain-Check im Bild 21, weil die Anwendung sich
* selbst unter der eingetragenen Adresse nicht erreicht.
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
* Regelbetrieb
* - Playwright: npm i -D playwright && npx playwright install chromium
@@ -18,6 +21,7 @@ import { mkdir } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { chromium } from "playwright";
import sharp from "sharp";
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
locale: "de-DE",
});
async function schuss(context, pfad, datei, { ganzeSeite = true, vorher } = {}) {
/**
* Nimmt eine Seite auf.
*
* `bereich` grenzt auf einen Ausschnitt ein — für Dialoge und einzelne Karten,
* bei denen die ganze Seite mehr verdeckt als zeigt.
*/
async function schuss(
context,
pfad,
datei,
{ ganzeSeite = true, vorher, bereich } = {},
) {
const page = await context.newPage();
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
if (vorher) await vorher(page);
await page.waitForTimeout(300);
await page.screenshot({ path: join(ZIEL, datei), fullPage: ganzeSeite });
const ziel = join(ZIEL, datei);
if (bereich) {
await page.locator(bereich).first().screenshot({ path: ziel });
} else {
await page.screenshot({ path: ziel, fullPage: ganzeSeite });
}
await page.close();
console.log(" ✓", datei);
}
/**
* Ein Beispiellogo für die Aufnahmen zum Logo im Code.
*
* Erzeugt statt mitgeliefert: So liegt kein Bild im Repo, das niemand pflegt,
* und die Farbe passt zu den übrigen Aufnahmen.
*/
async function beispiellogo() {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256">
<rect width="256" height="256" rx="56" fill="#312E81"/>
<circle cx="128" cy="128" r="66" fill="none" stroke="#FFFFFF" stroke-width="18"/>
<circle cx="128" cy="128" r="20" fill="#FFFFFF"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
console.log("Bilder werden erzeugt:");
await schuss(desktop, "/", "01-startseite.png");
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
await page.waitForTimeout(400);
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
await page.locator("tbody tr").last().getByRole("button", { name: /bearbeiten/ }).click();
await page.getByRole("heading", { name: "Ziel bearbeiten" }).waitFor();
await page.getByRole("heading", { name: "Code bearbeiten" }).waitFor();
},
});
@@ -258,8 +293,129 @@ await schuss(desktop, "/verwaltung/gruppen", "13-verwaltung-gruppen.png");
await schuss(desktop, "/verwaltung/domains", "14-verwaltung-domains.png");
await schuss(desktop, "/konto", "15-konto.png");
/* ------------------------------------------- Seit 1.7.0: die neuen Funktionen */
const logo = await beispiellogo();
// Logo in der Mitte: ganze Seite, damit Auswahl und Vorschau zusammen im Bild
// sind — das eine erklärt das andere.
await schuss(desktop, "/erstellen/website", "16-logo-im-code.png", {
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/nordwind");
await page.getByRole("checkbox", { name: /Logo in die Mitte/ }).check();
await page.setInputFiles('input[type="file"]', {
name: "logo.png",
mimeType: "image/png",
buffer: logo,
});
await page.waitForTimeout(2500);
},
});
// Für die Dublettenwarnung braucht es einen bereits abgelegten statischen Code.
const statisch = await api("/api/codes", {
method: "POST",
json: {
type: "website",
data: { url: "https://example.com/aushang" },
design: DESIGN,
mode: "static",
label: "Aushang Eingang",
},
});
if (statisch.status === 201) angelegt.push(statisch.body.code);
await schuss(desktop, "/erstellen/website", "17-dublettenwarnung.png", {
bereich: "section:has-text('Art des Codes')",
vorher: async (page) => {
await page.locator("#website-url").fill("https://example.com/aushang");
await page.locator('button[data-mode="static"]').click();
await page.waitForTimeout(900);
await page.getByRole("button", { name: /ablegen/i }).click();
await page.getByText("Diesen Inhalt hast du schon abgelegt").waitFor({
timeout: 15000,
});
},
});
// Filter nach Art — sichtbar, weil jetzt beide Arten in der Liste stehen.
await schuss(desktop, "/meine-codes", "18-meine-codes-filter.png", {
vorher: async (page) => {
await page.getByLabel("Nach Art filtern").selectOption("static");
await page.waitForTimeout(600);
},
});
// Freigabe je Gruppe: eine zweite Gruppe, damit die Auswahl etwas zu wählen hat.
const gruppe = await api("/api/admin/groups", {
method: "POST",
json: { name: "Marketing" },
});
const gruppeId = gruppe.body?.group?.id ?? null;
if (gruppeId) {
// Ein Typ in dieser Gruppe abgeschaltet, damit das Bild den Fall zeigt,
// um den es geht.
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: false, groupId: gruppeId },
});
}
await schuss(desktop, "/verwaltung/typen", "19-verwaltung-typen-gruppe.png", {
vorher: async (page) => {
if (gruppeId) {
await page.getByLabel("Freigabe für").selectOption(gruppeId);
await page.waitForTimeout(900);
}
},
});
// Kurzhilfe: nur der Dialog, die Seite dahinter trägt nichts bei.
await schuss(desktop, "/verwaltung/domains", "20-domain-kurzhilfe.png", {
bereich: "dialog[open]",
vorher: async (page) => {
await page.getByRole("button", { name: "Kurzhilfe" }).click();
await page.locator("dialog[open]").waitFor();
await page.waitForTimeout(400);
},
});
// Domain-Check: der Bericht zur eigenen Adresse. Weil BASE_URL und der Port
// des Containers hier zusammenpassen, geht die Prüfung durch — genau der
// Fall, den die Anleitung zeigen soll.
await schuss(desktop, "/verwaltung/domains", "21-domain-check.png", {
vorher: async (page) => {
await page.getByRole("button", { name: "Prüfen" }).first().click();
await page
.locator("text=/Die Domain führt hierher|So kommt niemand hier an/")
.first()
.waitFor({ timeout: 30000 });
await page.waitForTimeout(400);
},
});
await browser.close();
/* --- Was über die Oberfläche entstand, kennt `angelegt` noch nicht --------- */
const bestand = await api("/api/codes");
for (const eintrag of bestand.body?.codes ?? []) {
if (!angelegt.some((vorhanden) => vorhanden.id === eintrag.id)) {
angelegt.push(eintrag);
}
}
// Die Abweichung und die Gruppe wieder zurücknehmen — das Skript soll eine
// Instanz so hinterlassen, wie es sie vorgefunden hat.
if (gruppeId) {
await api("/api/admin/types", {
method: "PATCH",
json: { type: "payment", enabled: true, groupId: gruppeId },
});
await api(`/api/admin/groups/${gruppeId}`, { method: "DELETE" });
}
/* ------------------------------------------------------------ Aufräumen */
for (const eintrag of angelegt) {
+192 -1
View File
@@ -1,7 +1,9 @@
# Anmeldung und Sitzungen — Durchsicht nach OWASP
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:
@@ -163,6 +165,171 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
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.
### 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
@@ -218,6 +385,30 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
Bewusst nicht umgesetzt, mit Begründung.
**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.
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
`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
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
`login`, `setup`, `logout` und `change-password`. Konten legt ein
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "qrcode-generator",
"version": "1.7.0",
"version": "1.7.1",
"private": true,
"scripts": {
"dev": "next dev",
+43 -14
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 };
}
@@ -128,21 +141,37 @@ 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" },
);
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}
+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),
};
+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;
}
+10
View File
@@ -23,6 +23,16 @@ export type ChangelogEntry = {
};
export const CHANGELOG: readonly ChangelogEntry[] = [
{
version: "1.7.1",
date: "2026-08-21",
summary: "Bilder zu den neuen Funktionen in der Bedienungsanleitung.",
changes: [
"Die Bedienungsanleitung zeigt die Funktionen aus 1.7.0 jetzt auch im Bild: Logo im Code, Dublettenwarnung, Filter nach Art, Freigabe je Gruppe, Kurzhilfe und Domain-Bericht.",
"Alle übrigen Bilder mit erneuert — sie stammten noch aus 1.2.0.",
"Das Skript, das die Bilder erzeugt, lief seit einer Umbenennung ins Leere und tut es wieder.",
],
},
{
version: "1.7.0",
date: "2026-08-21",
+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;
}
+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.