API security hardening — batch 1¶
Motivation¶
A live black-box audit of the deployed control plane (ca-elb-dashboard)
surfaced several low-risk information-disclosure and contract gaps on the
public api-sidecar surface:
- No security response headers — responses lacked HSTS,
X-Content-Type-Options,X-Frame-Options,Referrer-Policy, andPermissions-Policy. Serverversion banner leak —Server: uvicorn,nginx/1.31.1exposed the framework and nginx build.- Token error leaked PyJWT internals —
invalid token: Not enough segmentshanded an attacker probing token shapes free recon. /openapi.jsonanonymously public — the full machine-readable route inventory (201 KB) was served to any unauthenticated caller.- OpenAPI spec missing
securitySchemes— every route enforces an MSAL bearer token at runtime, but the spec advertised no security requirement.
User-facing change¶
- Every api-sidecar response now carries
Strict-Transport-Security,X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy, andPermissions-Policy. The middleware usessetdefault, so the SPA-tuned headers the frontend nginx sidecar already sets on proxied static assets are preserved (no clobbering of its CSP / referrer policy). - The
Serverresponse header is masked to a constantElasticBLAST; uvicorn's own banner is suppressed via--no-server-headerand nginx's viaserver_tokens off. - Invalid bearer tokens now return
{"detail": "invalid token"}— the specific PyJWT reason stays in the server log only. /openapi.jsonand/api/docsare now both gated behind the existingENABLE_DOCSflag (unset in production → both hidden). Runtime auth is unchanged, so hiding the spec strips no caller's access.- When the spec is enabled, it now declares a
BearerAuth(http/bearer/JWT) security scheme and a globalsecurityrequirement. - A
Content-Security-Policyheader for the api sidecar is available behind a new default-OFFSTRICT_CSPgate (§12a Rule 4);STRICT_CSP_POLICYoverrides the conservative default.
API / IaC diff summary¶
| File | Change |
|---|---|
api/app/security_headers.py |
new SecurityHeadersMiddleware (always-on headers, banner mask, CSP behind STRICT_CSP) |
api/main.py |
register middleware (outermost); gate openapi_url behind ENABLE_DOCS; _install_openapi_security_scheme() |
api/auth.py |
generic invalid token client message; PyJWT reason logged only |
api/Dockerfile |
add --no-server-header to the uvicorn CMD |
web/nginx.conf |
add server_tokens off; |
api/tests/test_security_headers.py |
new — 9 tests covering both gate states |
Dockerfile + nginx.conf changes only take effect on the next image build / deploy; they were not deployed as part of this change (charter §13 — validated locally via pytest only).
Validation evidence¶
uv run pytest -q api/tests/test_security_headers.py→ 9 passeduv run pytest -q api/tests→ 2395 passed, 3 skippeduv run pytest -q api/tests/test_persona_matrix.py→ green (no authz change)uv run ruff check api→ All checks passed
Hardening discipline (§12a)¶
- In scope: sanitise, network (headers), jwt (spec scheme)
- RBAC change is single-PR safe (no role narrowed)
- Persona Matrix tests pass for owner / contributor / reader / dev_bypass
- Reader allowlist unchanged
- Capability Probe N/A (no role change)
- RBAC removal preflight N/A (no
roleAssignmentsdiff) - New guard ships default-OFF behind
STRICT_CSP(CSP header) - No
Depends(require_caller)added to an SSE event stream - This change note summarises persona impact (none — additive headers, documentation-only spec changes, generic error string)