Files
odysseus/tests/test_security_headers_middleware.py
T
Giuseppe 95c2dca4b5 fix(security): add HSTS and Permissions-Policy to SecurityHeadersMiddleware (#3081)
* fix(security): add HSTS and Permissions-Policy headers to SecurityHeadersMiddleware

Strict-Transport-Security is sent only when the connection is HTTPS
(detected via request.url.scheme or X-Forwarded-Proto: https), so
plain-HTTP dev deployments behind a reverse proxy are unaffected.

Permissions-Policy disables camera, microphone, and geolocation APIs
unconditionally — Odysseus does not use them, and this prevents a
successful XSS from requesting browser-native sensor access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(security): scope Permissions-Policy microphone directive to same-origin

Reviewers on PR #3081 (alteixeira20, NubsCarson) flagged that
microphone=() blocks mic access for same-origin (self) too, breaking
Odysseus's own voice/STT flow (getUserMedia({audio: true}) in
static/js/voiceRecorder.js). Scope it to microphone=(self) so
third-party origins stay locked out while the app's own UI keeps mic
access; camera and geolocation remain fully disabled as unused.

Adds focused middleware tests covering HSTS scoping (HTTPS direct,
X-Forwarded-Proto, absent on plain HTTP) and the Permissions-Policy
same-origin microphone contract.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 04:58:33 +01:00

68 lines
2.0 KiB
Python

# 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