c835996bd6
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.
3.1 KiB
3.1 KiB
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, aContent-Security-Policythat locks the Vite origin, andReferrer-Policy: same-originshould be on by default. - 3.2.24 —
/api/healthis 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.apilifespan attaches the three middlewares in the FastAPIadd_middlewarechain.api_routers/health.pyis rewritten to useget_health_snapshot().- Audit log: every rejection (size / rate / method) writes a
api.request_rejectedevent 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.