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”).
Overview
Section titled “Overview”| Component | Technology | Location |
|---|---|---|
| Password hashing | argon2id (OWASP parameters) | internal/core/services/auth_service.go |
| User auth | JWT (HS256), 60 min TTL | AuthService + middlewares.Authenticate |
| Service auth | API keys (SHA-256-hashed) | APIKeyService |
| Authorization | RBAC: user → roles → permissions (static catalog) | middlewares.RequirePermission |
| Secrets in the DB | ORM++ field encryption (AES-256-GCM) | orm:"encrypted" tags |
| Secrets on disk | generated config.json (0600) | internal/config/config.go |
| Attacks to the firewall | CrowdSec LAPI (watcher JWT ⇒ ban decision) | internal/crowdsec, pipeline.ThreatTracker |
Passwords: argon2id
Section titled “Passwords: argon2id”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.
JWT lifecycle
Section titled “JWT lifecycle”- Login (
POST /api/v1/auth/login): After argon2id verification, theAuthServiceissues an HS256-signed JWT. Claims: user ID (sub, UUID), username,iat/exp. - Request: The frontend sends
Authorization: Bearer <jwt>. TheAuthenticatemiddleware validates signature and expiry — the signing method is explicitly pinned to HS256 (jwt.WithValidMethods), which prevents algorithm confusion. - 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.
- 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.
RBAC: static catalog
Section titled “RBAC: static catalog”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 permissionsUser "bob" ──> Role "viewer" ──> tenants:read, routes:read, submissions:readRoutes 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(permissionapikeys: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).
Secrets in the database
Section titled “Secrets in the database”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: deliberately terse
Section titled “Public API: deliberately terse”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).
Logging & access log
Section titled “Logging & access log”The logging service
(internal/logging)
is based on log/slog:
- Level via
log_level(debug,info,warn,error);./form-gateway -debugraises it at runtime. - Access log (
access_log: true): method, path, status, duration, IP, username; 4xx asWARN, 5xx asERROR. - 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.
When a report is sent
Section titled “When a report is sent”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.
| Signal | Weight | Why |
|---|---|---|
honeypot | 50 | Only a bot fills the invisible field |
replay | 50 | Reused nonce = script |
pow ungueltig | 35 | Forged or missing PoW solution |
challenge ungueltig | 30 | Tampered or foreign token |
site-key/origin unbekannt | 25 | Probing for valid site keys |
reputation | 25 | Tor exit, blocked ASN/IP |
herkunftsland gesperrt | 10 | tenant geo rule (low: policy, not proof of attack) |
absender-adresse geblockt | 20 | Reply-To on the blocklist |
zu schnell ausgefuellt | 20 | Below the minimum fill time |
rate-limit | 15 | On its own just load, not proof of an attack |
eingabe-haertung | 10 | Oversized 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:
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:
cscli decisions list --scenario techeve/form-gatewayHow the report works technically
Section titled “How the report works technically”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.
Further measures
Section titled “Further measures”- 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-Policyand a Content-Security-Policy (middlewares/security.go).Strict-Transport-Securityis 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, nofollowon every response (the load-bearing one — the JSON responses from/api/v1and/v1carry 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 arobots.txt. The latter blocks everything except the client snippet: Googlebot renders customer pages and fetches the scripts they embed — blocking/form-gateway.jshere 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-srcallows'unsafe-inline'because Svelte components setstyle="…"attributes; inline CSS is harmless without script execution.Cross-Origin-Resource-Policyis 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 astarget@x.tld, lurker@y.tldwould 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=notifyandWatchdogSec=30. The application (internal/watchdog) reportsREADY=1only 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 byRestart=always(2 s backoff, max. 10 failed starts per 5 min); memory leaks are bounded byMemoryHigh=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/IdleTimeoutare set (against Slowloris), and the body is capped at 1 MiB — all bodies are JSON, there are no uploads. - CORS:
/v1reflects 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 buildfails onnpm auditorgovulncheckfindings. - Default bind:
127.0.0.1— anyone exposing the service deliberately sets"host": "0.0.0.0"and terminates TLS at the reverse proxy.
Deliberate simplifications (as of MVP)
Section titled “Deliberate simplifications (as of MVP)”- 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 Fiberlimitermiddleware or throttle at the proxy. - In-memory rate limits of the Public API: apply per instance; move to Redis/proxy for horizontal scaling.