Zum Inhalt springen

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”).

BausteinTechnologieOrt
Passwort-Hashingargon2id (OWASP-Parameter)internal/core/services/auth_service.go
User-AuthJWT (HS256), 60 min TTLAuthService + middlewares.Authenticate
Service-AuthAPI-Keys (SHA-256-gehasht)APIKeyService
AutorisierungRBAC: User → Rollen → Permissions (statischer Katalog)middlewares.RequirePermission
Secrets in der DBORM++-Feld-Verschlüsselung (AES-256-GCM)orm:"encrypted"-Tags
Secrets auf Plattegenerierte config.json (0600)internal/config/config.go
Angriffe an die FirewallCrowdSec LAPI (Watcher-JWT ⇒ Ban-Decision)internal/crowdsec, pipeline.ThreatTracker

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.

  1. Login (POST /api/v1/auth/login): Nach argon2id-Verifikation stellt der AuthService ein HS256-signiertes JWT aus. Claims: User-ID (sub, UUID), Username, iat/exp.
  2. Request: Das Frontend sendet Authorization: Bearer <jwt>. Die Authenticate-Middleware validiert Signatur und Ablauf — die Signaturmethode ist explizit auf HS256 festgenagelt (jwt.WithValidMethods), was Algorithm-Confusion verhindert.
  3. Rollenauflösung: Permissions stehen nicht im Token. Bei jedem Request wird der User frisch aus der DB geladen — Rollenänderungen und Deaktivierungen greifen sofort.
  4. 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.

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 Permissions
User "bob" ──> Rolle "viewer" ──> tenants:read, routes:read, submissions:read

Routen 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.

Für Prozesse ohne Browser (CI, Monitoring) gibt es API-Keys (X-API-Key-Header):

  • Erzeugung über POST /api/v1/apikeys (Permission apikeys: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).

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).

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.

Der Log-Service (internal/logging) basiert auf log/slog:

  • Level über log_level (debug, info, warn, error); ./form-gateway -debug hebt es zur Laufzeit an.
  • Access-Log (access_log: true): Methode, Pfad, Status, Dauer, IP, Username; 4xx als WARN, 5xx als ERROR.
  • 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.

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.

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.

SignalGewichtWarum
honeypot50Nur ein Bot füllt das unsichtbare Feld
replay50Wiederverwendete Nonce = Skript
pow ungueltig35Gefälschte oder fehlende PoW-Lösung
challenge ungueltig30Manipuliertes oder fremdes Token
site-key/origin unbekannt25Probing nach gültigen Site-Keys
reputation25Tor-Exit, geblockte ASN/IP
herkunftsland gesperrt10Geo-Regel des Mandanten (niedrig: Policy, kein Angriffsbeweis)
absender-adresse geblockt20Reply-To auf der Blocklist
zu schnell ausgefuellt20Unter der Mindest-Ausfüllzeit
rate-limit15Allein nur Last, kein Angriffsbeweis
eingabe-haertung10Ü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.

Auf dem CrowdSec-Host einen Watcher für das Gateway anlegen:

Terminal-Fenster
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:

Terminal-Fenster
cscli decisions list --scenario techeve/form-gateway

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.

  • 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-Policy sowie eine Content-Security-Policy (middlewares/security.go). Strict-Transport-Security wird 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, nofollow auf jeder Antwort (verbindlich — die JSON-Antworten von /api/v1 und /v1 haben 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 eine robots.txt. Letztere sperrt alles außer dem Client-Snippet: Googlebot rendert Kundenseiten und lädt dabei eingebundene Skripte mit — wäre /form-gateway.js gesperrt, 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-src erlaubt 'unsafe-inline', weil Svelte-Komponenten style="…"-Attribute setzen; Inline-CSS ist ohne Script-Ausführung ungefährlich. Cross-Origin-Resource-Policy wird bewusst nicht gesetzt — sonst könnten Kundenseiten /form-gateway.js nicht mehr einbinden.
  • Mail-Header: Empfänger- und Absenderadressen werden mit mail.ParseAddress geprüft (genau eine blanke Adresse — eine Liste wie ziel@x.tld, mitleser@y.tld schmuggelte 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=notify und WatchdogSec=30. Die Anwendung (internal/watchdog) meldet READY=1 erst, 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ängt Restart=always (2 s Backoff, max. 10 Fehlstarts in 5 min), Speicherlecks begrenzen MemoryHigh=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/IdleTimeout sind gesetzt (gegen Slowloris), der Body ist auf 1 MiB begrenzt — alle Bodys sind JSON, Uploads gibt es keine.
  • CORS: /v1 spiegelt 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 build bricht bei npm audit- oder govulncheck-Funden ab.
  • Default-Bind: 127.0.0.1 — wer exponiert, setzt bewusst "host": "0.0.0.0" und terminiert TLS im Reverse-Proxy.
  • 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.