2026-05-30 — STRICT_CORS narrows methods + headers (audit P2 #12)¶
Motivation¶
Security audit item P2 #12: the api sidecar's CORSMiddleware was
configured with allow_methods=["*"] and allow_headers=["*"]. With
allow_credentials=True this is not the OWASP-listed wildcard issue
(those guards already refuse allow_origins=["*"]) but it is more
generous than necessary — any method or header the browser asks for
in the preflight is echoed back. The audit asked for an explicit
allowlist that matches what the dashboard SPA actually sends.
User-facing change¶
- Default behaviour unchanged. Per charter §12a Rule 4 the new
narrowing is gated behind
STRICT_CORS=true. When the flag is unset, CORSMiddleware is configured with["*"]for methods and headers exactly as today. - When
STRICT_CORS=true: - Methods narrowed to
GET,POST,PUT,DELETE,OPTIONS(every method the SPA actually sends; PATCH/HEAD/CONNECT/TRACE refused). - Headers narrowed to
Authorization,Content-Type,x-client-request-id(every header the SPA actually sends). - Operators with custom flows override with
STRICT_CORS_ALLOW_METHODS=GET,POST,PATCH,…and/orSTRICT_CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Custom,….
API / IaC diff summary¶
api/main.py¶
- The CORSMiddleware block now branches on
STRICT_CORS=true. The legacy["*"]path is preserved as the default. - New env vars (no Bicep change yet — flipping the gate is a separate post-soak PR per Rule 4):
STRICT_CORS— turns on narrowing.STRICT_CORS_ALLOW_METHODS— optional comma-separated override.STRICT_CORS_ALLOW_HEADERS— optional comma-separated override.
Tests¶
- New
api/tests/test_strict_cors.py(8 tests) covers: - OFF: wildcard method expansion and wildcard header echoing.
- ON: known method (POST) accepted, unknown method (PATCH) rejected.
- ON: known header (Authorization) accepted, unknown header rejected.
- ON: method override accepts the extra method.
- ON: header override accepts the extra header.
Validation evidence¶
$ uv run pytest -q api/tests/test_strict_cors.py
........ [100%]
8 passed in 4.16s
$ uv run pytest -q api/tests
2117 passed, 3 skipped in 33.49s
$ uv run ruff check api/main.py api/tests/test_strict_cors.py
All checks passed!
No new deployment required. Container App env vars unchanged — the narrowing is dormant until an operator opts in.
Hardening discipline (§12a)¶
- In scope: cors
- RBAC change is single-PR safe (no role narrowed) — N/A, no RBAC
- Persona Matrix tests pass for owner / contributor / reader / dev_bypass — full suite green (2117 passed)
- Reader allowlist unchanged
- Capability Probe passes locally — N/A for code-only change
- New guard ships default-OFF behind
STRICT_*env var —STRICT_CORSdefaults to OFF; both the wildcard echo path and the narrowed-allowlist path are covered by tests - No
Depends(require_caller)added to an SSE event stream - Change note (this file) summarises persona impact: every persona
keeps working unchanged while
STRICT_CORSis unset (the default). Once flipped, only requests that try to use a method or header outside the (overridable) allowlist are rejected at preflight — the SPA never does that today.