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:
@@ -370,6 +370,60 @@ fingerprints and the post-rotation table count. The old key is
|
||||
retained in the `cyclone.db.key.previous` Keychain account for a
|
||||
grace period so a botched rotation can be rolled back by hand.
|
||||
|
||||
## Security hardening (SP19)
|
||||
|
||||
Three pure-ASGI middlewares sit in front of every FastAPI request.
|
||||
They're sized for Cyclone's local-only posture — a misconfigured
|
||||
Tailscale / ngrok bind, a buggy cron job uploading a 4 GB file, or a
|
||||
port-scraper — not for hostile internet exposure.
|
||||
|
||||
| Middleware | Default | Override | Reject |
|
||||
|------------|---------|----------|--------|
|
||||
| `BodySizeLimitMiddleware` | 50 MB | `CYCLONE_MAX_BODY_BYTES` | `413 body_too_large` over Content-Length cap; chunked reads capped too |
|
||||
| `RateLimitMiddleware` | 300 req/min/IP | `CYCLONE_RATE_LIMIT_PER_MIN` | `429 rate_limited` over the sliding window; `/api/health` exempt |
|
||||
| `SecurityHeadersMiddleware` | always on | n/a | stamps `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, `Permissions-Policy`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` |
|
||||
|
||||
Every rejection (413 / 429) also writes a tamper-evident
|
||||
`api.request_rejected` event into the SP11 audit chain so an
|
||||
operator can correlate a misbehaving client with the SP18 JSON logs:
|
||||
|
||||
```json
|
||||
{"event_type":"api.request_rejected","entity_id":"POST /api/parse-837","payload":{"status":413,"reason":"body_too_large","path":"/api/parse-837","method":"POST","ip":"127.0.0.1"}}
|
||||
```
|
||||
|
||||
### Health probe
|
||||
|
||||
`GET /api/health` now returns a subsystem snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"db": {"ok": true},
|
||||
"scheduler": {"running": true, "interval_s": 60, "sftp_block": "co_medicaid",
|
||||
"backup_scheduler_running": false, "backup_interval_hours": 24.0},
|
||||
"pubsub": {"parse_completed": 1, "batch_added": 1},
|
||||
"batch": {"last_batch_id": 42, "last_batch_kind": "837P",
|
||||
"last_batch_at": "2026-06-21T15:30:00.123Z",
|
||||
"last_batch_filename": "TP11525703-837P-..."}
|
||||
}
|
||||
```
|
||||
|
||||
Returns `"status": "degraded"` if any subsystem reports an error —
|
||||
the per-subsystem dict still surfaces so an operator can see which
|
||||
one is unhappy. `/api/health` is rate-limit exempt so a load balancer
|
||||
hammering the endpoint doesn't trip the limiter.
|
||||
|
||||
### Files
|
||||
|
||||
* `cyclone.security` — `BodySizeLimitMiddleware`,
|
||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`, and
|
||||
`get_health_snapshot()` (~330 LOC).
|
||||
* `cyclone.api_routers.health` rewritten to use `get_health_snapshot()`.
|
||||
* `cyclone.pubsub.EventBus.stats()` — new method that returns
|
||||
per-kind subscriber counts.
|
||||
* `tests/test_security.py` — 13 new tests.
|
||||
|
||||
## Structured logging (SP18)
|
||||
|
||||
Cyclone emits newline-delimited JSON to stderr by default — readable
|
||||
@@ -599,7 +653,7 @@ backup API).
|
||||
|
||||
## Roadmap
|
||||
|
||||
Sub-projects 2 through 18 are **shipped**. See the [completeness
|
||||
Sub-projects 2 through 19 are **shipped**. See the [completeness
|
||||
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
||||
the honest gap analysis against the industry definition of a HIPAA
|
||||
clearinghouse — the short version is that the local-only,
|
||||
@@ -610,6 +664,16 @@ scope.
|
||||
|
||||
Shipped sub-projects (most recent first):
|
||||
|
||||
- **Sub-project 19 (shipped) — Security hardening + health probe.**
|
||||
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
|
||||
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
|
||||
completeness-review gaps §3.1.4 (no body/rate limits) and §3.1.25
|
||||
(no CSP / security headers). 413/429 rejections emit a
|
||||
tamper-evident `api.request_rejected` audit event (SP11 chain).
|
||||
`/api/health` is now a rich subsystem snapshot — DB connectivity,
|
||||
MFT scheduler state, backup scheduler state, live pubsub
|
||||
subscriber counts, last batch id + timestamp. See
|
||||
[Security hardening (SP19)](#security-hardening-sp19) below.
|
||||
- **Sub-project 18 (shipped) — Structured JSON logging.** All logs
|
||||
emitted by the API, CLI, scheduler tick loop, and backup service
|
||||
flow through a `JsonFormatter` (newline-delimited JSON, ISO-8601 ms
|
||||
|
||||
Reference in New Issue
Block a user