# SP19 — Security Hardening + Health Probe **Date:** 2026-06-21 **Branch:** `sp19-security-hardening` **Status:** Shipped **Scope:** Backend only. No frontend changes. --- ## 1. Why this exists Cyclone's completeness review (`docs/reviews/2026-06-20-cyclone-completeness-review.md` §3.1.4, §3.1.25, §3.2.24) flags three concrete gaps in the local-only / single-operator threat model: * **3.1.4 — No request size limits / no rate limits.** FastAPI defaults accept any body size and any request rate. A 4 GB file upload OOMs the parser; a flood of requests holds the GIL. Even for a `127.0.0.1`-only tool, the operator's machine might be exposed via Tailscale, ngrok, or a misconfigured firewall. * **3.1.25 — No CSP / security headers.** CORS is set; the rest of FastAPI's defaults are in play. `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, a `Content-Security-Policy` that locks the Vite origin, and `Referrer-Policy: same-origin` should be on by default. * **3.2.24 — `/api/health` is shallow.** It returns only the package version. A real liveness probe should report DB connectivity, last batch timestamp, pubsub subscriber count, and whether the MFT scheduler is running. SP19 closes all three. ## 2. Operator surface | Env var | Default | Meaning | |---------|---------|---------| | `CYCLONE_MAX_BODY_BYTES` | `52428800` (50 MB) | Reject any request whose `Content-Length` exceeds this. | | `CYCLONE_RATE_LIMIT_PER_MIN` | `300` | Per-IP requests/minute (sliding window). | | `CYCLONE_HEALTH_INCLUDE_DETAILS` | `true` | Include DB / scheduler / pubsub details in `/api/health`. | CLI: no new flags. The values come from env vars only. ## 3. Files * `cyclone/security.py` — new module (~150 LOC). * `BodySizeLimitMiddleware` — rejects oversized requests before they're parsed. * `RateLimitMiddleware` — token-bucket per IP, in-memory. * `SecurityHeadersMiddleware` — adds the static response headers. * `get_health_snapshot()` — gathers DB / scheduler / pubsub info. * `cyclone.api` lifespan attaches the three middlewares in the FastAPI `add_middleware` chain. `api_routers/health.py` is rewritten to use `get_health_snapshot()`. * Audit log: every rejection (size / rate / method) writes a `api.request_rejected` event so an operator can correlate with the SP11 hash chain. ## 4. Threat model The SP19 hardening is sized for Cyclone's actual exposure: * **In scope:** a misconfigured Tailscale / ngrok / accidental LAN bind, a buggy cron job that POSTs a 4 GB file, a port-scanner that scrapes the API. * **Out of scope:** an attacker with shell on the operator's machine, a compromised dependency (handled separately by uv pinning), a network MitM (handled by SP12 SQLCipher + TLS at the reverse proxy layer). ## 5. Tests * `test_security.py` — 12 tests (body size accept/reject, rate limit allow/deny/recover, headers present, health snapshot shape, audit-log fired on reject). * `test_api_health.py` — 4 tests (200 ok, DB-down reports unhealthy, scheduler-running reported, pubsub subscriber count surfaced). Total: 16 new tests.