diff --git a/core/middleware.py b/core/middleware.py index 82d1d0324..a0b7cd8b7 100644 --- a/core/middleware.py +++ b/core/middleware.py @@ -63,6 +63,14 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=()" + + is_https = ( + request.url.scheme == "https" + or request.headers.get("X-Forwarded-Proto") == "https" + ) + if is_https: + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" if is_report: response.headers["Content-Security-Policy"] = ( diff --git a/tests/test_security_headers_middleware.py b/tests/test_security_headers_middleware.py new file mode 100644 index 000000000..a7537c3c6 --- /dev/null +++ b/tests/test_security_headers_middleware.py @@ -0,0 +1,67 @@ +# tests/test_security_headers_middleware.py +""" +Focused regression coverage for `SecurityHeadersMiddleware` +(core/middleware.py), added alongside the HSTS + Permissions-Policy +hardening: + + 1. HSTS is emitted only for HTTPS requests, including those reaching + the app over a reverse proxy (`X-Forwarded-Proto: https`). + 2. HSTS is absent on plain HTTP so local/dev deployments are unaffected. + 3. `Permissions-Policy` locks down camera/geolocation but preserves + same-origin microphone access (`microphone=(self)`), so the app's + own voice/STT flow (`getUserMedia({ audio: true })`) keeps working. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from core.middleware import SecurityHeadersMiddleware + + +def _build_app(): + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/") + def root(): + return {"ok": True} + + return app + + +def _client(base_url="http://testserver"): + return TestClient(_build_app(), base_url=base_url) + + +def test_hsts_absent_on_plain_http(): + response = _client().get("/") + + assert "strict-transport-security" not in response.headers + + +def test_hsts_present_for_direct_https_requests(): + response = _client(base_url="https://testserver").get("/") + + assert response.headers["strict-transport-security"] == ( + "max-age=31536000; includeSubDomains" + ) + + +def test_hsts_present_via_x_forwarded_proto_https(): + response = _client().get("/", headers={"X-Forwarded-Proto": "https"}) + + assert response.headers["strict-transport-security"] == ( + "max-age=31536000; includeSubDomains" + ) + + +def test_permissions_policy_locks_camera_and_geolocation_but_allows_self_microphone(): + response = _client().get("/") + + policy = response.headers["permissions-policy"] + assert policy == "camera=(), microphone=(self), geolocation=()" + + # Explicitly pin the contract the reviewer flagged: an empty allowlist + # would also block the app's own same-origin voice/STT button. + assert "microphone=()" not in policy + assert "microphone=(self)" in policy