Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6743dc007d | ||
|
|
367d9c540b | ||
|
|
1e0d04d0db | ||
|
|
4e2d222e86 | ||
|
|
fcc63ff69a | ||
|
|
1f4753b753 | ||
|
|
cdda7df17a | ||
|
|
c6891f66ff |
@@ -14,12 +14,26 @@ BASE_URL=http://localhost:3000
|
|||||||
APP_PORT=3000
|
APP_PORT=3000
|
||||||
|
|
||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
# false — jeder, der die App erreicht, darf alles (Einzelplatz/LAN).
|
|
||||||
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
|
# true — Anmeldung erforderlich. Beim ersten Aufruf legst du den ersten
|
||||||
# Administrator an.
|
# Administrator an. Das ist die Vorgabe, auch wenn die Zeile fehlt.
|
||||||
|
# false — jeder, der die App erreicht, darf alles: Verwaltung, Uploads,
|
||||||
|
# saemtliche Codes. Nur fuer Einzelplatz- oder LAN-Betrieb, und
|
||||||
|
# nur, wenn niemand Fremdes an den Rechner kommt. Der Container
|
||||||
|
# warnt beim Start deutlich, solange das so steht.
|
||||||
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
|
# Bewusst hier und nicht in der Oberfläche: so kann man sich nicht aussperren.
|
||||||
# Wer den Zugang verliert, setzt den Wert zurück auf false und startet neu.
|
# Wer den Zugang verliert, setzt den Wert auf false und startet neu.
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=true
|
||||||
|
|
||||||
|
# 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.
|
# Groesste erlaubte Datei fuer die Typen "PDF" und "Bild", in Megabyte.
|
||||||
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
# Die Dateien liegen in der Datenbank — wer die sichert, hat alles. Sehr
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ Versionsnummer in der Fußzeile.
|
|||||||
> Diese Datei wird erzeugt: `npm run changelog`.
|
> Diese Datei wird erzeugt: `npm run changelog`.
|
||||||
> Gepflegt wird der Verlauf in `src/lib/changelog.ts`.
|
> 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.
|
||||||
|
|
||||||
|
- Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.
|
||||||
|
- QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.
|
||||||
|
- Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.
|
||||||
|
- Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.
|
||||||
|
- „Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.
|
||||||
|
- Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.
|
||||||
|
|
||||||
## 1.6.0 — 21.08.2026
|
## 1.6.0 — 21.08.2026
|
||||||
|
|
||||||
Sichtbare Version mit Änderungsverlauf.
|
Sichtbare Version mit Änderungsverlauf.
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden.
|
|||||||
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG.
|
||||||
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code,
|
||||||
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H.
|
||||||
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter,
|
- **Meine Codes:** Liste oder Karten, Suche, Sortierung, Filter nach Gruppe
|
||||||
Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen
|
und nach Art, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch
|
||||||
ablegen, um sie wiederzufinden.
|
mit Namen ablegen; liegt derselbe Inhalt schon dort, wird gewarnt.
|
||||||
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
- **Feedback auswerten:** Durchschnitt, Verteilung und Kommentare — anonym
|
||||||
erhoben, ohne Name und IP-Adresse.
|
erhoben, ohne Name und IP-Adresse.
|
||||||
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
- **Anmeldung abschaltbar.** Mit Anmeldung: Benutzer, Gruppen und die Rollen
|
||||||
Administrator, Bearbeiter und Betrachter.
|
Administrator, Bearbeiter und Betrachter.
|
||||||
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt.
|
- **Mehrere Domains** für Kurz-URLs, den Gruppen zugeteilt — mit Kurzhilfe
|
||||||
|
zur Einrichtung und einer Prüfung, ob eine Domain wirklich hierher führt.
|
||||||
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
- **Single Sign-on** über Authentik oder einen anderen OIDC-Anbieter.
|
||||||
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst.
|
- **Typen abschaltbar:** Was ihr nicht braucht, erscheint gar nicht erst —
|
||||||
|
für die ganze Installation oder je Gruppe.
|
||||||
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
- **Eigenes Erscheinungsbild:** Name, Logo, Favicon und Akzentfarbe im
|
||||||
Admin-Bereich einstellbar.
|
Admin-Bereich einstellbar.
|
||||||
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
- **Hell und dunkel:** folgt dem Betriebssystem, mit Umschalter für eine
|
||||||
@@ -289,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-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-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:
|
Für ein reines nginx (ohne NPM) heißt das:
|
||||||
|
|
||||||
@@ -303,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
|
Bei **Traefik** und **Caddy** ist nichts zu tun — beide setzen die Kopfzeilen
|
||||||
von sich aus.
|
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
|
### Größe der Uploads
|
||||||
|
|
||||||
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
PDFs und Bilder gehen durch den Proxy. nginx bricht Anfragen oberhalb von
|
||||||
@@ -609,7 +619,7 @@ Zwei Eigenschaften sind dabei zu unterscheiden:
|
|||||||
|
|
||||||
| | Bedeutung | Woher |
|
| | Bedeutung | Woher |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **umgesetzt** | Der Typ ist ausprogrammiert. In Version 1 gilt das nur für „Website“. | Registry im Code |
|
| **umgesetzt** | Der Typ ist ausprogrammiert. Seit 1.1.0 gilt das für alle fünfzehn. | Registry im Code |
|
||||||
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
| **freigegeben** | Die Verwaltung zeigt den Typ an. | Datenbank, jederzeit änderbar |
|
||||||
|
|
||||||
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
Nur umgesetzt **und** freigegeben heißt benutzbar. Freigegeben, aber nicht
|
||||||
@@ -673,7 +683,10 @@ Kurz-URL rückt dann unter den Namen. Zum Herunterladen dient dort die
|
|||||||
Kartenansicht.
|
Kartenansicht.
|
||||||
|
|
||||||
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
Abgelegte **statische Codes** stehen mit in der Liste, tragen aber ein
|
||||||
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Steht der
|
Abzeichen „statisch“ statt einer Kurz-URL und zählen keine Scans. Beim
|
||||||
|
Ablegen läuft eine **Dublettenprüfung**: Liegt in derselben Gruppe schon ein
|
||||||
|
statischer Code mit identischem Inhalt, hält das Ablegen an und nennt ihn —
|
||||||
|
eine Rückfrage mit dem Knopf „Trotzdem ablegen“, keine Sperre. Verglichen wird nur der Inhalt, nicht die Gestaltung. Steht der
|
||||||
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
Filter auf „Nur statische“, fällt die Scan-Spalte weg und die Sortierung nach
|
||||||
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
Scans mit ihr — bei einer Liste aus lauter Nullen sagt sie nichts.
|
||||||
|
|
||||||
@@ -1017,6 +1030,8 @@ deutschen Meldung.
|
|||||||
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
| `GET` | `/r/{slug}` | Weiterleitung, erhöht den Scan-Zähler |
|
||||||
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
| `GET` | `/api/admin/types` | Zustand aller QR-Typen (`?group={id}` je Gruppe) |
|
||||||
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
| `PATCH` | `/api/admin/types` | Typ freigeben oder abschalten (`groupId` optional) |
|
||||||
|
| `POST` | `/api/admin/domains/{id}/check` | Domain prüfen: DNS, Zertifikat, Erreichbarkeit, Ziel |
|
||||||
|
| `GET` | `/api/domain-check` | Beweis für die Domain-Prüfung (`?nonce=…`, ohne Anmeldung) |
|
||||||
|
|
||||||
Beispiel:
|
Beispiel:
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ services:
|
|||||||
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
BASE_URL: ${BASE_URL:-http://localhost:3000}
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
# Anmeldung mit Benutzern, Gruppen und Rollen.
|
||||||
AUTH_ENABLED: ${AUTH_ENABLED:-false}
|
# Ohne Angabe: an. Abgeschaltet wird nur, wer ausdruecklich
|
||||||
|
# AUTH_ENABLED=false setzt — siehe .env.example.
|
||||||
|
AUTH_ENABLED: ${AUTH_ENABLED:-true}
|
||||||
|
# Nur einschalten, wenn wirklich ein Proxy davorsteht — siehe
|
||||||
|
# .env.example.
|
||||||
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-10}
|
||||||
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
SESSION_IDLE_DAYS: ${SESSION_IDLE_DAYS:-30}
|
||||||
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
SESSION_MAX_DAYS: ${SESSION_MAX_DAYS:-90}
|
||||||
|
|||||||
@@ -5,5 +5,17 @@ set -e
|
|||||||
echo "→ Datenbankmigrationen werden angewendet …"
|
echo "→ Datenbankmigrationen werden angewendet …"
|
||||||
node /app/node_modules/prisma/build/index.js migrate deploy
|
node /app/node_modules/prisma/build/index.js migrate deploy
|
||||||
|
|
||||||
|
# Eine Anwendung ohne Anmeldung sieht genauso aus wie eine mit — deshalb
|
||||||
|
# muss sie es sagen. Wer das hier im Protokoll sieht und nicht erwartet hat,
|
||||||
|
# soll sofort wissen, woran er ist.
|
||||||
|
if [ "$(printf '%s' "${AUTH_ENABLED:-}" | tr '[:upper:]' '[:lower:]')" = "false" ]; then
|
||||||
|
echo "" >&2
|
||||||
|
echo " !! ACHTUNG: AUTH_ENABLED=false — die Anwendung laeuft OHNE Anmeldung." >&2
|
||||||
|
echo " !! Jeder, der sie erreicht, darf alles: Verwaltung, Uploads, saemtliche" >&2
|
||||||
|
echo " !! Codes anlegen, aendern und loeschen. Das ist nur fuer Einzelplatz-" >&2
|
||||||
|
echo " !! oder LAN-Betrieb gedacht und gehoert nicht ins offene Netz." >&2
|
||||||
|
echo "" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
echo "→ Anwendung wird gestartet …"
|
echo "→ Anwendung wird gestartet …"
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -112,12 +112,42 @@ gescannt zu werden.
|
|||||||
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
das Scannen erschweren, weil der Scanner den Code nicht mehr vom Hintergrund
|
||||||
trennen kann.
|
trennen kann.
|
||||||
|
|
||||||
|
### Ein Logo in die Mitte setzen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Ganz unten in der Gestaltung steht **Logo in der Mitte**. Ein Haken genügt,
|
||||||
|
dann fragt die Anwendung, woher das Bild kommen soll:
|
||||||
|
|
||||||
|
- **Logo dieser Installation** — das, was unter Verwaltung → Erscheinungsbild
|
||||||
|
hinterlegt ist. Kein Upload je Code nötig. Die Wahl erscheint nur, wenn dort
|
||||||
|
überhaupt eines liegt.
|
||||||
|
- **Eigenes Logo** — je Code hochgeladen, für Kundenprojekte mit fremdem
|
||||||
|
Zeichen. PNG, JPEG, WebP oder AVIF, bis 2 MB.
|
||||||
|
|
||||||
|
Die **Größe** liegt zwischen 10 und 25 % der Kantenlänge; voreingestellt sind
|
||||||
|
20 %, das verdeckt rund 4 % der Fläche. Hinter dem Bild liegt eine Fläche in
|
||||||
|
der Hintergrundfarbe, damit es sich absetzt.
|
||||||
|
|
||||||
|
**Die Fehlerkorrektur springt dabei auf H** und lässt sich nicht mehr ändern —
|
||||||
|
was das Bild verdeckt, muss sie ausgleichen. Das steht auch so unter der
|
||||||
|
Auswahl; heimlich geschieht es nicht.
|
||||||
|
|
||||||
|
Ab 22 % erscheint eine Warnung. Die Fehlerkorrektur trägt das zwar, aber
|
||||||
|
manche Scanner-Apps suchen in der Mitte nach Ausrichtungsmustern.
|
||||||
|
|
||||||
|
> **Einmal selbst scannen, bevor etwas in den Druck geht.** Ein Code mit Logo
|
||||||
|
> ist die einzige Stelle, an der die Anwendung etwas ausliefern kann, das auf
|
||||||
|
> dem einen Gerät geht und auf dem anderen nicht. Zwei verschiedene Apps
|
||||||
|
> kosten eine halbe Minute und ersparen eine Neuauflage.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Hier stehen alle **dynamischen** Codes. Statische erscheinen nicht — sie
|
Hier stehen die **dynamischen** Codes und die statischen, die du abgelegt
|
||||||
werden nicht gespeichert.
|
hast. Beide Arten stehen nebeneinander in derselben Liste — ein statischer
|
||||||
|
trägt statt einer Kurz-URL das Abzeichen „statisch“ und zählt keine Scans.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -128,6 +158,11 @@ Projekte nebeneinander.
|
|||||||
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
- Die **Sortierung** kennt neueste, älteste, Name und Scan-Anzahl.
|
||||||
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
- Ein **Gruppenfilter** erscheint, sobald Codes in mehr als einer Gruppe
|
||||||
liegen.
|
liegen.
|
||||||
|
- Ein **Filter nach Art** erscheint, sobald dynamische und statische Codes
|
||||||
|
nebeneinander liegen: *Alle Arten*, *Nur dynamische*, *Nur statische*.
|
||||||
|

|
||||||
|
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.
|
- Das Symbol neben der Kurz-URL kopiert sie in die Zwischenablage.
|
||||||
|
|
||||||
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
Über die beiden Schalter rechts geht es zur **Kartenansicht** mit großer
|
||||||
@@ -135,6 +170,23 @@ Vorschau. Die Wahl merkt sich der Browser.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### Statische Codes ablegen
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
nur der Inhalt, nicht die Gestaltung: Derselbe Code in Schwarz-Weiß und in
|
||||||
|
Hausfarben gilt hier als Dublette.
|
||||||
|
|
||||||
|
Bei **dynamischen** Codes fragt niemand nach. Zwei Kurz-Links auf dasselbe
|
||||||
|
Ziel sind der Normalfall, sobald zwei Kampagnen getrennt gezählt werden.
|
||||||
|
|
||||||
### Ziel ändern
|
### Ziel ändern
|
||||||
|
|
||||||

|

|
||||||
@@ -247,6 +299,27 @@ sich Typen abschalten; sie verschwinden dann aus der Auswahl.
|
|||||||
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
unverändert weiter** — sie leiten weiter, zählen Scans und lassen sich
|
||||||
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
bearbeiten. Nur neue lassen sich nicht mehr anlegen.
|
||||||
|
|
||||||
|
**Freigabe je Gruppe.** Über der Liste steht die Auswahl **Freigabe für**:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Auswahl | Wirkung |
|
||||||
|
| --- | --- |
|
||||||
|
| Alle Gruppen (Grundeinstellung) | gilt für die ganze Installation |
|
||||||
|
| Nur: *eine Gruppe* | weicht für diese eine Gruppe ab |
|
||||||
|
|
||||||
|
So bekommt die Marketingabteilung Social Media und die Werkstatt nur Website
|
||||||
|
und PDF. Fehlt eine Abweichung, folgt die Gruppe der Grundeinstellung.
|
||||||
|
|
||||||
|
**Die Grundeinstellung gewinnt immer.** Ein Typ, der dort abgeschaltet ist,
|
||||||
|
lässt sich in keiner Gruppe einschalten — der Schalter steht dann ausgegraut
|
||||||
|
und nennt den Grund. Andersherum wäre „hier abgeschaltet“ keine verlässliche
|
||||||
|
Aussage mehr.
|
||||||
|
|
||||||
|
Wer in mehreren Gruppen ist, sieht auf der Startseite die **Vereinigung**.
|
||||||
|
Welche Freigabe zählt, entscheidet sich beim Anlegen anhand der gewählten
|
||||||
|
Gruppe.
|
||||||
|
|
||||||
### Gruppen
|
### Gruppen
|
||||||
|
|
||||||

|

|
||||||
@@ -275,7 +348,34 @@ mehreren Gruppen dienen; die Codes bleiben trotzdem strikt nach Gruppe
|
|||||||
getrennt.
|
getrennt.
|
||||||
|
|
||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten — siehe README, Abschnitt „Mehrere Domains“.
|
Anwendung leiten. Was dafür einzurichten ist, steht nicht nur im README: Der
|
||||||
|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
| Schritt | Was er sagt |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | ob der Name auflöst, und auf welche Adressen |
|
||||||
|
| Zertifikat | gültig bis wann, von wem ausgestellt |
|
||||||
|
| Erreichbar | ob dort ein Webserver antwortet, und wie schnell |
|
||||||
|
| Ziel | ob dahinter **diese** Installation steht |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig; die Prüfung lässt sich beweisen, dass es diese Anwendung ist.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
> **Der Aufruf startet dort, wo der Container läuft.** Bei internem DNS oder
|
||||||
|
> Hairpin-NAT kann er scheitern, obwohl von außen alles stimmt. Und
|
||||||
|
> umgekehrt: Ein Erfolg von innen beweist noch nicht, dass die Domain von
|
||||||
|
> außen erreichbar ist. Dafür bleibt der Blick von einem fremden Netz aus.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 258 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 72 KiB |
@@ -315,8 +315,9 @@ Die **Vorschau** wird serverseitig gerendert und aktualisiert sich bei jeder
|
|||||||
|
|
||||||
## Meine Codes
|
## Meine Codes
|
||||||
|
|
||||||
Zeigt alle dynamischen Codes — statische erscheinen nicht, weil sie nicht
|
Zeigt die dynamischen Codes und die statischen, die abgelegt wurden. Ein
|
||||||
gespeichert werden.
|
abgelegter statischer Code trägt statt einer Kurz-URL das Abzeichen
|
||||||
|
„statisch“ und zählt keine Scans.
|
||||||
|
|
||||||
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
- **Zwei Ansichten:** Liste (Voreinstellung, eine Zeile je Code) und Karten
|
||||||
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
mit großer Vorschau. Die Wahl merkt sich der Browser.
|
||||||
@@ -324,6 +325,10 @@ gespeichert werden.
|
|||||||
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
- **Sortierung** nach neueste, älteste, Name oder Scan-Anzahl.
|
||||||
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
- **Gruppenfilter**, sobald Codes in mehr als einer Gruppe liegen; dann gibt
|
||||||
es auch eine Gruppenspalte.
|
es auch eine Gruppenspalte.
|
||||||
|
- **Filter nach Art** (*Alle Arten*, *Nur dynamische*, *Nur statische*),
|
||||||
|
sobald beide Arten vorkommen. Bei „Nur statische“ verschwindet die
|
||||||
|
Scan-Spalte und mit ihr die Sortierung danach: Statische zählen keine
|
||||||
|
Scans, die Spalte wäre eine Reihe aus Strichen.
|
||||||
- **Kurz-URL kopieren** mit einem Klick.
|
- **Kurz-URL kopieren** mit einem Klick.
|
||||||
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
- **Ziel ändern** — die Kurz-URL und damit der gedruckte Code bleiben gültig.
|
||||||
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
- **Löschen** mit Rückfrage, die den betroffenen Code nennt.
|
||||||
@@ -426,6 +431,53 @@ Domains werden in der Verwaltung angelegt und Gruppen zugeteilt.
|
|||||||
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
Damit das funktioniert, muss der Reverse Proxy jede dieser Domains auf diese
|
||||||
Anwendung leiten.
|
Anwendung leiten.
|
||||||
|
|
||||||
|
### Kurzhilfe zur Einrichtung
|
||||||
|
|
||||||
|
Was außerhalb der Anwendung einzurichten ist, steht nicht nur im README: Auf
|
||||||
|
der Domain-Seite öffnet der Knopf **Kurzhilfe** eine Erklärung in sechs
|
||||||
|
Schritten — DNS-Eintrag, Weiterleitung im Reverse Proxy samt der drei nötigen
|
||||||
|
Kopfzeilen zum Abtippen, Zertifikat, Registrieren, Gruppenzuteilung,
|
||||||
|
Nachprüfen. Der Hostname aus `BASE_URL` wird als Beispiel eingesetzt.
|
||||||
|
|
||||||
|
Bewusst ein Dialog und kein Dauertext: Die Erklärung braucht man einmal beim
|
||||||
|
Einrichten und danach nie wieder.
|
||||||
|
|
||||||
|
### Prüfen, ob eine Domain hierher führt
|
||||||
|
|
||||||
|
Bei jedem Eintrag steht ein Knopf **Prüfen**. Er geht den Weg der Scanner nach
|
||||||
|
und meldet vier Dinge:
|
||||||
|
|
||||||
|
| Schritt | Was geprüft wird |
|
||||||
|
| --- | --- |
|
||||||
|
| DNS | Löst der Name auf? Auf welche Adressen? |
|
||||||
|
| Zertifikat | Gültig, von wem ausgestellt, wie lange noch? |
|
||||||
|
| Erreichbar | Antwortet dort ein Webserver, und wie schnell? |
|
||||||
|
| Ziel | Steht dahinter **diese** Installation? |
|
||||||
|
|
||||||
|
Der letzte Schritt ist der eigentliche Wert. Dass irgendwo etwas antwortet,
|
||||||
|
sagt wenig — es könnte die Parkseite des Registrars sein oder eine andere
|
||||||
|
Installation dieser Anwendung. Deshalb ruft die Prüfung unter der Domain eine
|
||||||
|
eigene Route auf und lässt sich einen HMAC über eine frische Zufallszahl
|
||||||
|
zurückgeben. Das Geheimnis dafür entsteht beim ersten Check, liegt in der
|
||||||
|
Datenbank und verlässt den Server nie.
|
||||||
|
|
||||||
|
Die Meldungen sind im Klartext: abgelaufen, selbst ausgestellt, gilt für einen
|
||||||
|
anderen Namen, Zwischenzertifikat fehlt, Port 443 nimmt nichts an, „es
|
||||||
|
antwortet etwas, aber nicht diese Anwendung“. Läuft ein Zertifikat in
|
||||||
|
höchstens 14 Tagen ab, gilt der Schritt als Warnung statt als Erfolg.
|
||||||
|
Scheitert schon DNS, fallen die drei Folgeschritte aus — dreimal dieselbe
|
||||||
|
Meldung würde die eine wichtige verdecken.
|
||||||
|
|
||||||
|
**Die Grenze der Aussage** steht unter jedem Bericht: Der Aufruf startet dort,
|
||||||
|
wo der Container läuft. Bei internem DNS oder Hairpin-NAT kann er scheitern,
|
||||||
|
obwohl von außen alles stimmt; umgekehrt beweist ein Erfolg von innen nicht,
|
||||||
|
dass die Domain von außen erreichbar ist.
|
||||||
|
|
||||||
|
Geprüft wird ausschließlich ein Hostname **aus der Datenbank**, nie einer aus
|
||||||
|
der Anfrage — sonst wäre die Route ein Werkzeug, mit dem sich vom Server aus
|
||||||
|
beliebige Adressen abklopfen ließen. Näheres in
|
||||||
|
[sicherheit.md](sicherheit.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Erscheinungsbild
|
## Erscheinungsbild
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ Fußzeile.
|
|||||||
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
Kurz gefasst: Version 1.0.0 (13.08.2026) brachte das Grundgerüst mit dem Typ
|
||||||
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
Website, 1.1.0 alle fünfzehn Typen samt Anmeldung, Gruppen und Domains,
|
||||||
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
1.2.0 Single Sign-on und die Sicherheitsdurchsicht, 1.3.0 Erscheinungsbild
|
||||||
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes
|
und Dark Mode, 1.4.0 sprechende Kurz-Links, 1.5.0 benennbare statische Codes,
|
||||||
und 1.6.0 diesen Versionshinweis.
|
1.6.0 den Versionshinweis und 1.7.0 sechs Erweiterungen auf einmal — Logo im
|
||||||
|
Code, Freigabe je Gruppe, Domain-Check, Dublettenprüfung, Filter nach Art und
|
||||||
|
die Kurzhilfe zu den Domains.
|
||||||
|
|
||||||
|
Damit sind zwei Punkte erledigt, die hier lange unter „Als Nächstes“ standen:
|
||||||
|
**QR-Typen je Gruppe freigeben** und **Logo im QR-Code**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,27 +30,7 @@ und 1.6.0 diesen Versionshinweis.
|
|||||||
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
In dieser Reihenfolge. Die Einschätzungen sind grobe Hausnummern für eine
|
||||||
Person, die den Code kennt.
|
Person, die den Code kennt.
|
||||||
|
|
||||||
### 1. QR-Typen je Gruppe freigeben · mittel
|
### 1. Statistik über die Zeit · mittel
|
||||||
|
|
||||||
Heute gilt die Freigabe eines Typs für die ganze Installation: Was
|
|
||||||
abgeschaltet ist, ist für alle weg. Bei mehreren Gruppen ist das zu grob — die
|
|
||||||
Marketingabteilung braucht Social Media, die Werkstatt nur Website und PDF.
|
|
||||||
|
|
||||||
Geplant: Die Freigabe bekommt eine zweite Dimension. Es bleibt eine globale
|
|
||||||
Einstellung als Grundlage, und je Gruppe lässt sich davon abweichen. Wer in
|
|
||||||
mehreren Gruppen ist, sieht auf der Startseite die Vereinigung; beim Anlegen
|
|
||||||
entscheidet die gewählte Gruppe.
|
|
||||||
|
|
||||||
Zu klären ist der Fall, in dem ein Typ global aus, in einer Gruppe aber an
|
|
||||||
ist — entweder die Gruppe darf das überstimmen, oder die globale Abschaltung
|
|
||||||
gewinnt immer. Die zweite Lesart ist die weniger überraschende und
|
|
||||||
wahrscheinlich die richtige.
|
|
||||||
|
|
||||||
Berührt: `QrTypeSetting` (zusammengesetzter Schlüssel aus Typ und Gruppe),
|
|
||||||
`src/lib/qr/availability.ts`, die Startseite und die Typ-Verwaltung, die dann
|
|
||||||
eine Gruppenauswahl bekommt.
|
|
||||||
|
|
||||||
### 2. Statistik über die Zeit · mittel
|
|
||||||
|
|
||||||
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
Der Zähler weiß heute nur, wie oft und wann zuletzt. Gebraucht wird der
|
||||||
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
Verlauf: Wie lief die Kampagne in den ersten Tagen, wann ist Ruhe
|
||||||
@@ -59,7 +44,7 @@ sagt nichts über einzelne Menschen aus und braucht deshalb weder Einwilligung
|
|||||||
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
noch Löschfristen. Wer mehr will, muss sich mit beidem befassen — das ist eine
|
||||||
andere Entscheidung als „ein Diagramm wäre schön“.
|
andere Entscheidung als „ein Diagramm wäre schön“.
|
||||||
|
|
||||||
### 3. Papierkorb statt Löschen · klein
|
### 2. Papierkorb statt Löschen · klein
|
||||||
|
|
||||||
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
Heute ist ein gedruckter Code nach einem Fehlklick unwiederbringlich tot,
|
||||||
samt seiner Dateien und Rückmeldungen.
|
samt seiner Dateien und Rückmeldungen.
|
||||||
@@ -69,20 +54,7 @@ Geplant: Gelöschte Codes 30 Tage aufheben und dann endgültig entfernen. In
|
|||||||
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
im Papierkorb liegt, bleibt sein Slug belegt — sonst könnte ihn ein neuer Code
|
||||||
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
bekommen und die gedruckten Codes zeigten plötzlich woandershin.
|
||||||
|
|
||||||
### 4. Logo im QR-Code · mittel
|
### 3. Ablaufdatum und Zeitsteuerung · mittel
|
||||||
|
|
||||||
Ein kleines Bild in die Mitte setzen, wie man es von Firmen-Codes kennt.
|
|
||||||
|
|
||||||
Geplant: Upload eines Logos je Code, mittig eingesetzt, mit erzwungener
|
|
||||||
Fehlerkorrektur **H** und einer Warnung, sobald zu viel Fläche verdeckt wird.
|
|
||||||
|
|
||||||
Das ist der Punkt mit dem größten Risiko, etwas Kaputtes auszuliefern: Ein
|
|
||||||
Code, der auf dem eigenen Handy noch funktioniert, kann bei anderen
|
|
||||||
Scanner-Apps durchfallen. Gehört deshalb nicht ohne eine ehrliche Prüfung mit
|
|
||||||
mehreren Geräten und Apps ausgeliefert — und mit einer klaren Obergrenze für
|
|
||||||
die verdeckte Fläche.
|
|
||||||
|
|
||||||
### 5. Ablaufdatum und Zeitsteuerung · mittel
|
|
||||||
|
|
||||||
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
Ein Code, der ab einem Datum woandershin führt oder abläuft — für
|
||||||
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
Veranstaltungen, Aktionen und Aushänge mit Verfallsdatum.
|
||||||
@@ -99,6 +71,20 @@ Geplant, in zwei Stufen:
|
|||||||
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
Zu bedenken: Zeitzonen und die Frage, was beim Bearbeiten mit vergangenen
|
||||||
Fenstern geschieht.
|
Fenstern geschieht.
|
||||||
|
|
||||||
|
### 4. Gestaltung eines gespeicherten Codes ändern · klein
|
||||||
|
|
||||||
|
Aufgefallen beim Einbau des Logos: In „Meine Codes“ lässt sich die Gestaltung
|
||||||
|
eines abgelegten Codes gar nicht mehr anfassen — weder Farben noch Größe noch
|
||||||
|
Logo. Wer ein Logo nachrüsten will, kommt nur über die Schnittstelle daran.
|
||||||
|
|
||||||
|
Bei einem **dynamischen** Code ist das unnötig streng: Das gedruckte Bild
|
||||||
|
bleibt zwar, wie es ist, aber der erneute Download soll ja gerade die neue
|
||||||
|
Gestaltung tragen. Geplant: derselbe Gestaltungsbereich wie im Generator, im
|
||||||
|
Bearbeiten-Dialog.
|
||||||
|
|
||||||
|
Bei einem **statischen** Code bleibt es beim Nein — er ist ein Schnappschuss,
|
||||||
|
und eine geänderte Gestaltung ergäbe ein anderes Bild als das gedruckte.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Später denkbar
|
## Später denkbar
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
* Voraussetzungen:
|
* Voraussetzungen:
|
||||||
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
* - eine **leere** Instanz (die Beispieldaten würden sonst mit fremden
|
||||||
* Codes im Bild landen), erreichbar unter BASE
|
* 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
|
* - AUTH_ENABLED=true, damit Kopfzeile und Verwaltung so aussehen wie im
|
||||||
* Regelbetrieb
|
* Regelbetrieb
|
||||||
* - Playwright: npm i -D playwright && npx playwright install chromium
|
* - 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 { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { chromium } from "playwright";
|
import { chromium } from "playwright";
|
||||||
|
import sharp from "sharp";
|
||||||
|
|
||||||
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
const BASE = (process.argv[2] ?? "http://localhost:3000").replace(/\/+$/, "");
|
||||||
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
const ZIEL = join(dirname(fileURLToPath(import.meta.url)), "bilder");
|
||||||
@@ -197,16 +201,47 @@ const handy = await browser.newContext({
|
|||||||
locale: "de-DE",
|
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();
|
const page = await context.newPage();
|
||||||
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
await page.goto(BASE + pfad, { waitUntil: "networkidle" });
|
||||||
if (vorher) await vorher(page);
|
if (vorher) await vorher(page);
|
||||||
await page.waitForTimeout(300);
|
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();
|
await page.close();
|
||||||
console.log(" ✓", datei);
|
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:");
|
console.log("Bilder werden erzeugt:");
|
||||||
|
|
||||||
await schuss(desktop, "/", "01-startseite.png");
|
await schuss(desktop, "/", "01-startseite.png");
|
||||||
@@ -242,7 +277,7 @@ await schuss(desktop, "/meine-codes", "06-ziel-bearbeiten.png", {
|
|||||||
await page.waitForTimeout(400);
|
await page.waitForTimeout(400);
|
||||||
// Bewusst die letzte Zeile: ein Website-Code, dessen Dialog ganz ins Bild passt.
|
// 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.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, "/verwaltung/domains", "14-verwaltung-domains.png");
|
||||||
await schuss(desktop, "/konto", "15-konto.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();
|
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 */
|
/* ------------------------------------------------------------ Aufräumen */
|
||||||
|
|
||||||
for (const eintrag of angelegt) {
|
for (const eintrag of angelegt) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
# Anmeldung und Sitzungen — Durchsicht nach OWASP
|
||||||
|
|
||||||
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
Durchsicht der Anmeldung, der Sitzungsverwaltung und der Berechtigungen gegen
|
||||||
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026.
|
die einschlägigen OWASP-Merkblätter. Stand: 15.08.2026, ergänzt am 24.08.2026
|
||||||
|
um die Befunde einer externen Codeprüfung (Punkte 11 bis 14 und die drei
|
||||||
|
offenen Punkte am Ende).
|
||||||
|
|
||||||
Geprüft gegen:
|
Geprüft gegen:
|
||||||
|
|
||||||
@@ -163,6 +165,171 @@ Nutzung abgewiesen —, aber die Tabelle wuchs unbegrenzt.
|
|||||||
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
**Behoben:** Beim Nachführen einer Sitzung (höchstens stündlich je Sitzung)
|
||||||
wird beiläufig aufgeräumt.
|
wird beiläufig aufgeräumt.
|
||||||
|
|
||||||
|
### 11. OIDC wertete eine unbestätigte E-Mail als bestätigt
|
||||||
|
|
||||||
|
**Befund:** Der schwerste der externen Prüfung. Findet die OIDC-Rückkehr ein
|
||||||
|
lokales Konto mit derselben E-Mail-Adresse, hängt sie die Identität des
|
||||||
|
Anbieters daran — aber nur, wenn die Adresse **bestätigt** ist. Genau das
|
||||||
|
prüfte der Code auch, nur war die Bestätigung falsch berechnet:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
emailVerified: payload.email_verified !== false
|
||||||
|
```
|
||||||
|
|
||||||
|
Damit galt nicht nur `true` als bestätigt, sondern auch ein **fehlender
|
||||||
|
Claim**, `null`, `0` und jede Zeichenkette. Ein Anbieter, der
|
||||||
|
`email_verified` gar nicht mitschickt — das ist erlaubt und kommt vor —,
|
||||||
|
hätte die Verknüpfung geöffnet. Wer sich dort eine fremde Adresse eintragen
|
||||||
|
kann, hätte damit das hiesige Konto übernommen, im schlimmsten Fall das eines
|
||||||
|
Administrators.
|
||||||
|
|
||||||
|
**Behoben:** Nur ein ausdrückliches `true` zählt. Alles andere führt zur
|
||||||
|
bisherigen Meldung „der Anbieter hat sie nicht bestätigt“, und die
|
||||||
|
Verknüpfung unterbleibt.
|
||||||
|
|
||||||
|
**Was das nicht war:** kein Angriff ohne Zugriff auf den Anbieter — das
|
||||||
|
ID-Token muss weiterhin gültig signiert sein und Aussteller, Empfänger und
|
||||||
|
Nonce bestehen.
|
||||||
|
|
||||||
|
### 12. Eine fehlende Angabe schaltete die Anmeldung ab
|
||||||
|
|
||||||
|
**Befund:** `isAuthEnabled()` verlangte den Wert `true`; alles andere hieß
|
||||||
|
„aus“. Eine vergessene oder falsch geschriebene Variable ergab damit eine
|
||||||
|
Anwendung, in der jeder Besucher alles darf — und nichts sah danach aus. Die
|
||||||
|
Compose-Vorgabe stand zusätzlich auf `false`.
|
||||||
|
|
||||||
|
**Behoben:** Umgedreht. Abgeschaltet wird nur, wer ausdrücklich `false`
|
||||||
|
setzt; ein fehlender Wert bedeutet „an“. Die Vorgaben in `docker-compose.yml`
|
||||||
|
und `.env.example` stehen auf `true`. Steht die Anmeldung aus, schreibt der
|
||||||
|
Container beim Start eine mehrzeilige Warnung ins Protokoll.
|
||||||
|
|
||||||
|
Der offene Betrieb bleibt möglich — er ist für Einzelplatz und LAN gedacht
|
||||||
|
und dokumentiert. Er ist nur nicht mehr das, was man versehentlich bekommt.
|
||||||
|
|
||||||
|
### 13. Wettlauf beim ersten Administrator über OIDC
|
||||||
|
|
||||||
|
**Befund:** Der OIDC-Pfad las `user.count()` und legte danach an. Zwei
|
||||||
|
gleichzeitige erste Anmeldungen konnten sich beide für die erste halten und
|
||||||
|
beide als Instanz-Administrator entstehen. Die Passwort-Ersteinrichtung
|
||||||
|
sicherte denselben Ablauf längst mit einer serialisierbaren Transaktion.
|
||||||
|
|
||||||
|
**Behoben:** Derselbe Schutz auch hier — Zählen und Anlegen liegen in einer
|
||||||
|
Transaktion mit `isolationLevel: "Serializable"`.
|
||||||
|
|
||||||
|
### 14. Die Herkunftsprüfung verglich das Schema nicht
|
||||||
|
|
||||||
|
**Befund:** `isSameOrigin()` verglich nur den Host. `http://example.com` galt
|
||||||
|
damit als eigen, obwohl die Anwendung unter `https://example.com` läuft.
|
||||||
|
|
||||||
|
**Behoben:** Das Schema wird mitverglichen, und es kommt aus `BASE_URL` — der
|
||||||
|
einen Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt.
|
||||||
|
|
||||||
|
**Zur Einordnung:** Die Prüfung vergleicht weiterhin gegen den `Host`- bzw.
|
||||||
|
`X-Forwarded-Host`-Header der Anfrage. Aus einem Browser heraus ist das nicht
|
||||||
|
zu missbrauchen — `Host` darf Skriptcode nicht setzen, und ein selbst
|
||||||
|
gesetztes `X-Forwarded-Host` macht die Anfrage zu einer, die erst einen
|
||||||
|
CORS-Vorabflug bestehen müsste, den die Anwendung nicht beantwortet. Das
|
||||||
|
verbleibende Unbehagen ist das Vertrauensmodell insgesamt; dazu unten mehr.
|
||||||
|
|
||||||
|
### 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
|
## Was schon vorher stimmte
|
||||||
@@ -218,6 +385,30 @@ selbst ausgestellt, falscher Name — statt bloß daran zu scheitern.
|
|||||||
|
|
||||||
Bewusst nicht umgesetzt, mit Begründung.
|
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
|
**E-Mail-Verifizierung.** Es gibt keine — und sie würde derzeit auch nichts
|
||||||
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
bringen. Die Anwendung kennt **keine Selbstregistrierung**: Es gibt nur
|
||||||
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
`login`, `setup`, `logout` und `change-password`. Konten legt ein
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "qrcode-generator",
|
"name": "qrcode-generator",
|
||||||
"version": "1.6.0",
|
"version": "1.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type OidcIdentity,
|
type OidcIdentity,
|
||||||
} from "@/lib/auth/oidc";
|
} from "@/lib/auth/oidc";
|
||||||
import { createSession } from "@/lib/auth/session";
|
import { createSession } from "@/lib/auth/session";
|
||||||
|
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getBaseUrl } from "@/lib/env";
|
import { getBaseUrl } from "@/lib/env";
|
||||||
|
|
||||||
@@ -100,6 +101,12 @@ async function findeOderLegeAn(
|
|||||||
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
// jemand eine fremde Adresse eintragen und damit ein hiesiges Konto
|
||||||
// übernehmen.
|
// übernehmen.
|
||||||
if (!identity.emailVerified) {
|
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 {
|
return {
|
||||||
fehler:
|
fehler:
|
||||||
"Es gibt hier schon ein Konto mit dieser E-Mail-Adresse, der Anbieter hat sie aber nicht bestätigt.",
|
"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 }),
|
...(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 };
|
return { id: verknuepft.id, isActive: verknuepft.isActive };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,21 +141,37 @@ async function findeOderLegeAn(
|
|||||||
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
// Der erste Mensch, der sich überhaupt anmeldet, wird Administrator —
|
||||||
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
// sonst gäbe es bei `OIDC_ONLY=true` niemanden, der die Verwaltung öffnen
|
||||||
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
// kann. Das entspricht der Ersteinrichtung bei der Passwort-Anmeldung.
|
||||||
const istErster = (await prisma.user.count()) === 0;
|
//
|
||||||
|
// Serialisierbar, aus demselben Grund wie dort: Zwischen „gibt es schon
|
||||||
const angelegt = await prisma.user.create({
|
// jemanden?" und dem Anlegen könnte sonst eine zweite Anmeldung
|
||||||
data: {
|
// dazwischenkommen, und beide würden sich für die erste halten. Am Ende
|
||||||
email: identity.email,
|
// stünden zwei Administratoren da, von denen einer ungebeten ist.
|
||||||
name: identity.name,
|
const angelegt = await prisma.$transaction(
|
||||||
passwordHash: null,
|
async (tx) => {
|
||||||
oidcSubject: identity.subject,
|
const istErster = (await tx.user.count()) === 0;
|
||||||
isInstanceAdmin: istAdmin ?? istErster,
|
return tx.user.create({
|
||||||
lastLoginAt: new Date(),
|
data: {
|
||||||
memberships: standardgruppe
|
email: identity.email,
|
||||||
? { create: { groupId: standardgruppe.id, role: config.defaultRole } }
|
name: identity.name,
|
||||||
: undefined,
|
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 };
|
return { id: angelegt.id, isActive: angelegt.isActive };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,18 +17,31 @@ import {
|
|||||||
} from "@/lib/auth/session";
|
} from "@/lib/auth/session";
|
||||||
import {
|
import {
|
||||||
clearAttempts,
|
clearAttempts,
|
||||||
|
kontoWartezeit,
|
||||||
requestOrigin,
|
requestOrigin,
|
||||||
|
teureRechnungErlaubt,
|
||||||
tooManyAttempts,
|
tooManyAttempts,
|
||||||
|
verbleibendeKontowartezeit,
|
||||||
} from "@/lib/auth/throttle";
|
} from "@/lib/auth/throttle";
|
||||||
|
import { sicherheitsereignis } from "@/lib/auth/protokoll";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
import { QrRequestError } from "@/lib/qr/type-module";
|
import { QrRequestError } from "@/lib/qr/type-module";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/** Sperrdauer nach zu vielen Fehlversuchen. */
|
/**
|
||||||
const MAX_ATTEMPTS = 8;
|
* Wartezeit statt Sperre.
|
||||||
const LOCK_MINUTES = 15;
|
*
|
||||||
|
* 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
|
* Wird gegen einen nicht vorhandenen Benutzer gerechnet, damit die
|
||||||
@@ -248,14 +261,31 @@ async function handleSetup(
|
|||||||
|
|
||||||
async function handleLogin(
|
async function handleLogin(
|
||||||
input: z.infer<typeof loginSchema>,
|
input: z.infer<typeof loginSchema>,
|
||||||
absender: string,
|
/** Absender laut vertrauenswürdigem Proxy — `null`, wenn keiner davorsteht. */
|
||||||
|
absender: string | null,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
// Vor allem anderen: Wer viele Konten durchprobiert, löst nie eine
|
// Wer viele Konten durchprobiert, löst nie eine Wartezeit für ein
|
||||||
// Kontosperre aus — dagegen hilft nur eine Bremse je Absender.
|
// einzelnes Konto aus — dagegen hilft nur eine Bremse je Absender. Sie
|
||||||
const wartezeit = tooManyAttempts(`login:${absender}`);
|
// greift nur hinter einem Proxy, dem ausdrücklich vertraut wird; sonst
|
||||||
if (wartezeit !== null) {
|
// 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(
|
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,
|
429,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -275,12 +305,15 @@ async function handleLogin(
|
|||||||
throw invalid;
|
throw invalid;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.lockedUntil && user.lockedUntil.getTime() > Date.now()) {
|
// Wartezeit des Kontos — **vor** der Passwortprüfung, damit ein Versuch,
|
||||||
const minutes = Math.ceil(
|
// der ohnehin abgewiesen wird, keine 128 MiB kostet.
|
||||||
(user.lockedUntil.getTime() - Date.now()) / 60000,
|
const kontoWarten = verbleibendeKontowartezeit(
|
||||||
);
|
user.failedLogins,
|
||||||
|
user.lockedUntil,
|
||||||
|
);
|
||||||
|
if (kontoWarten > 0) {
|
||||||
throw new QrRequestError(
|
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,
|
429,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -295,15 +328,17 @@ async function handleLogin(
|
|||||||
|
|
||||||
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
if (!(await verifyPassword(input.password, user.passwordHash))) {
|
||||||
const failedLogins = user.failedLogins + 1;
|
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({
|
await prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: {
|
data: { failedLogins, lockedUntil: new Date() },
|
||||||
failedLogins,
|
});
|
||||||
lockedUntil:
|
sicherheitsereignis("anmeldung-fehlgeschlagen", {
|
||||||
failedLogins >= MAX_ATTEMPTS
|
konto: user.id,
|
||||||
? new Date(Date.now() + LOCK_MINUTES * 60 * 1000)
|
fehlversuche: failedLogins,
|
||||||
: null,
|
wartezeit: kontoWartezeit(failedLogins),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
throw invalid;
|
throw invalid;
|
||||||
}
|
}
|
||||||
@@ -333,7 +368,7 @@ async function handleLogin(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
clearAttempts(`login:${absender}`);
|
if (absender !== null) clearAttempts(`login:${absender}`);
|
||||||
await createSession(user.id);
|
await createSession(user.id);
|
||||||
return Response.json({ ok: true, email: user.email });
|
return Response.json({ ok: true, email: user.email });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { requestOrigin } from "@/lib/auth/throttle";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
import { errorResponse, firstZodMessage, jsonError } from "@/lib/http";
|
||||||
|
|
||||||
@@ -31,6 +32,31 @@ const FENSTER_MS = 60_000;
|
|||||||
const MAX_PRO_FENSTER = 10;
|
const MAX_PRO_FENSTER = 10;
|
||||||
const zaehler = new Map<string, { anzahl: number; bis: number }>();
|
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 {
|
function zuSchnell(schluessel: string): boolean {
|
||||||
const jetzt = Date.now();
|
const jetzt = Date.now();
|
||||||
const eintrag = zaehler.get(schluessel);
|
const eintrag = zaehler.get(schluessel);
|
||||||
@@ -58,12 +84,19 @@ export async function POST(request: NextRequest): Promise<Response> {
|
|||||||
return jsonError(firstZodMessage(parsed.error));
|
return jsonError(firstZodMessage(parsed.error));
|
||||||
}
|
}
|
||||||
|
|
||||||
const herkunft =
|
// Erst der Deckel für alle — er lässt sich nicht über Kopfzeilen umgehen.
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
if (insgesamtZuSchnell()) {
|
||||||
request.headers.get("x-real-ip") ||
|
return jsonError(
|
||||||
"unbekannt";
|
"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(
|
return jsonError(
|
||||||
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
"Zu viele Rückmeldungen in kurzer Zeit. Bitte versuche es gleich noch einmal.",
|
||||||
429,
|
429,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
import { THEME_INIT_SCRIPT } from "@/components/theme-toggle";
|
||||||
import { accentCss } from "@/lib/branding-colors";
|
import { accentCss } from "@/lib/branding-colors";
|
||||||
@@ -47,6 +48,11 @@ export async function generateMetadata(): Promise<Metadata> {
|
|||||||
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
||||||
const branding = await getBranding();
|
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 (
|
return (
|
||||||
<html lang="de" className="h-full">
|
<html lang="de" className="h-full">
|
||||||
<body className="flex min-h-full flex-col">
|
<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
|
Wer der Systemeinstellung folgt, braucht das nicht — das erledigt
|
||||||
`light-dark()` im CSS von allein.
|
`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
|
Überschreibt die Farbvariablen, die Tailwind ohnehin benutzt — damit
|
||||||
wirkt die Akzentfarbe an jeder Stelle der Oberfläche, ohne dass eine
|
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.
|
die Voreinstellung zurück.
|
||||||
*/}
|
*/}
|
||||||
<style
|
<style
|
||||||
|
nonce={nonce}
|
||||||
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
dangerouslySetInnerHTML={{ __html: accentCss(branding.accentColor) }}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -98,13 +98,21 @@ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`}</HelpCode>
|
|||||||
</HelpStep>
|
</HelpStep>
|
||||||
|
|
||||||
<HelpStep n={6} title="Nachprüfen">
|
<HelpStep n={6} title="Nachprüfen">
|
||||||
<p>Von außerhalb des eigenen Netzes:</p>
|
<p>
|
||||||
|
Dafür gibt es den Knopf <strong>Prüfen</strong> bei jedem Eintrag
|
||||||
|
in der Liste. Er meldet DNS, Zertifikat, Erreichbarkeit — und ob
|
||||||
|
hinter der Domain wirklich diese Installation steht.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der Aufruf startet dort, wo dieser Container läuft. Bei internem
|
||||||
|
DNS oder Hairpin-NAT kann er scheitern, obwohl von außen alles
|
||||||
|
stimmt. Von einem fremden Netz aus hilft dann:
|
||||||
|
</p>
|
||||||
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
<HelpCode>{`curl -I https://${beispiel}/`}</HelpCode>
|
||||||
<p>
|
<p>
|
||||||
Kommt eine Antwort dieser Anwendung, stimmen DNS, Proxy und
|
Zum Schluss einen Code der zugeteilten Gruppe anlegen, die Domain
|
||||||
Zertifikat. Danach einen Code der zugeteilten Gruppe anlegen, die
|
auswählen und den fertigen Code einmal mit dem Handy scannen — das
|
||||||
Domain auswählen und den fertigen Code einmal mit dem Handy
|
ist die einzige Prüfung, die wirklich zählt.
|
||||||
scannen — das ist die einzige Prüfung, die wirklich zählt.
|
|
||||||
</p>
|
</p>
|
||||||
</HelpStep>
|
</HelpStep>
|
||||||
</ol>
|
</ol>
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ export type Actor =
|
|||||||
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
|
* Bewusst eine Umgebungsvariable und kein Schalter in der Oberfläche: so
|
||||||
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
|
* kann man sich nicht aussperren. Wer den Zugang verliert, setzt
|
||||||
* `AUTH_ENABLED=false` in der .env und startet neu.
|
* `AUTH_ENABLED=false` in der .env und startet neu.
|
||||||
|
*
|
||||||
|
* **Abgeschaltet wird nur auf ausdrückliche Ansage.** Ein fehlender oder
|
||||||
|
* leerer Wert bedeutet „an“. Vorher war es umgekehrt: Wer die Variable
|
||||||
|
* vergaß, bekam eine Anwendung, in der jeder Besucher alles darf — und
|
||||||
|
* merkte es nicht, weil nichts danach aussah.
|
||||||
*/
|
*/
|
||||||
export function isAuthEnabled(): boolean {
|
export function isAuthEnabled(): boolean {
|
||||||
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() === "true";
|
return (process.env.AUTH_ENABLED ?? "").trim().toLowerCase() !== "false";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActor(): Promise<Actor> {
|
export async function getActor(): Promise<Actor> {
|
||||||
|
|||||||
@@ -326,7 +326,14 @@ export async function exchangeCode(options: {
|
|||||||
return {
|
return {
|
||||||
subject,
|
subject,
|
||||||
email,
|
email,
|
||||||
emailVerified: payload.email_verified !== false,
|
// Nur ein ausdrückliches `true` zählt. Ein fehlender Claim, `null`, `0`
|
||||||
|
// oder die Zeichenkette „true" gelten als **nicht** bestätigt.
|
||||||
|
//
|
||||||
|
// Vorher stand hier `!== false`, und damit war das Gegenteil der Fall:
|
||||||
|
// Ein Anbieter, der den Claim gar nicht schickt, hätte die Verknüpfung
|
||||||
|
// mit einem bestehenden Konto erlaubt — genau das, was die Prüfung in
|
||||||
|
// der Callback-Route verhindern soll.
|
||||||
|
emailVerified: payload.email_verified === true,
|
||||||
name,
|
name,
|
||||||
groups: readGroups(payload, config.groupsClaim),
|
groups: readGroups(payload, config.groupsClaim),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
import "server-only";
|
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
|
* Drei Stufen, die verschiedene Dinge schützen:
|
||||||
* einzelnes Konto, diese Bremse den Server vor jemandem, der viele Konten
|
|
||||||
* gleichzeitig durchprobiert — dabei löst die Sperre nämlich nie aus.
|
|
||||||
*
|
*
|
||||||
* Im Arbeitsspeicher und damit pro Container. Bei einer selbst gehosteten
|
* 1. **Je Absender** (`tooManyAttempts`) — greift nur, wenn ein Proxy davor
|
||||||
* Anwendung dieser Größe ist das angemessen; ein verteilter Zähler bräuchte
|
* steht, dem ausdrücklich vertraut wird. Sonst wählt der Aufrufer seine
|
||||||
* einen weiteren Dienst. Die Adresse dient nur als Schlüssel und wird
|
* Kennung selbst und die Bremse wäre eine Behauptung.
|
||||||
* nirgends gespeichert.
|
* 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 };
|
type Eintrag = { anzahl: number; bis: number };
|
||||||
@@ -53,17 +63,109 @@ export function clearAttempts(schluessel: string): void {
|
|||||||
zaehler.delete(schluessel);
|
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
|
* `X-Forwarded-For` kann jeder Aufrufer selbst setzen. Nur wenn ein Proxy
|
||||||
* das hier eine Bremse und keine Sperre: Wer den Wert fälscht, umgeht sie,
|
* davorsteht, der die Kopfzeile **überschreibt**, sagt sie etwas aus. Das
|
||||||
* aber die Kontosperre greift weiterhin.
|
* 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 (
|
return (
|
||||||
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
headers.get("x-real-ip") ||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import pkg from "../../package.json";
|
|||||||
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
* unweigerlich auseinander; eine Prüfung wacht darüber, dass die erzeugte
|
||||||
* Datei zu dieser Quelle passt.
|
* Datei zu dieser Quelle passt.
|
||||||
*
|
*
|
||||||
* Die Einträge sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
* Die Einträge bis 1.5.0 sind aus dem Abschnitt „Gebaut“ der Roadmap und der
|
||||||
* Git-Historie zusammengetragen. Versionsnummern gab es dabei nie — es
|
* Git-Historie zusammengetragen: Versionsnummern gab es damals nicht, deshalb
|
||||||
* existieren keine Tags —, deshalb ist je **Erscheinungstag** eine Version
|
* ist je **Erscheinungstag** eine gebildet. Nachträglich vergeben, aber die
|
||||||
* gebildet. Das ist nachträglich vergeben, aber die Daten selbst stimmen.
|
* Daten selbst stimmen. Ab 1.6.0 trägt jede Fassung einen Git-Tag.
|
||||||
*/
|
*/
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -23,6 +23,29 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: readonly 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",
|
||||||
|
summary: "Logo im Code, Freigabe je Gruppe und ein Domain-Check.",
|
||||||
|
changes: [
|
||||||
|
"Ein Logo in der Mitte des Codes — das der Installation oder ein eigenes je Code, 10 bis 25 % der Kantenlänge. Die Fehlerkorrektur springt dabei auf H.",
|
||||||
|
"QR-Typen lassen sich je Gruppe freigeben. Die Grundeinstellung bleibt die Grundlage; eine globale Abschaltung gewinnt immer.",
|
||||||
|
"Der Knopf „Prüfen“ bei jeder Domain meldet DNS, Zertifikat, Erreichbarkeit — und ob dahinter wirklich diese Installation steht.",
|
||||||
|
"Eine Kurzhilfe auf der Domain-Seite führt in sechs Schritten auf, was für eine neue Domain einzurichten ist.",
|
||||||
|
"„Meine Codes“ lässt sich nach Art filtern: alle, nur dynamische, nur statische.",
|
||||||
|
"Beim Ablegen eines statischen Codes wird gewarnt, wenn derselbe Inhalt schon in der Gruppe liegt.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "1.6.0",
|
version: "1.6.0",
|
||||||
date: "2026-08-21",
|
date: "2026-08-21",
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export function isSameOrigin(headers: Headers): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Das Schema kommt aus `BASE_URL`, nicht aus der Anfrage: Es ist die eine
|
||||||
|
// Angabe zum Ursprung, über die der Aufrufer nicht mitbestimmt. Ohne diesen
|
||||||
|
// Vergleich gälte `http://example.com` als eigen, obwohl die Anwendung nur
|
||||||
|
// unter `https://example.com` läuft.
|
||||||
|
const erwartetesSchema = (process.env.BASE_URL ?? "").startsWith("http://")
|
||||||
|
? "http:"
|
||||||
|
: "https:";
|
||||||
|
if (herkunft.protocol !== erwartetesSchema) return false;
|
||||||
|
|
||||||
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
const host = headers.get("x-forwarded-host") ?? headers.get("host");
|
||||||
return Boolean(host) && herkunft.host === host;
|
return Boolean(host) && herkunft.host === host;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
* Zwei voneinander unabhängige Eigenschaften bestimmen, was ein Typ kann:
|
||||||
*
|
*
|
||||||
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
* - `implemented` — steht in der Registry und sagt, ob der Typ überhaupt
|
||||||
* ausprogrammiert ist. In V1 gilt das nur für „Website“.
|
* ausprogrammiert ist. Seit 1.1.0 gilt das für alle fünfzehn; die
|
||||||
|
* Unterscheidung bleibt für Typen, die später dazukommen.
|
||||||
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
|
* - `enabled` — kommt aus der Datenbank und sagt, ob die Verwaltung den Typ
|
||||||
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
|
* freigegeben hat. Fehlt der Eintrag, ist der Typ freigegeben.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,17 +26,68 @@ import { isSameOrigin } from "@/lib/http";
|
|||||||
const SCHREIBEND = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
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
|
* Der Video-Typ bettet YouTube und Vimeo ein — beide brauchen einen eigenen
|
||||||
* bringt eingebettete Skripte mit, dafür bräuchte es Nonces durch die ganze
|
* Eintrag, sonst bliebe der Rahmen nach dem Klick leer. Mehr steht hier
|
||||||
* Anwendung. Eine halbherzige Policy mit `unsafe-inline` sähe nur nach
|
* nicht: Was nicht eingebettet wird, gehört auch nicht in die Liste.
|
||||||
* Schutz aus. Was hier steht, wirkt dagegen sofort.
|
|
||||||
*/
|
*/
|
||||||
|
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> = {
|
const HEADER: Record<string, string> = {
|
||||||
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
|
// Kein Einbetten in fremde Seiten — sonst ließe sich die Verwaltung hinter
|
||||||
// einer harmlos aussehenden Seite verstecken und fernsteuern.
|
// einer harmlos aussehenden Seite verstecken und fernsteuern. Steht auch
|
||||||
"Content-Security-Policy": "frame-ancestors 'none'",
|
// in der CSP; dieser Header erreicht ältere Browser.
|
||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
// Der Browser soll den Inhaltstyp nicht raten.
|
// Der Browser soll den Inhaltstyp nicht raten.
|
||||||
"X-Content-Type-Options": "nosniff",
|
"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)) {
|
for (const [name, wert] of Object.entries(HEADER)) {
|
||||||
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
|
// Routen, die eigene Regeln setzen — etwa die Sandbox für hochgeladene
|
||||||
// Dateien —, sollen sie behalten.
|
// Dateien —, sollen sie behalten.
|
||||||
|
|||||||