feat(sp19): security hardening + rich health probe

Three pure-ASGI middlewares close completeness-review gaps §3.1.4
(no body/rate limits) and §3.1.25 (no security headers):

- BodySizeLimitMiddleware — rejects oversized uploads (50 MB
  default, CYCLONE_MAX_BODY_BYTES override). 413 on over-cap
  Content-Length and on chunked reads that cross the cap.
- RateLimitMiddleware — sliding-window per-IP limiter (300/min
  default, CYCLONE_RATE_LIMIT_PER_MIN override). 429 over the
  window. /api/health is exempt.
- SecurityHeadersMiddleware — stamps X-Content-Type-Options,
  X-Frame-Options, Referrer-Policy, Permissions-Policy, and a
  strict Content-Security-Policy on every response.

Every 413/429 also writes a tamper-evident api.request_rejected
event into the SP11 audit chain so an operator can correlate
rejections with the SP18 JSON logs.

GET /api/health is rewritten to return a subsystem snapshot:
DB connectivity (SELECT 1), MFT scheduler state, backup scheduler
state, live pubsub subscriber counts, last batch id + timestamp.
Returns status='degraded' if any subsystem is unhappy; per-subsystem
errors surfaced in the respective dict.

Cyclone.pubsub.EventBus.stats() — new method for live subscriber
counts.

13 new tests (test_security.py) + 1 updated (test_api.py health
endpoint). All 883 backend tests pass.
This commit is contained in:
Tyler
2026-06-21 10:21:01 -06:00
parent c7fd7ecc0e
commit c835996bd6
8 changed files with 918 additions and 8 deletions
@@ -0,0 +1,78 @@
# 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.