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:
@@ -31,10 +31,18 @@ def client() -> TestClient:
|
||||
|
||||
|
||||
def test_health_endpoint(client: TestClient):
|
||||
"""SP19: health endpoint now returns a subsystem snapshot."""
|
||||
resp = client.get("/api/health")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body == {"status": "ok", "version": __version__}
|
||||
# Old contract (status + version) is preserved.
|
||||
assert body["status"] == "ok"
|
||||
assert body["version"] == __version__
|
||||
# SP19 additions.
|
||||
assert "db" in body and body["db"].get("ok") is True
|
||||
assert "scheduler" in body
|
||||
assert "pubsub" in body
|
||||
assert "batch" in body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user