Skip to content

Security Model

Writ edited this page Aug 5, 2026 · 2 revisions

Security model

What this coordinator defends, how, and where the edges are.

To report a vulnerability, use private vulnerability reporting — never a public issue. See SECURITY.md.

Trust boundaries

  browser ──TLS──▶ reverse proxy ──loopback──▶ coordinator ──WebSocket──▶ agents
                                                    │
                                                    └── SQLite + files (one volume)
  • The operator is the owner. This is a single-owner deployment. There is no multi-tenant boundary to breach because there is one tenant.
  • Agents are semi-trusted. They hold a token that authorises connecting and running work. They do not get to decide their own dispatch share — the coordinator owns active_sessions, and an agent's claimed capacity is capped by the ceiling in its token. Never point an agent you do not control at a coordinator you care about.
  • The sites you crawl are hostile. Their content is data, never instruction.

Fail-closed at boot

The coordinator refuses to start in production when:

  • any signing secret is blank, under 32 characters, or a shipped default — HS256 signs happily with an empty key, so a half-filled .env would otherwise leave the token-signing key publicly known;
  • SECRET_ENCRYPTION_KEY is missing, or is one of the known-compromised values that were once committed to source control;
  • CORS_ORIGINS is * while credentials are enabled;
  • WRIT_PUBLIC_URL is unset.

It warns loudly but starts when WRIT_PUBLIC_URL is plaintext http:// on a routable host, and when REQUIRE_ADMIN_MFA is off.

Authentication

  • Passwords are hashed with Argon2.
  • Failed logins are throttled on both the IP and the account — one host spraying many accounts and many hosts hammering one account are different attacks and both are covered. A non-existent account still pays a constant-time dummy verify, so timing does not leak whether an account exists.
  • Repeat offenders are IP-banned automatically, checked early in the request pipeline.
  • Second factors: TOTP and passkeys (WebAuthn). Set REQUIRE_ADMIN_MFA=true after enrolling, or you will lock yourself out of the admin surface.
  • Refresh tokens rotate, with a short grace window so a browser reload during an in-flight refresh does not log you out.

Request-level defences

Host allowlist Spoofed Host headers are rejected in production. Derived from WRIT_PUBLIC_URL; extra names via ALLOWED_HOSTS or Settings → Network, which applies live.
Rate limiting A per-IP ceiling across everything, plus much stricter per-path limits on login, password reset, pairing and webhooks.
Body size caps 1 MB by default; larger only on the routes that genuinely stream.
Security headers HSTS in production, X-Content-Type-Options, X-Frame-Options: DENY, a CSP with no external origins, Referrer-Policy, Permissions-Policy.
CSP The header and the SPA's <meta> CSP are kept identical in substance — a browser enforces both and takes the intersection, so drift silently breaks the app. Fonts are self-hosted; nothing phones a CDN.

Dataset renders that echo scraped third-party content back at you are served under their own stricter default-src 'none', which the global policy never overwrites.

SSRF

Screening is on in every environment, including development — it is deliberately decoupled from ENVIRONMENT so a dev instance cannot be talked into reading cloud metadata. Private, loopback, link-local and metadata ranges are blocked; schemes are checked; DNS is pinned and redirects re-validated so a target cannot resolve safe and then rebind.

ALLOW_PRIVATE_TARGETS=true opens the private-range check only — set it only if you genuinely need to monitor intranet apps. Every other screen stays active.

Secrets at rest

Stored credentials are encrypted with a Fernet key (SECRET_ENCRYPTION_KEY). The key lives in .env; the ciphertext lives in the database. Keep them apart — see Backup and restore.

Password fields detected during recording are never written into a workflow as a literal value, and one-time-code fields are recorded as a resilient twofa step rather than the code you typed.

Container posture

Both services run non-root, with a read-only root filesystem, cap_drop: ALL, and no-new-privileges. The only writable location is the /data volume; /tmp is a noexec tmpfs. An attacker who reaches a file-write primitive cannot modify the application code and cannot persist across a restart.

The coordinator publishes on loopback only by default. doc-extract also publishes on loopback — its shared secret is the only thing in front of it, so never expose port 8092 on a public interface.

Known edges

  • /metrics is unauthenticated when ENABLE_METRICS=true — that is what Prometheus expects. It discloses your route topology and traffic shape. Scrape it over a private network or behind your proxy's auth.
  • The generic API rate limiter fails open. On a limiter outage availability wins over throttling. Money- and authz-sensitive paths fail closed instead.
  • Single owner, single worker. There is no horizontal scaling story for the coordinator; it is not a gap so much as the design.
  • AGPL §13: if you modify this and let others use it over a network, you owe them your source. GET /api/about makes that offer for you — point WRIT_SOURCE_URL at your own repository if you patch it.

Hardening checklist

  • TLS terminated, WRIT_PUBLIC_URL is the https:// URL
  • FORWARDED_ALLOW_IPS names your proxy and is not *
  • Every admin has a second factor, then REQUIRE_ADMIN_MFA=true
  • SECRET_ENCRYPTION_KEY backed up off-host
  • Firewall allows 80/443 inbound and nothing else
  • ALLOW_PRIVATE_TARGETS and ALLOW_INSECURE_DEV both false
  • .env is chmod 600 and not committed

Clone this wiki locally