From 95c2dca4b5d64725ea34e81745d3bbdaf3f81a03 Mon Sep 17 00:00:00 2001 From: Giuseppe Date: Sun, 7 Jun 2026 05:58:33 +0200 Subject: [PATCH] fix(security): add HSTS and Permissions-Policy to SecurityHeadersMiddleware (#3081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --- core/middleware.py | 8 +++ tests/test_security_headers_middleware.py | 67 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_security_headers_middleware.py 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