Security¶
Authentication & authorization¶
- JWT tokens - Short-lived access tokens (5 min) with refresh rotation
- HttpOnly cookies - Refresh tokens stored in secure, HttpOnly, SameSite=Strict cookies
- Authenticated pages only - Every frontend page except the login screen requires an authenticated session
- DB-backed refresh sessions - Active refresh-session families survive backend restarts
- Refresh-token family lifetime cap - Every login starts a token family with an immutable
family_expires_at. Rotation never extends it, so a stolen refresh token cannot be rotated indefinitely. SeeFINDINGS_REFRESH_TOKEN_FAMILY_MAX_LIFETIME_DAYS(security policy, default 30 days, bounded 7-30) versusFINDINGS_REFRESH_SESSION_RETENTION_DAYS(DB cleanup lag for dead rows, auto-derived as 2 × the family cap). The two settings govern different things and should not be confused. - Token-version kill switch - Logout or refresh-family revocation immediately invalidates outstanding access tokens for that user
- bcrypt-sha256 hashing - Passwords pre-hashed with HMAC-SHA256 then bcrypted (no 72-byte truncation, full passphrase contributes to the digest)
- Password policy - Min length 16 (hard floor, env can't go lower), max 128, zxcvbn score >= 3. Configurable per Configuration.
- RBAC - Three roles (admin / reviewer / client_user) with server-enforced permissions
- Client scoping -
client_userrole sees only data belonging to their linked client (row-level filtering at ORM level)
HTTP security¶
- Edge rate limiting - 30 req/min on
/api/v1/auth/login(strict bucket, brute-force protection), 1200 req/min on/api/v1/*(general bucket); enforced in Caddy. Both rules match the legacy unversioned/api/...paths via optional(?:v1/)?regex during the deprecation window - Malformed-path rejection - Caddy rejects requests with
//,/./,/../,%2F,%5C, or%00in the path with 400, before any handler runs - Forwarding-header sanitization - 9 spoofable headers (X-Forwarded-For, X-Real-IP, etc.) blocked at the edge
- Body-size cap - 1MB default, 30MB on the
/api/v1/findings/<id>/attachmentsendpoint (anchored regexp, exact match) - HTTP timeouts - read_body 30s, read_header 10s, write 60s, idle 2m
- Security headers - CSP (
frame-ancestors 'none',base-uri 'self',form-action 'self'), COOP, CORP, X-Frame-Options DENY, X-Content-Type-Options nosniff, X-Permitted-Cross-Domain-Policies none, Referrer-Policy strict-origin-when-cross-origin, Permissions-Policy - HSTS -
max-age=63072000; includeSubDomains; preloadover HTTPS - CORS - Configurable allowed origins
- TLS - Auto-provisioned via Caddy (Let's Encrypt) in production
File security¶
- Content-type validation - Only allowed MIME types accepted (images, PDF, text, CSV, JSON, ZIP)
- Size limits - Attachment uploads are limited in both Caddy and the backend; the backend is authoritative, and the Caddy cap should be set at or slightly above it to reject oversized uploads early
- Virus scanning - ClamAV scans every upload before storage; uploads are blocked when the scanner is disabled or unavailable
- Separated storage buckets - Evidence and generated exports are stored in different object-storage buckets
- Proxied downloads - Evidence files and stored exports are served through the backend (object storage is not exposed to the internet)
Reporting controls¶
- Export guardrails - The backend rejects oversized exports based on finding count, total input size, and generated output size
- Stored export metadata - Each export records the export date, file name, creating user, format, size, SHA256, lock expiry, and taxonomy version used at generation time
- Historical taxonomy reference - Stored exports are linked to the taxonomy version that was active when they were created
Operational¶
- No US Cloud Act dependencies - Fully self-hosted, EU-friendly. Self-hosted fonts (Inter, JetBrains Mono) instead of Google Fonts. No third-party scripts on user-facing pages.
- Encrypted backups - Production runtime mode requires a passphrase file for backup encryption. The backup container fails fast (loud banner, exit code 78) if the secret is missing instead of silently looping.
- Change audit trail - Per-field history on all finding modifications
- Dependency lock - Backend (
requirements.lock.txt) and frontend (package-lock.json) both pinned. Lock-sync check in CI prevents drift. - Container hardening - Backup container runs with
cap_drop: ["ALL"]andsecurity_opt: ["no-new-privileges:true"]. Even root inside the container can't chown.
Security findings register¶
Every security or behavior-affecting finding identified during internal audits is tracked in docs/SECURITY-FINDINGS.md with a stable VL-YYYY-NNN ID, plus a corresponding GitHub issue.
To report a vulnerability, use the Report a Vulnerability button on the Security tab of the repository (Private Vulnerability Reporting is enabled). Email fallback per SECURITY.md.