mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
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>
This commit is contained in:
@@ -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"] = (
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user