Sicherheit
Dieses Dokument behandelt die Sicherheit des Admin-Bereichs und die Querschnittsthemen (Logging, Secrets, Header). Die Schutzschichten der Public API (Challenge/PoW/Quarantäne …) sind fachlich im Konzept (§5) beschrieben und technisch in architecture.md („Der Submit-Pfad”).
Überblick
Abschnitt betitelt „Überblick“| Baustein | Technologie | Ort |
|---|---|---|
| Passwort-Hashing | argon2id (OWASP-Parameter) | internal/core/services/auth_service.go |
| User-Auth | JWT (HS256), 60 min TTL | AuthService + middlewares.Authenticate |
| Service-Auth | API-Keys (SHA-256-gehasht) | APIKeyService |
| Autorisierung | RBAC: User → Rollen → Permissions (statischer Katalog) | middlewares.RequirePermission |
| Secrets in der DB | ORM++-Feld-Verschlüsselung (AES-256-GCM) | orm:"encrypted"-Tags |
| Secrets auf Platte | generierte config.json (0600) | internal/config/config.go |
| Angriffe an die Firewall | CrowdSec LAPI (Watcher-JWT ⇒ Ban-Decision) | internal/crowdsec, pipeline.ThreatTracker |
Passwörter: argon2id
Abschnitt betitelt „Passwörter: argon2id“Passwörter werden ausnahmslos mit argon2id gehasht (64 MiB Speicher, 4 Threads — resistent gegen GPU-Cracking). Es gibt im gesamten System keinen Ort, an dem ein Klartext-Passwort eines Admin-Users gespeichert wird. Der Login vergleicht bei unbekanntem Usernamen gegen einen Dummy-Hash, damit sich „User existiert nicht” und „Passwort falsch” zeitlich nicht unterscheiden lassen, und liefert für beide Fälle denselben Fehler (kein User-Enumeration-Leak).
Login-Drossel: POST /api/v1/auth/login ist unauthentifiziert, und
jeder Versuch kostet einen argon2id-Hash mit 64 MiB Speicher — ungebremst
wäre der Endpunkt damit nicht nur ein Brute-Force-Ziel, sondern auch ein
billiger Speicher-DoS. login_max_attempts (Default 10, 0 = aus)
begrenzt deshalb die Fehlversuche je 5-Minuten-Fenster, getrennt gezählt
pro Client-IP und pro Benutzername — ein verteilter Angriff auf das
admin-Konto läuft sonst an der IP-Grenze vorbei. Überschreitung
beantwortet der Server mit 429 und Retry-After. Erfolgreiche Logins
werden wieder gutgeschrieben: Wer sein Passwort kennt, sperrt sich nicht
selbst aus.
JWT-Lebenszyklus
Abschnitt betitelt „JWT-Lebenszyklus“- Login (
POST /api/v1/auth/login): Nach argon2id-Verifikation stellt derAuthServiceein HS256-signiertes JWT aus. Claims: User-ID (sub, UUID), Username,iat/exp. - Request: Das Frontend sendet
Authorization: Bearer <jwt>. DieAuthenticate-Middleware validiert Signatur und Ablauf — die Signaturmethode ist explizit auf HS256 festgenagelt (jwt.WithValidMethods), was Algorithm-Confusion verhindert. - Rollenauflösung: Permissions stehen nicht im Token. Bei jedem Request wird der User frisch aus der DB geladen — Rollenänderungen und Deaktivierungen greifen sofort.
- Ablauf: Nach
access_token_ttl_minutes(Default 60) antwortet der Server 401 → das Frontend loggt automatisch aus (frontend_api.md).
Das JWT-Secret wird beim ersten Start kryptografisch zufällig generiert
(48 Bytes aus crypto/rand) und liegt nur in der config.json
(Dateirechte 0600). Secrets unter 32 Zeichen werden beim Laden abgelehnt.
RBAC: statischer Katalog
Abschnitt betitelt „RBAC: statischer Katalog“Rollen und Permissions sind Code, keine DB-Tabellen
(internal/core/domain/rbac.go): Sie ändern sich nur mit einem Release.
User tragen ihre Rollennamen als Feld; die Auflösung auf Permissions
passiert in-memory.
User "alice" ──> Rolle "admin" ──> alle PermissionsUser "bob" ──> Rolle "viewer" ──> tenants:read, routes:read, submissions:readRouten schützt man deklarativ:
tenants.Get("/", middlewares.RequirePermission(domain.PermTenantsRead), ctrl.List)tenants.Post("/:id/active", middlewares.RequirePermission(domain.PermTenantsWrite), ctrl.SetActive)RequirePermission antwortet 401 (nicht eingeloggt) bzw. 403 (Recht
fehlt). Neue Permission einführen: Konstante in domain/rbac.go
definieren (ressource:aktion), im Rollenkatalog (RoleDefs) zuweisen,
Route schützen; im Frontend optional auth.can('...') — reine Kosmetik,
die echte Prüfung macht immer der Server.
API-Keys für Service-Kommunikation
Abschnitt betitelt „API-Keys für Service-Kommunikation“Für Prozesse ohne Browser (CI, Monitoring) gibt es API-Keys
(X-API-Key-Header):
- Erzeugung über
POST /api/v1/apikeys(Permissionapikeys:manage); der Klartext-Key (fgw_…) ist nur in dieser einen Response enthalten. - Gespeichert wird ausschließlich der SHA-256-Hash.
- Jeder Key läuft im Rechte-Kontext des erstellenden Users — RBAC gilt unverändert.
- Keys können ablaufen (
expires_in_days) und widerrufen werden. - Ein Key ist nie mehr wert als das Konto dahinter: Ist der User deaktiviert, wird der Key abgelehnt — genau wie im JWT-Pfad.
Scopes: "read" erlaubt nur GET/HEAD/OPTIONS — schreibende
Methoden lehnt die Authenticate-Middleware mit 403 ab, bevor
Controller-Code läuft. Rate-Limiting:
api_key_rate_limit_per_minute (Default 120, 0 = aus) drosselt pro
Key und Minute, greift vor der Key-Validierung (auch Brute-Force mit
ungültigen Keys) und antwortet 429 mit Retry-After. Browser-
Sessions sind nicht betroffen.
Der Seeding-User system existiert für Hintergrundprozesse; er kann
sich nicht einloggen (IsSystem).
Secrets in der Datenbank
Abschnitt betitelt „Secrets in der Datenbank“Tenant.SigningSecret (signiert die Challenge-Tokens) und
SmtpRoute.Password liegen per ORM++-Feld-Verschlüsselung
(AES-256-GCM) nur als Ciphertext in der DB. Der Schlüssel ist
encryption_key in der config.json — sichern; ohne ihn sind diese
Felder unlesbar. Beide Werte werden von der API nie serialisiert
(json:"-", View-Structs).
Kompromittierung einer Kundenseite leakt nichts Sensibles: Der Site-Key im Client-JS ist eine öffentliche Kennung, das Signing-Secret und die SMTP-Zugangsdaten leben nur im Gateway (Konzept §5.8). Bei Verdacht: Secret-Rotation per Knopf im Admin (macht laufende Challenges ungültig).
Public API: bewusst wortkarg
Abschnitt betitelt „Public API: bewusst wortkarg“Ablehnungen der Public API sind immer
400 {"ok":false,"reason":"rejected"} — welcher Schutz griff, steht nur
im Log und im Admin (Konzept §8). Honeypot-Treffer und Quarantäne sehen
für den Client wie Erfolg aus (kein Oracle für Spammer). Empfänger- und
Absenderadressen kommen nie aus dem Formular; Header-Injection wird
in der Eingabe-Härtung (CRLF-Filter, Adress-Validierung) abgefangen.
Logging & Access-Log
Abschnitt betitelt „Logging & Access-Log“Der Log-Service (internal/logging)
basiert auf log/slog:
- Level über
log_level(debug,info,warn,error);./form-gateway -debughebt es zur Laufzeit an. - Access-Log (
access_log: true): Methode, Pfad, Status, Dauer, IP, Username; 4xx alsWARN, 5xx alsERROR. - Es werden niemals Passwörter, Tokens, Signing-Secrets oder komplette Formularinhalte geloggt. Client-IPs landen in der DB nur als gekürzter SHA-256-Hash (Datensparsamkeit, Konzept §10).
- Sprache: Log-Meldungen sind standardmäßig Englisch; bei deutscher
Systemsprache (
LANG=de*) Deutsch ohne Umlaute (internal/locale). Log-Schlüssel (tenant,error, …) sind immer englisch. Signal- und Ablehnungsgründe (honeypot,challenge ungueltig, …) sind Daten — sprachunabhängig fest, da sie auch in Events gespeichert und im Admin-UI angezeigt werden.
CrowdSec: Angriffsmuster an die Firewall melden
Abschnitt betitelt „CrowdSec: Angriffsmuster an die Firewall melden“Die Prüf-Pipeline weist einzelne Requests ab — sie hält den Angreifer aber nicht davon ab, es weitere tausend Mal zu versuchen. Deshalb kann das Gateway auffällige IPs an eine CrowdSec Local API (LAPI) melden. Ein dort registrierter Bouncer (nftables/iptables, OPNsense/pfSense, Traefik, nginx …) zieht die Entscheidung und sperrt die IP in der vorgelagerten Firewall: Der nächste Request erreicht den Service gar nicht erst. Standardmäßig ist das Feature aus.
Wann gemeldet wird
Abschnitt betitelt „Wann gemeldet wird“Jeder Abwehr-Treffer der Pipeline ist ein Signal mit einem Gewicht.
Die Gewichte einer IP werden über ein gleitendes Fenster
(window_minutes, Default 10) summiert; ab score_threshold
(Default 100) geht die Meldung raus.
| Signal | Gewicht | Warum |
|---|---|---|
honeypot | 50 | Nur ein Bot füllt das unsichtbare Feld |
replay | 50 | Wiederverwendete Nonce = Skript |
pow ungueltig | 35 | Gefälschte oder fehlende PoW-Lösung |
challenge ungueltig | 30 | Manipuliertes oder fremdes Token |
site-key/origin unbekannt | 25 | Probing nach gültigen Site-Keys |
reputation | 25 | Tor-Exit, geblockte ASN/IP |
herkunftsland gesperrt | 10 | Geo-Regel des Mandanten (niedrig: Policy, kein Angriffsbeweis) |
absender-adresse geblockt | 20 | Reply-To auf der Blocklist |
zu schnell ausgefuellt | 20 | Unter der Mindest-Ausfüllzeit |
rate-limit | 15 | Allein nur Last, kein Angriffsbeweis |
eingabe-haertung | 10 | Übergroße/kaputte Felder |
Das ist bewusst so austariert, dass kein einzelner Fehlversuch eines echten Nutzers zu einer Sperre führt: Eine abgelaufene Challenge oder ein zu schnell abgeschicktes Formular bleiben deutlich unter der Schwelle. Erst die Kombination oder Wiederholung — also das, was ein Skript produziert — reißt sie. Ein erschöpftes Tageskontingent des Mandanten zählt gar nicht mit, sonst träfe es dessen echte Besucher.
Nach einer Meldung gilt die Sperrdauer als Cooldown: Solange die Firewall ohnehin blockt, wird dieselbe IP nicht erneut gemeldet.
Einrichtung
Abschnitt betitelt „Einrichtung“Auf dem CrowdSec-Host einen Watcher für das Gateway anlegen:
cscli machines add form-gateway --password 'ein-langes-passwort'Dann in der config.json (oder per Umgebungsvariablen):
"crowdsec": { "enabled": true, "lapi_url": "http://127.0.0.1:8080", "machine_id": "form-gateway", "password": "ein-langes-passwort", "ban_duration": "4h"}Nach dem Neustart prüfen — gemeldete IPs tauchen mit Szenario
techeve/form-gateway auf:
cscli decisions list --scenario techeve/form-gatewayWie die Meldung technisch läuft
Abschnitt betitelt „Wie die Meldung technisch läuft“Das Gateway meldet sich als Watcher an (POST /v1/watchers/login
mit machine_id/password ⇒ JWT) und schickt pro erkannter IP einen
Alert mit angehängter Ban-Decision an POST /v1/alerts — dasselbe
Vorgehen wie cscli decisions add. Das JWT wird gecacht und nach einem
401 automatisch erneuert.
Der Versand läuft asynchron über eine gepufferte Queue: Eine langsame oder nicht erreichbare LAPI darf einen Formular-Request niemals bremsen. Läuft die Queue voll, werden Meldungen verworfen statt zu warten (fail-open) — die eigenen Schutzschichten tragen weiterhin.
Weitere Maßnahmen
Abschnitt betitelt „Weitere Maßnahmen“- Fehler-Handling: Interne Fehler erreichen den Client nur als generisches „interner Serverfehler”.
- Security-Header:
X-Content-Type-Options,X-Frame-Options,Referrer-Policy,Permissions-Policy,Cross-Origin-Opener-Policysowie eine Content-Security-Policy (middlewares/security.go).Strict-Transport-Securitywird nur über HTTPS gesetzt. - Nicht in Suchmaschinen: Das Gateway ist eine Admin-Oberfläche und
gehört nicht in den Index. Drei Schichten:
X-Robots-Tag: noindex, nofollowauf jeder Antwort (verbindlich — die JSON-Antworten von/api/v1und/v1haben weder robots.txt noch meta-Tag, und bei API-only-Deployments ohne eingebettetes Frontend gibt es gar keine robots.txt), zusätzlich<meta name="robots">in der SPA und einerobots.txt. Letztere sperrt alles außer dem Client-Snippet: Googlebot rendert Kundenseiten und lädt dabei eingebundene Skripte mit — wäre/form-gateway.jsgesperrt, beschädigte unsere robots.txt die SEO fremder Seiten. - CSP: Tragender Teil ist
script-src 'self'ohne'unsafe-inline'und'unsafe-eval'— das Admin-JWT liegt im localStorage, eine einzige XSS-Lücke würde es sonst direkt ausleiten. Die SPA lädt ausschließlich eigene, von Vite gebaute Assets (kein CDN, kein Inline-Script).style-srcerlaubt'unsafe-inline', weil Svelte-Komponentenstyle="…"-Attribute setzen; Inline-CSS ist ohne Script-Ausführung ungefährlich.Cross-Origin-Resource-Policywird bewusst nicht gesetzt — sonst könnten Kundenseiten/form-gateway.jsnicht mehr einbinden. - Mail-Header: Empfänger- und Absenderadressen werden mit
mail.ParseAddressgeprüft (genau eine blanke Adresse — eine Liste wieziel@x.tld, mitleser@y.tldschmuggelte sonst einen stillen Empfänger in jede Mail). Zusätzlich entschärft der Mailer beim Bauen der Nachricht jeden Header-Wert (kein CR/LF, keine Steuerzeichen) — die letzte Bastion gegen Header-Injection. - Selbstheilung (systemd-Watchdog): Die Unit läuft mit
Type=notifyundWatchdogSec=30. Die Anwendung (internal/watchdog) meldetREADY=1erst, wenn Health-Endpunkt und Datenbank tatsächlich antworten, und füttert den Watchdog danach nur, solange beide Selbsttests weiter bestehen. Ein hängender Prozess — lebendig, aber nicht mehr ansprechbar (Deadlock, stehende DB) — wird so von systemd nach 30 s mit SIGABRT beendet (Go schreibt dann alle Goroutine-Stacktraces ins Journal: Ursache statt nur Symptom) und neu gestartet. Abstürze fängtRestart=always(2 s Backoff, max. 10 Fehlstarts in 5 min), Speicherlecks begrenzenMemoryHigh=512M/MemoryMax=768M— ein OOM-Kill trifft den Dienst, nicht den Host, und mündet ebenfalls im automatischen Neustart. Außerhalb von systemd (Entwicklung, Demo) ist der Watchdog-Code ein No-op. - Zeitlimits:
ReadTimeout/WriteTimeout/IdleTimeoutsind gesetzt (gegen Slowloris), der Body ist auf 1 MiB begrenzt — alle Bodys sind JSON, Uploads gibt es keine. - CORS:
/v1spiegelt den Origin nur für die Browser-Mechanik — die echte Origin-Prüfung passiert serverseitig gegen die Allowlist des Mandanten (CORS erzwingt der Browser, nicht der Server). - Build-Gates:
make buildbricht beinpm audit- odergovulncheck-Funden ab. - Default-Bind:
127.0.0.1— wer exponiert, setzt bewusst"host": "0.0.0.0"und terminiert TLS im Reverse-Proxy.
Bewusste Vereinfachungen (Stand MVP)
Abschnitt betitelt „Bewusste Vereinfachungen (Stand MVP)“- Kein Refresh-Token-Flow: Nach Token-Ablauf ist ein erneuter Login nötig.
- Token im localStorage: einfach und für ein internes Admin-UI angemessen; strikterer XSS-Schutz ⇒ httpOnly-Cookies + CSRF-Schutz.
- Kein Rate-Limit auf
/auth/login: für exponierte Deployments die Fiber-limiter-Middleware ergänzen oder im Proxy drosseln. - In-Memory-Rate-Limits der Public API: gelten pro Instanz; bei horizontaler Skalierung in Redis/Proxy verlagern.