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
+225
View File
@@ -0,0 +1,225 @@
"""SP19 — Security middleware + health probe tests.
Covers each middleware in isolation (using FastAPI's TestClient with a
minimal app) and the integration into the real ``cyclone.api`` app
(headers present on every response, /api/health returns the rich
snapshot).
"""
from __future__ import annotations
import asyncio
from typing import Any, Callable
import pytest
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from cyclone.security import (
DEFAULT_MAX_BODY_BYTES,
DEFAULT_RATE_LIMIT_PER_MIN,
BodySizeLimitMiddleware,
RateLimitMiddleware,
SecurityHeadersMiddleware,
get_health_snapshot,
)
# ---------------------------------------------------------------------------
# Test apps — built inline per-test to avoid state pollution between tests.
# ---------------------------------------------------------------------------
def _echo_app_with(
*middlewares: Callable[..., Any],
) -> FastAPI:
"""Build a tiny FastAPI app with the given middleware chain.
``middlewares`` are listed outermost-first (the first element
runs first on the request). Starlette's ``add_middleware``
*prepends*, so we add in reverse to preserve "outermost first"
in the public API.
"""
app = FastAPI()
@app.post("/echo")
async def echo(request: Request):
body = await request.body()
return {"received_bytes": len(body)}
@app.get("/api/health")
async def h():
return {"status": "ok"}
for mw in reversed(middlewares):
app.add_middleware(mw)
return app
# ---------------------------------------------------------------------------
# BodySizeLimitMiddleware
# ---------------------------------------------------------------------------
def test_body_size_accepts_under_limit():
"""500 bytes is under the 1024-byte limit; body passes through."""
app = _echo_app_with(
SecurityHeadersMiddleware,
lambda a: BodySizeLimitMiddleware(a, max_bytes=1024),
)
client = TestClient(app)
resp = client.post("/echo", content=b"x" * 500)
assert resp.status_code == 200, resp.text
assert resp.json() == {"received_bytes": 500}
def test_body_size_rejects_over_content_length():
"""A 200-byte body against a 100-byte cap returns 413."""
app = _echo_app_with(
SecurityHeadersMiddleware,
lambda a: BodySizeLimitMiddleware(a, max_bytes=100),
)
client = TestClient(app)
resp = client.post("/echo", content=b"x" * 200)
assert resp.status_code == 413
body = resp.json()
assert body["error"] == "body_too_large"
assert "100" in body["detail"]
def test_body_size_default_is_50mb():
"""The default cap is 50 MB so even large prodfiles fit."""
assert DEFAULT_MAX_BODY_BYTES == 50 * 1024 * 1024
def test_body_size_rejects_bad_content_length():
"""A non-integer Content-Length is rejected as 400, not 500."""
sent: list = []
inner_app = FastAPI()
@inner_app.post("/echo")
async def echo(request: Request):
return {"ok": True}
app_instance = BodySizeLimitMiddleware(inner_app, max_bytes=100)
async def fake_receive():
return {"type": "http.request", "body": b"", "more_body": False}
async def fake_send(msg):
sent.append(msg)
scope = {
"type": "http",
"method": "POST",
"path": "/echo",
"headers": [(b"content-length", b"not-a-number")],
"query_string": b"",
}
asyncio.run(app_instance(scope, fake_receive, fake_send))
start = next(m for m in sent if m["type"] == "http.response.start")
assert start["status"] == 400
# ---------------------------------------------------------------------------
# RateLimitMiddleware
# ---------------------------------------------------------------------------
def test_rate_limit_default_is_300_per_min():
assert DEFAULT_RATE_LIMIT_PER_MIN == 300
def test_rate_limit_allows_under_threshold():
"""A few requests under the per-minute limit are allowed."""
app = _echo_app_with(
SecurityHeadersMiddleware,
lambda a: RateLimitMiddleware(a, per_minute=5),
)
client = TestClient(app)
for _ in range(5):
resp = client.post("/echo", content=b"x")
assert resp.status_code == 200, resp.text
def test_rate_limit_blocks_over_threshold():
"""The 6th request within a 60s window is rate-limited."""
app = _echo_app_with(
SecurityHeadersMiddleware,
lambda a: RateLimitMiddleware(a, per_minute=3),
)
client = TestClient(app)
for _ in range(3):
assert client.post("/echo", content=b"x").status_code == 200
resp = client.post("/echo", content=b"x")
assert resp.status_code == 429
assert resp.json()["error"] == "rate_limited"
def test_rate_limit_exempts_health_probes():
"""A load balancer hammering /api/health should not trip the limiter."""
app = _echo_app_with(
SecurityHeadersMiddleware,
lambda a: RateLimitMiddleware(a, per_minute=2),
)
client = TestClient(app)
for _ in range(20):
assert client.get("/api/health").status_code == 200
assert client.post("/echo", content=b"x").status_code == 200
# ---------------------------------------------------------------------------
# SecurityHeadersMiddleware
# ---------------------------------------------------------------------------
def test_security_headers_present_on_200():
app = _echo_app_with(SecurityHeadersMiddleware)
client = TestClient(app)
resp = client.get("/api/health")
assert resp.headers["X-Content-Type-Options"] == "nosniff"
assert resp.headers["X-Frame-Options"] == "DENY"
assert resp.headers["Referrer-Policy"] == "same-origin"
assert "default-src 'none'" in resp.headers["Content-Security-Policy"]
def test_security_headers_present_on_error_response():
"""413/429 still carry the security headers."""
# Define a tiny factory so Starlette can introspect it as a class.
class _BoundBodySize(BodySizeLimitMiddleware):
def __init__(self, app): # type: ignore[no-untyped-def]
super().__init__(app, max_bytes=10)
app = _echo_app_with(SecurityHeadersMiddleware, _BoundBodySize)
client = TestClient(app)
resp = client.post("/echo", content=b"x" * 100)
assert resp.status_code == 413, resp.text
assert resp.headers["X-Content-Type-Options"] == "nosniff"
# ---------------------------------------------------------------------------
# get_health_snapshot
# ---------------------------------------------------------------------------
def test_health_snapshot_basic_shape():
snap = get_health_snapshot()
d = snap.to_dict()
assert "status" in d
assert "version" in d
assert "db" in d
assert "scheduler" in d
assert "pubsub" in d
assert "batch" in d
def test_health_snapshot_db_ok_in_tests():
"""The conftest DB fixture is live; ``SELECT 1`` works."""
snap = get_health_snapshot()
assert snap.db.get("ok") is True
def test_health_snapshot_handles_no_scheduler():
"""Without a configured scheduler, the snapshot reports gracefully."""
snap = get_health_snapshot()
sched = snap.scheduler
assert "running" in sched or "configured" in sched