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.
79 lines
3.1 KiB
Markdown
79 lines
3.1 KiB
Markdown
# 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.
|