Skip to content

Security

This document covers the security of the admin area and the cross-cutting topics (logging, secrets, headers). The protection layers of the Public API (challenge/PoW/quarantine …) are described conceptually in the concept document (§5) and technically in architecture.md (“The submit path”).

ComponentTechnologyLocation
Password hashingargon2id (OWASP parameters)internal/core/services/auth_service.go
User authJWT (HS256), 60 min TTLAuthService + middlewares.Authenticate
Service authAPI keys (SHA-256-hashed)APIKeyService
AuthorizationRBAC: user → roles → permissions (static catalog)middlewares.RequirePermission
Secrets in the DBORM++ field encryption (AES-256-GCM)orm:"encrypted" tags
Secrets on diskgenerated config.json (0600)internal/config/config.go
Attacks to the firewallCrowdSec LAPI (watcher JWT ⇒ ban decision)internal/crowdsec, pipeline.ThreatTracker

Passwords are hashed exclusively with argon2id (64 MiB memory, 4 threads — resistant to GPU cracking). There is no place anywhere in the system where an admin user’s plaintext password is stored. On login with an unknown username, the system compares against a dummy hash so that “user does not exist” and “wrong password” cannot be distinguished by timing, and returns the same error in both cases (no user-enumeration leak).

Login throttle: POST /api/v1/auth/login is unauthenticated, and every attempt costs one argon2id hash with 64 MiB of memory — unthrottled, the endpoint would be not just a brute-force target but also a cheap memory DoS. login_max_attempts (default 10, 0 = off) therefore caps failed attempts per 5-minute window, counted separately per client IP and per username — otherwise a distributed attack on the admin account slips past the IP limit. Exceeding it returns 429 with Retry-After. Successful logins are credited back: knowing your password never locks you out.

  1. Login (POST /api/v1/auth/login): After argon2id verification, the AuthService issues an HS256-signed JWT. Claims: user ID (sub, UUID), username, iat/exp.
  2. Request: The frontend sends Authorization: Bearer <jwt>. The Authenticate middleware validates signature and expiry — the signing method is explicitly pinned to HS256 (jwt.WithValidMethods), which prevents algorithm confusion.
  3. Role resolution: Permissions are not stored in the token. On every request, the user is freshly loaded from the DB — role changes and deactivations take effect immediately.
  4. Expiry: After access_token_ttl_minutes (default 60), the server responds with 401 → the frontend automatically logs out (frontend_api.md).

The JWT secret is generated cryptographically at random on first start (48 bytes from crypto/rand) and lives only in config.json (file permissions 0600). Secrets under 32 characters are rejected at load time.

Roles and permissions are code, not DB tables (internal/core/domain/rbac.go): they only change with a release. Users carry their role names as a field; resolution to permissions happens in memory.

User "alice" ──> Role "admin" ──> all permissions
User "bob" ──> Role "viewer" ──> tenants:read, routes:read, submissions:read

Routes are protected declaratively:

tenants.Get("/", middlewares.RequirePermission(domain.PermTenantsRead), ctrl.List)
tenants.Post("/:id/active", middlewares.RequirePermission(domain.PermTenantsWrite), ctrl.SetActive)

RequirePermission responds with 401 (not logged in) or 403 (missing permission). To introduce a new permission: define a constant in domain/rbac.go (resource:action), assign it in the role catalog (RoleDefs), protect the route; optionally add auth.can('...') in the frontend — purely cosmetic, the real check is always performed by the server.

API keys for service-to-service communication

Section titled “API keys for service-to-service communication”

For processes without a browser (CI, monitoring), there are API keys (X-API-Key header):

  • Created via POST /api/v1/apikeys (permission apikeys:manage); the plaintext key (fgw_…) is included only in that single response.
  • Only the SHA-256 hash is stored.
  • Every key operates within the permission context of the creating user — RBAC applies unchanged.
  • Keys can expire (expires_in_days) and be revoked.
  • A key is never worth more than the account behind it: if the user is deactivated, the key is rejected — exactly as in the JWT path.

Scopes: "read" allows only GET/HEAD/OPTIONS — the Authenticate middleware rejects write methods with 403 before any controller code runs. Rate limiting: api_key_rate_limit_per_minute (default 120, 0 = off) throttles per key and per minute, applies before key validation (including brute-force attempts with invalid keys), and responds with 429 and Retry-After. Browser sessions are not affected.

The seeding user system exists for background processes; it cannot log in (IsSystem).

Tenant.SigningSecret (signs the challenge tokens) and SmtpRoute.Password are stored in the DB only as ciphertext, via ORM++ field encryption (AES-256-GCM). The key is encryption_key in config.json — back it up; without it, these fields are unreadable. Neither value is ever serialized by the API (json:"-", view structs).

Compromising a customer’s site leaks nothing sensitive: the site key in the client JS is a public identifier, while the signing secret and SMTP credentials live only in the gateway (concept §5.8). If compromise is suspected: rotate the secret with a button in the admin UI (this invalidates any in-flight challenges).

Public API rejections are always 400 {"ok":false,"reason":"rejected"} — which protection mechanism triggered is recorded only in the log and in the admin UI (concept §8). Honeypot hits and quarantine look like success to the client (no oracle for spammers). Recipient and sender addresses never come from the form; header injection is caught by input hardening (CRLF filtering, address validation).

The logging service (internal/logging) is based on log/slog:

  • Level via log_level (debug, info, warn, error); ./form-gateway -debug raises it at runtime.
  • Access log (access_log: true): method, path, status, duration, IP, username; 4xx as WARN, 5xx as ERROR.
  • Passwords, tokens, signing secrets, or complete form contents are never logged. Client IPs are stored in the DB only as a truncated SHA-256 hash (data minimization, concept §10).
  • Language: Log messages default to English; on a German system locale (LANG=de*) they are German without umlauts (internal/locale). Log keys (tenant, error, …) are always English. Signal and rejection reasons (honeypot, challenge ungueltig, …) are data — fixed and locale-independent, since they are also stored in events and shown in the admin UI.

CrowdSec: reporting attack patterns to the firewall

Section titled “CrowdSec: reporting attack patterns to the firewall”

The check pipeline rejects individual requests — but it does not stop an attacker from trying another thousand times. The gateway can therefore report suspicious IPs to a CrowdSec Local API (LAPI). A bouncer registered there (nftables/iptables, OPNsense/pfSense, Traefik, nginx …) picks up the decision and blocks the IP in the upstream firewall: the next request never reaches the service at all. The feature is off by default.

Every hit in the pipeline is a signal with a weight. An IP’s weights are summed over a sliding window (window_minutes, default 10); once score_threshold (default 100) is reached, the report goes out.

SignalWeightWhy
honeypot50Only a bot fills the invisible field
replay50Reused nonce = script
pow ungueltig35Forged or missing PoW solution
challenge ungueltig30Tampered or foreign token
site-key/origin unbekannt25Probing for valid site keys
reputation25Tor exit, blocked ASN/IP
herkunftsland gesperrt10tenant geo rule (low: policy, not proof of attack)
absender-adresse geblockt20Reply-To on the blocklist
zu schnell ausgefuellt20Below the minimum fill time
rate-limit15On its own just load, not proof of an attack
eingabe-haertung10Oversized or malformed fields

The weights are deliberately balanced so that no single mistake by a real user leads to a block: an expired challenge or a form submitted too quickly stays well below the threshold. Only the combination or repetition — what a script produces — crosses it. An exhausted daily quota does not count at all, otherwise the tenant’s real visitors would be hit.

After a report, the ban duration doubles as a cooldown: while the firewall is blocking anyway, the same IP is not reported again.

Create a watcher for the gateway on the CrowdSec host:

Terminal window
cscli machines add form-gateway --password 'a-long-password'

Then in config.json (or via environment variables):

"crowdsec": {
"enabled": true,
"lapi_url": "http://127.0.0.1:8080",
"machine_id": "form-gateway",
"password": "a-long-password",
"ban_duration": "4h"
}

After a restart, verify — reported IPs appear with the scenario techeve/form-gateway:

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

The gateway authenticates as a watcher (POST /v1/watchers/login with machine_id/password ⇒ JWT) and sends one alert with an attached ban decision to POST /v1/alerts per detected IP — the same approach as cscli decisions add. The JWT is cached and renewed automatically after a 401.

Sending runs asynchronously through a buffered queue: a slow or unreachable LAPI must never slow down a form request. If the queue fills up, reports are dropped rather than waited on (fail-open) — the gateway’s own protection layers still apply.

  • Error handling: Internal errors reach the client only as a generic “internal server error”.
  • Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy and a Content-Security-Policy (middlewares/security.go). Strict-Transport-Security is set over HTTPS only.
  • Kept out of search engines: The gateway is an admin interface and does not belong in any index. Three layers: X-Robots-Tag: noindex, nofollow on every response (the load-bearing one — the JSON responses from /api/v1 and /v1 carry neither robots.txt nor a meta tag, and API-only deployments without an embedded frontend have no robots.txt at all), plus <meta name="robots"> in the SPA and a robots.txt. The latter blocks everything except the client snippet: Googlebot renders customer pages and fetches the scripts they embed — blocking /form-gateway.js here would damage the SEO of other people’s sites.
  • CSP: The load-bearing part is script-src 'self' without 'unsafe-inline' and 'unsafe-eval' — the admin JWT lives in localStorage, so a single XSS hole would otherwise exfiltrate it directly. The SPA loads only its own Vite-built assets (no CDN, no inline script). style-src allows 'unsafe-inline' because Svelte components set style="…" attributes; inline CSS is harmless without script execution. Cross-Origin-Resource-Policy is deliberately not set — otherwise customer sites could no longer embed /form-gateway.js.
  • Mail headers: Recipient and sender addresses are validated with mail.ParseAddress (exactly one bare address — a list such as target@x.tld, lurker@y.tld would otherwise smuggle a silent recipient into every mail). On top of that, the mailer strips CR/LF and control characters from every header value while building the message — the last line of defence against header injection.
  • Self-healing (systemd watchdog): The unit runs with Type=notify and WatchdogSec=30. The application (internal/watchdog) reports READY=1 only once the health endpoint and the database actually respond, and afterwards keeps petting the watchdog only while both self-checks continue to pass. A hung process — alive but unresponsive (deadlock, stalled DB) — is therefore killed by systemd after 30 s with SIGABRT (Go then dumps all goroutine stack traces to the journal: cause, not just symptom) and restarted. Crashes are covered by Restart=always (2 s backoff, max. 10 failed starts per 5 min); memory leaks are bounded by MemoryHigh=512M / MemoryMax=768M — an OOM kill hits the service, not the host, and likewise ends in an automatic restart. Outside systemd (development, demo) the watchdog code is a no-op.
  • Timeouts: ReadTimeout/WriteTimeout/IdleTimeout are set (against Slowloris), and the body is capped at 1 MiB — all bodies are JSON, there are no uploads.
  • CORS: /v1 reflects the origin only for the browser mechanics — the actual origin check happens server-side against the tenant’s allowlist (CORS is enforced by the browser, not the server).
  • Build gates: make build fails on npm audit or govulncheck findings.
  • Default bind: 127.0.0.1 — anyone exposing the service deliberately sets "host": "0.0.0.0" and terminates TLS at the reverse proxy.
  • No refresh token flow: after the token expires, a fresh login is required.
  • Token in localStorage: simple and appropriate for an internal admin UI; stricter XSS protection ⇒ httpOnly cookies + CSRF protection.
  • No rate limit on /auth/login: for exposed deployments, add the Fiber limiter middleware or throttle at the proxy.
  • In-memory rate limits of the Public API: apply per instance; move to Redis/proxy for horizontal scaling.