Skip to content

Security

Threat model (scope of this build)

A multi-fab planning and dispatch tool. It handles operational data (tool IDs, shift plans, dispatcher actions) and user email addresses; no financial data. Engineer names are synthetic.

Threat Mitigation
Oversized or expensive requests (DoS) Pydantic limits on every input (≤ 60 engineers, ≤ 200 jobs, ≤ 30 benchmark seeds, ≤ 500-char symptoms). Rate limits in two layers on /plan, /benchmark, /optimality-gap, live writes, the live stream (plus at most 4 open streams per user per instance), repair search, help search and the assistant: a token bucket per instance, then for signed-in users a per-minute counter in the database that holds across instances. Unauthenticated endpoints get the in-memory bucket only (no database write per request). When the shared counter is unreachable, cheap endpoints fail open and the expensive ones (/benchmark, /optimality-gap, POST /shifts, an LLM-backed assistant answer) fail closed with 503 + Retry-After. Solver iteration and time caps. Explicit 413 for oversized scenarios.
SQL injection Parameterised queries only (sqlite3 and psycopg placeholders). No string-built SQL from input.
Lost updates between dispatchers Optimistic versioning on live shifts. The UI sends the shift's ETag as If-Match on every write, so a stale tab gets a 409 instead of silently overwriting; the server retries a lost race only when no If-Match was sent.
Information leakage One error envelope with no stack traces. Public /api/health reports status, db_ok, schema_ok, the number of databases (never their names), the engine version, release, commit and environment name; /api/meta describes the deployment (versions, algorithms, store kind) but never a tenant.
Clickjacking, MIME sniffing X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy and a Content-Security-Policy on every API response (default-src 'self'; frame-ancestors 'none'; the /api/docs and /api/redoc pages get a policy that allows exactly their CDN scripts and styles, with the inline bootstrap pinned by hash). HSTS from the API in production.
Cross-origin abuse CORS allow-list from settings (same origin in production).
Secrets in the repo None needed locally. Production secrets live only in platform environment variables; see .env.example.
Container hardening Non-root user (uid 10001), slim base, no build tools in the runtime image.
Unauthenticated access Public, unauthenticated endpoints are exactly: GET /, /api/health, /api/livez, /api/meta, /api/auth/config, POST /api/auth/demo (local demo mode only), /api/help, /api/help/{slug}, /api/help/search, /api/docs, /api/redoc, /api/openapi.json; /api/internal/prune takes the cron secret and /api/ingest/* a per-fab token. Everything else requires a bearer token. Supabase tokens are verified against the project's JWKS (or legacy HS256 secret) with issuer and audience checks and must carry exp, sub, aud and iss; demo sign-in is refused outside local and test environments unless explicitly opted in with a non-default secret. The live stream (EventSource can't send headers) authenticates with a 90-second HMAC ticket from POST /api/shifts/{id}/stream-ticket, bound to the user and that shift, so no long-lived token appears in a URL or a request log.
Privilege escalation Roles and fab access come from Supabase app_metadata (admin-only), not user-editable claims; the FAB_DISPATCHER_EMAILS bootstrap grant is withheld when the token marks the email unverified. Dispatcher-only actions return 403. On the hosted demo guests are dispatchers by configuration, so a guest may change only the live shifts it started (403 otherwise); named dispatchers share every shift in their fabs.
Bypassing the API through Supabase App tables are closed to Supabase's public PostgREST Data API: row-level security is on with no policies and the anon/authenticated grants are revoked (migration 3), so the publishable key can't read them. That is all RLS does here: the app connects as the table owner, to which RLS doesn't apply, so no app query is ever filtered by a policy, and on Neon or SQLite (no PostgREST roles) the migration is a no-op. Checked after every production deploy.
Cross-tenant data access Tenant isolation comes from the API, not the database: every fab-scoped request and live shift is checked against the user's fabs (404, not 403, outside them), every query is scoped by fab_id, and a fab can be given its own database (FAB_TENANT_DATABASES), as production does for fab 2.
Replay / double-apply Idempotency-Key on mutating live calls; optimistic versioning on shift writes.
XSS, clickjacking Content-Security-Policy on the web app (Vercel, nginx from every location, and the Vite preview; a test keeps the three identical): script-src 'self' https://challenges.cloudflare.com (no inline or eval'd script; Turnstile is the only external script), style-src 'self' 'unsafe-inline' https://fonts.googleapis.com (inline styles are allowed because the UI's CSS-in-JS sets them; that permits style injection, not script), connect-src 'self' https://*.supabase.co wss://*.supabase.co (the browser talks to Supabase Auth directly; the wildcard covers the project subdomain), frame-ancestors 'none', object-src 'none', base-uri 'self'. HSTS and Permissions-Policy alongside. Help content renders as React elements, never raw HTML.
Scheduled job abuse /api/internal/prune requires CRON_SECRET, compared in constant time.
Session tokens in the browser supabase-js keeps the access and refresh tokens in localStorage (no HttpOnly cookie), so any script that runs on the page could read them; the CSP above exists to keep such scripts out, and the access token is short-lived. The live stream never puts the access token in a URL (it uses the 90-second ticket).
Guest sign-ins Supabase anonymous users get FAB_GUEST_ROLE only; their tokens can't claim a higher role (role and email claims are ignored for guests); none refuses them. Supabase rate-limits anonymous sign-ups per IP, every API limit applies per guest, and Cloudflare Turnstile CAPTCHA (verified by Supabase Auth) blocks scripted sign-ups and email flooding.
Assistant misuse The assistant only reads (no tool changes anything), answers from the help articles and the caller's own plans, applies the same fab scoping and size limits as /plan, caps questions at 500 characters, and is rate-limited per user. The optional LLM sees only the question and the grounded answer, can only reword it, and its output never carries citations or actions; any failure falls back to the grounded answer.
Help content Rendered as React elements (no raw HTML), so Markdown can't inject script under the CSP. Broken internal links fail at startup and in CI.
Vulnerable dependencies pip-audit and npm audit in CI; Dependabot weekly updates.

Not in scope (and what production would add)

  • Enterprise SSO. Supabase Auth supports SAML/OIDC providers; connecting a fab's identity provider is configuration, not code.
  • Audit export. Every live event records its actor and time (shift_events); exporting it to the customer's SIEM is a natural next step.
  • Edge rate limiting. The API's limits are a token bucket per instance plus, for signed-in users, a per-minute counter shared through the database (see the DoS row). Unauthenticated traffic is limited per instance only; a WAF or edge rules should own that in production.

Reporting

Please report vulnerabilities privately rather than opening a public issue: use GitHub's private vulnerability reporting for this repository, Security → Report a vulnerability (https://github.com/pavansky/fab-dispatch/security/advisories/new). You should hear back within a week; fixes ship as a patch release and are noted in the changelog.