diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 4f94390a..a8d470be 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -687,21 +687,15 @@ def create_api( ) # ── Security Headers ────────────────────────────────── - @app.middleware("http") - async def security_headers_middleware(request: Request, call_next): - if request.headers.get("upgrade", "").lower() == "websocket": - return await call_next(request) - response = await call_next(request) - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" - response.headers["X-XSS-Protection"] = "1; mode=block" - response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()" - # HSTS only when behind TLS (reverse proxy sets X-Forwarded-Proto) - proto = request.headers.get("x-forwarded-proto", "") - if proto == "https" or request.url.scheme == "https": - response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" - return response + # Mode-aware helmet-style headers (#437). Trusted = minimal back-compat + # (nosniff + X-XSS-Protection disable). LAN adds clickjacking + + # referrer + permissions-policy + report-only CSP. Web adds strict CSP + # + HSTS-when-verified-HTTPS. See pinky_daemon.middleware.security_headers + # for the full matrix and the HSTS verification rules. + from pinky_daemon.middleware.security_headers import ( + build_security_headers_middleware, + ) + app.middleware("http")(build_security_headers_middleware()) session_store = SessionStore(db_path=db_path.replace(".db", "_sessions.db")) session_event_store = SessionEventStore(db_path=db_path.replace(".db", "_sessions.db")) diff --git a/src/pinky_daemon/middleware/__init__.py b/src/pinky_daemon/middleware/__init__.py new file mode 100644 index 00000000..880c6169 --- /dev/null +++ b/src/pinky_daemon/middleware/__init__.py @@ -0,0 +1,7 @@ +"""HTTP middleware modules for the pinky daemon. + +Currently houses :mod:`pinky_daemon.middleware.security_headers` (#437). +Future cross-cutting middleware introduced by the #433 hardening track +(rate limiting #438, CSRF #443, elevated-session enforcement #439) should +live here too. +""" diff --git a/src/pinky_daemon/middleware/security_headers.py b/src/pinky_daemon/middleware/security_headers.py new file mode 100644 index 00000000..a6ccdd81 --- /dev/null +++ b/src/pinky_daemon/middleware/security_headers.py @@ -0,0 +1,238 @@ +"""Security headers middleware (#437). + +Helmet-style HTTP response headers, gated by deployment mode. The ``trusted`` +posture stays minimal (we don't want to break the existing Mac Mini SPA in +ways the operator wouldn't notice); ``lan`` adds clickjacking + referrer +hardening; ``web`` adds HSTS and a Content Security Policy. + +Header matrix +------------- +============================ ======== ===== ===== +Header trusted lan web +============================ ======== ===== ===== +X-Content-Type-Options ✓ ✓ ✓ +X-Frame-Options ✗ ✓ ✓ +Referrer-Policy ✗ ✓ ✓ +Permissions-Policy ✗ ✓ ✓ +Content-Security-Policy ✗ basic strict +Strict-Transport-Security ✗ ✗ ✓ (verified-HTTPS only) +X-XSS-Protection ``0`` ``0`` ``0`` +============================ ======== ===== ===== + +``X-XSS-Protection: 0`` is the modern recommendation: the legacy auditor in +old browsers introduced its own XSS class and OWASP / MDN now recommend +disabling it explicitly. See OWASP "Secure Headers" project, 2024 guidance. + +CSP enforcement +--------------- +Lands in **report-only** mode by default (``Content-Security-Policy-Report-Only``). +Operators flip to enforcing via :data:`CSP_ENFORCING_ENV`. Rationale: the +Svelte SPA uses inline styles deliberately; flipping straight to enforcing +is a recipe for breaking the UI on a Friday. Soak in report-only first, +fix any genuine surprises, then enforce. + +HSTS +---- +Only emitted when the request's HTTPS-ness can be **verified**. A bare +``X-Forwarded-Proto: https`` from an untrusted peer must NOT trigger HSTS, +because an attacker who can MITM your HTTP traffic could pin HSTS for a +year on a victim's browser. The check uses +:func:`pinky_daemon.net.client_identity.resolve_client_identity` so trust +evaluation matches the rest of the #433 track. + +Part of #433. Issue #437. +""" + +from __future__ import annotations + +import logging +import os +from collections.abc import Awaitable, Callable + +from fastapi import Request + +from pinky_daemon.config import DeploymentMode +from pinky_daemon.net.client_identity import ( + peer_is_trusted, + raw_peer_from_request, +) + +__all__ = [ + "CSP_ENFORCING_ENV", + "build_csp", + "build_security_headers_middleware", + "headers_for_mode", + "is_verified_https", +] + +logger = logging.getLogger(__name__) + +#: Env var that flips CSP from report-only to enforcing. +CSP_ENFORCING_ENV = "PINKY_CSP_ENFORCING" + + +# ── Header builders ─────────────────────────────────────────────── + + +_PERMISSIONS_POLICY_BASIC = "camera=(), microphone=(), geolocation=()" +_PERMISSIONS_POLICY_STRICT = ( + "camera=(), microphone=(), geolocation=(), interest-cohort=(), " + "payment=(), usb=(), magnetometer=(), accelerometer=(), gyroscope=()" +) + + +def build_csp(mode: DeploymentMode) -> str: + """Build the Content-Security-Policy string for ``mode``. + + Both ``lan`` and ``web`` policies allow ``'unsafe-inline'`` for + ``style-src`` because the Svelte SPA uses inline styles deliberately + (per CLAUDE.md frontend conventions). Removing it requires a refactor + that's out of scope for #437 — call out as known compromise. + + ``web`` also locks down ``object-src`` and adds ``upgrade-insecure-requests``. + """ + common = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", # Svelte inline styles, see CLAUDE.md + "img-src 'self' data:", + "font-src 'self' data:", + # SSE + websocket to same origin + "connect-src 'self' ws: wss:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ] + if mode is DeploymentMode.WEB: + return "; ".join( + common + ["object-src 'none'", "upgrade-insecure-requests"] + ) + return "; ".join(common) + + +def headers_for_mode(mode: DeploymentMode) -> dict[str, str]: + """Return the static (non-HSTS) header set for a given posture. + + HSTS is intentionally absent — its inclusion depends on per-request + HTTPS-verification logic that lives in the middleware closure. + Callers that just want to inspect "what headers would mode X emit" use + this helper for the static slice. + """ + out: dict[str, str] = { + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "0", + } + if mode is DeploymentMode.TRUSTED: + return out + # lan + web share clickjacking + referrer + permissions-policy. + out["X-Frame-Options"] = "DENY" + out["Referrer-Policy"] = "strict-origin-when-cross-origin" + out["Permissions-Policy"] = ( + _PERMISSIONS_POLICY_STRICT + if mode is DeploymentMode.WEB + else _PERMISSIONS_POLICY_BASIC + ) + return out + + +# ── HSTS / HTTPS verification ───────────────────────────────────── + + +def is_verified_https(request: Request) -> bool: + """``True`` when the request can be **verified** to have arrived over HTTPS. + + Two acceptance paths: + + 1. Direct TLS — ``request.url.scheme == "https"``. The ASGI server + (uvicorn behind a TLS-terminating socket) sets this; can't be + spoofed by header. + 2. Trusted reverse proxy — ``X-Forwarded-Proto: https`` AND the + immediate peer is one of the configured trusted proxies (#442). + + A bare ``X-Forwarded-Proto: https`` from an untrusted peer is **not** + enough — that's the HSTS-poisoning vector we explicitly want to avoid. + """ + if request.url.scheme == "https": + return True + + proto = request.headers.get("x-forwarded-proto", "").lower().strip() + if proto != "https": + return False + + # The header says HTTPS — only trust it when it came from a configured + # reverse proxy. Bare X-Forwarded-Proto from an arbitrary peer must NOT + # be enough; that's the HSTS-poisoning vector. + state = request.app.state if request.app else None + trusted = ( + getattr(state, "trusted_proxies", None) if state else None + ) or () + raw_peer = raw_peer_from_request(request) + return peer_is_trusted(raw_peer, trusted) + + +# ── Middleware factory ──────────────────────────────────────────── + + +def _csp_enforcing_from_env(env: dict | None = None) -> bool: + source = env if env is not None else os.environ + raw = (source.get(CSP_ENFORCING_ENV) or "").strip().lower() + return raw in ("1", "true", "yes", "on") + + +def build_security_headers_middleware(*, csp_enforcing: bool | None = None): + """Return an ASGI middleware closure that emits hardened response headers. + + Args: + csp_enforcing: When ``True``, emit ``Content-Security-Policy`` + (enforcing). When ``False``, emit ``Content-Security-Policy-Report-Only``. + When ``None`` (default), read :data:`CSP_ENFORCING_ENV` per + request — slow path but lets tests flip behavior without + recreating the middleware. + + The middleware reads ``app.state.deployment_mode`` per request so the + same closure works in tests that swap modes. Websocket upgrades pass + through untouched (header injection breaks WS handshakes in some + clients). + """ + + async def security_headers_mw( + request: Request, + call_next: Callable[[Request], Awaitable], + ): + # Don't touch WebSocket upgrades — Starlette handles those before + # response middleware anyway, but be explicit. + if request.headers.get("upgrade", "").lower() == "websocket": + return await call_next(request) + + response = await call_next(request) + + state = request.app.state if request.app else None + mode = getattr(state, "deployment_mode", DeploymentMode.TRUSTED) + + for name, value in headers_for_mode(mode).items(): + response.headers[name] = value + + # CSP only in lan/web. Trusted is purely back-compat. + if mode is not DeploymentMode.TRUSTED: + csp_value = build_csp(mode) + enforcing = ( + csp_enforcing + if csp_enforcing is not None + else _csp_enforcing_from_env() + ) + header_name = ( + "Content-Security-Policy" + if enforcing + else "Content-Security-Policy-Report-Only" + ) + response.headers[header_name] = csp_value + + # HSTS only in web mode and only when HTTPS is verified. + if mode is DeploymentMode.WEB and is_verified_https(request): + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + + return response + + return security_headers_mw diff --git a/src/pinky_daemon/net/client_identity.py b/src/pinky_daemon/net/client_identity.py index 492c7f4d..91e4a132 100644 --- a/src/pinky_daemon/net/client_identity.py +++ b/src/pinky_daemon/net/client_identity.py @@ -65,7 +65,10 @@ "ClientIdentity", "get_client_identity", "parse_trusted_proxies", + "peer_is_trusted", + "raw_peer_from_request", "resolve_client_identity", + "trusted_proxies_from_env", ] logger = logging.getLogger(__name__) @@ -181,7 +184,7 @@ def _parse_ip(token: str) -> IPAddress | None: return None -def _peer_is_trusted(peer: IPAddress, trusted: tuple[IPNetwork, ...]) -> bool: +def peer_is_trusted(peer: IPAddress, trusted: tuple[IPNetwork, ...]) -> bool: """True when ``peer`` falls inside any configured trusted-proxy CIDR.""" for net in trusted: # IPv4 vs IPv6 mismatch raises TypeError on `in` — guard explicitly so @@ -258,7 +261,7 @@ def _warn_throttled(key: str, message: str) -> None: # ── Resolver ────────────────────────────────────────────────────── -def _raw_peer_from_request(request: Request) -> IPAddress: +def raw_peer_from_request(request: Request) -> IPAddress: """Pull the immediate TCP peer off a Starlette/FastAPI request. Falls back to the unspecified IPv4 address (``0.0.0.0``) when ``client`` @@ -291,7 +294,7 @@ def _client_from_chain( # Walk from the right (closest hop = last entry per RFC 7239 / XFF # convention). Skip any hop that is itself a trusted proxy. for hop in reversed(chain): - if not _peer_is_trusted(hop, trusted): + if not peer_is_trusted(hop, trusted): return hop # Whole chain is trusted proxies — the leftmost entry is the client. return chain[0] @@ -307,7 +310,7 @@ def resolve_client_identity( See module docstring for the full spec. This is the single function every IP-consuming feature in the hardening track should call. """ - raw_peer = _raw_peer_from_request(request) + raw_peer = raw_peer_from_request(request) # Trusted-LAN posture: never honor forwarded headers, full stop. if mode is DeploymentMode.TRUSTED: @@ -342,7 +345,7 @@ def resolve_client_identity( # Did anyone send a forwarding header at all? sent_forwarding = bool(forwarded_header or xff_header) - if not _peer_is_trusted(raw_peer, trusted_proxies): + if not peer_is_trusted(raw_peer, trusted_proxies): # Untrusted peer claiming to be a proxy — drop their chain and log # so audit can correlate suspected spoofing attempts. if sent_forwarding: diff --git a/tests/test_security_headers.py b/tests/test_security_headers.py new file mode 100644 index 00000000..13024ee6 --- /dev/null +++ b/tests/test_security_headers.py @@ -0,0 +1,322 @@ +"""Tests for the mode-aware security headers middleware (#437). + +Covers: + +* The static header matrix per mode (`headers_for_mode`). +* CSP construction per mode (`build_csp`). +* HSTS verification logic (`is_verified_https`). +* The ASGI middleware closure: header presence, CSP report-only vs. + enforcing, HSTS only emitted when verified, websocket upgrades pass + through untouched. +* End-to-end through `create_api` for trusted / lan / web. +""" + +from __future__ import annotations + +import ipaddress +import os +import tempfile +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +from pinky_daemon.config import DeploymentMode +from pinky_daemon.middleware.security_headers import ( + CSP_ENFORCING_ENV, + build_csp, + build_security_headers_middleware, + headers_for_mode, + is_verified_https, +) + +# ── Static header matrix ────────────────────────────────────── + + +class TestHeadersForMode: + def test_trusted_minimal(self): + h = headers_for_mode(DeploymentMode.TRUSTED) + assert h["X-Content-Type-Options"] == "nosniff" + assert h["X-XSS-Protection"] == "0" + # Trusted does NOT add the lan/web set. + assert "X-Frame-Options" not in h + assert "Referrer-Policy" not in h + assert "Permissions-Policy" not in h + + def test_lan_adds_clickjacking_referrer_perms(self): + h = headers_for_mode(DeploymentMode.LAN) + assert h["X-Content-Type-Options"] == "nosniff" + assert h["X-XSS-Protection"] == "0" + assert h["X-Frame-Options"] == "DENY" + assert h["Referrer-Policy"] == "strict-origin-when-cross-origin" + assert "Permissions-Policy" in h + # Basic policy: a few things off. + assert "camera=()" in h["Permissions-Policy"] + + def test_web_uses_strict_permissions_policy(self): + h = headers_for_mode(DeploymentMode.WEB) + # Strict policy lists more sinks. + assert "interest-cohort=()" in h["Permissions-Policy"] + assert "payment=()" in h["Permissions-Policy"] + # Web still has the lan-set headers. + assert h["X-Frame-Options"] == "DENY" + + def test_x_xss_protection_disabled_in_all_modes(self): + # Modern recommendation: disable the legacy auditor explicitly. + for mode in DeploymentMode: + assert headers_for_mode(mode)["X-XSS-Protection"] == "0" + + +# ── CSP ──────────────────────────────────────────────────────── + + +class TestBuildCsp: + def test_lan_csp_has_required_directives(self): + csp = build_csp(DeploymentMode.LAN) + for directive in ( + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", # known compromise per CLAUDE.md + "img-src 'self' data:", + "connect-src 'self' ws: wss:", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ): + assert directive in csp + + def test_web_csp_adds_object_src_and_upgrade(self): + csp = build_csp(DeploymentMode.WEB) + assert "object-src 'none'" in csp + assert "upgrade-insecure-requests" in csp + + def test_lan_csp_does_not_add_web_only_directives(self): + csp = build_csp(DeploymentMode.LAN) + assert "object-src 'none'" not in csp + assert "upgrade-insecure-requests" not in csp + + +# ── HSTS verification ────────────────────────────────────────── + + +class _FakeApp: + def __init__(self, mode, trusted=()): + self.state = type("State", (), {})() + self.state.deployment_mode = mode + self.state.trusted_proxies = trusted + + +class _FakeRequest: + def __init__( + self, + peer="127.0.0.1", + scheme="http", + headers=None, + app=None, + ): + self.client = type("C", (), {"host": peer})() + self.url = type("U", (), {"scheme": scheme})() + self.headers = headers or {} + self.app = app or _FakeApp(DeploymentMode.WEB) + + +class TestIsVerifiedHttps: + def test_direct_tls_is_verified(self): + req = _FakeRequest(scheme="https", peer="127.0.0.1") + assert is_verified_https(req) is True + + def test_xforwarded_proto_https_with_trusted_peer_is_verified(self): + trusted = (ipaddress.ip_network("127.0.0.1/32"),) + app = _FakeApp(DeploymentMode.WEB, trusted=trusted) + req = _FakeRequest( + peer="127.0.0.1", + headers={"x-forwarded-proto": "https"}, + app=app, + ) + assert is_verified_https(req) is True + + def test_xforwarded_proto_https_with_untrusted_peer_rejected(self): + # Direct attacker on the open internet sets the header. + # MUST NOT poison HSTS. + app = _FakeApp(DeploymentMode.WEB, trusted=()) + req = _FakeRequest( + peer="203.0.113.7", + headers={"x-forwarded-proto": "https"}, + app=app, + ) + assert is_verified_https(req) is False + + def test_xforwarded_proto_http_returns_false(self): + trusted = (ipaddress.ip_network("127.0.0.1/32"),) + app = _FakeApp(DeploymentMode.WEB, trusted=trusted) + req = _FakeRequest( + peer="127.0.0.1", + headers={"x-forwarded-proto": "http"}, + app=app, + ) + assert is_verified_https(req) is False + + def test_no_proto_header_no_tls_returns_false(self): + req = _FakeRequest(peer="127.0.0.1") + assert is_verified_https(req) is False + + +# ── Middleware closure ──────────────────────────────────────── + + +async def _make_response(_req): + from fastapi.responses import JSONResponse + + return JSONResponse({"ok": True}) + + +class TestMiddlewareClosure: + @pytest.mark.asyncio + async def test_trusted_mode_minimal_headers(self): + mw = build_security_headers_middleware(csp_enforcing=False) + req = _FakeRequest(app=_FakeApp(DeploymentMode.TRUSTED)) + resp = await mw(req, _make_response) + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-XSS-Protection"] == "0" + assert "X-Frame-Options" not in resp.headers + assert "Content-Security-Policy" not in resp.headers + assert "Content-Security-Policy-Report-Only" not in resp.headers + assert "Strict-Transport-Security" not in resp.headers + + @pytest.mark.asyncio + async def test_lan_mode_emits_csp_report_only_by_default(self): + mw = build_security_headers_middleware(csp_enforcing=False) + req = _FakeRequest(app=_FakeApp(DeploymentMode.LAN)) + resp = await mw(req, _make_response) + assert resp.headers["X-Frame-Options"] == "DENY" + assert "Content-Security-Policy-Report-Only" in resp.headers + assert "Content-Security-Policy" not in resp.headers + # No HSTS in LAN even with verified HTTPS. + assert "Strict-Transport-Security" not in resp.headers + + @pytest.mark.asyncio + async def test_csp_enforcing_emits_enforcing_header(self): + mw = build_security_headers_middleware(csp_enforcing=True) + req = _FakeRequest(app=_FakeApp(DeploymentMode.WEB)) + resp = await mw(req, _make_response) + assert "Content-Security-Policy" in resp.headers + assert "Content-Security-Policy-Report-Only" not in resp.headers + + @pytest.mark.asyncio + async def test_csp_enforcing_env_flips_to_enforcing(self): + mw = build_security_headers_middleware() + req = _FakeRequest(app=_FakeApp(DeploymentMode.LAN)) + with patch.dict(os.environ, {CSP_ENFORCING_ENV: "true"}, clear=False): + resp = await mw(req, _make_response) + assert "Content-Security-Policy" in resp.headers + + @pytest.mark.asyncio + async def test_web_mode_no_hsts_without_verified_https(self): + mw = build_security_headers_middleware() + req = _FakeRequest(app=_FakeApp(DeploymentMode.WEB)) # http + resp = await mw(req, _make_response) + assert "Strict-Transport-Security" not in resp.headers + + @pytest.mark.asyncio + async def test_web_mode_hsts_when_direct_tls(self): + mw = build_security_headers_middleware() + req = _FakeRequest( + scheme="https", app=_FakeApp(DeploymentMode.WEB) + ) + resp = await mw(req, _make_response) + assert ( + resp.headers["Strict-Transport-Security"] + == "max-age=31536000; includeSubDomains" + ) + + @pytest.mark.asyncio + async def test_web_mode_hsts_when_trusted_proxy_says_https(self): + mw = build_security_headers_middleware() + trusted = (ipaddress.ip_network("127.0.0.1/32"),) + app = _FakeApp(DeploymentMode.WEB, trusted=trusted) + req = _FakeRequest( + peer="127.0.0.1", + headers={"x-forwarded-proto": "https"}, + app=app, + ) + resp = await mw(req, _make_response) + assert "Strict-Transport-Security" in resp.headers + + @pytest.mark.asyncio + async def test_web_mode_no_hsts_when_untrusted_proxy_claims_https(self): + # The HSTS poisoning vector — must remain blocked. + mw = build_security_headers_middleware() + app = _FakeApp(DeploymentMode.WEB, trusted=()) + req = _FakeRequest( + peer="203.0.113.7", + headers={"x-forwarded-proto": "https"}, + app=app, + ) + resp = await mw(req, _make_response) + assert "Strict-Transport-Security" not in resp.headers + + @pytest.mark.asyncio + async def test_websocket_upgrade_passes_through_untouched(self): + # Returning a non-Response sentinel lets us assert no header + # mutation happened. (The middleware short-circuits before + # call_next's return is touched.) + async def _ws_response(_req): + return "ws-handshake-result" + + mw = build_security_headers_middleware() + req = _FakeRequest( + headers={"upgrade": "websocket"}, + app=_FakeApp(DeploymentMode.WEB), + ) + out = await mw(req, _ws_response) + assert out == "ws-handshake-result" + + +# ── End-to-end through create_api ────────────────────────────── + + +@pytest.fixture +def tmp_db_path(): + with tempfile.TemporaryDirectory() as tmpdir: + yield os.path.join(tmpdir, "test.db") + + +def _api_with_lan_bypass(tmp_db_path, mode: DeploymentMode): + """Build a real FastAPI app for ``mode`` while letting TestClient + through the LAN filter (TestClient's peer is 0.0.0.0).""" + from pinky_daemon.api import create_api + + env = {**os.environ, "PINKY_LAN_EXTRA_CIDRS": "0.0.0.0/32"} + with patch.dict(os.environ, env, clear=True): + return create_api(db_path=tmp_db_path, deployment_mode=mode) + + +class TestCreateApiSecurityHeaders: + def test_trusted_mode_minimal_headers_on_real_request(self, tmp_db_path): + app = _api_with_lan_bypass(tmp_db_path, DeploymentMode.TRUSTED) + client = TestClient(app) + resp = client.get("/api") + assert resp.headers["X-Content-Type-Options"] == "nosniff" + assert resp.headers["X-XSS-Protection"] == "0" + assert "X-Frame-Options" not in resp.headers + assert "Content-Security-Policy" not in resp.headers + assert "Content-Security-Policy-Report-Only" not in resp.headers + + def test_lan_mode_full_set_minus_hsts(self, tmp_db_path): + app = _api_with_lan_bypass(tmp_db_path, DeploymentMode.LAN) + client = TestClient(app) + resp = client.get("/api") + assert resp.headers["X-Frame-Options"] == "DENY" + assert resp.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + # CSP report-only by default + assert "Content-Security-Policy-Report-Only" in resp.headers + assert "Strict-Transport-Security" not in resp.headers + + def test_web_mode_no_hsts_over_plain_http(self, tmp_db_path): + # TestClient uses http://, no trusted-proxy header → HSTS must NOT + # appear, even though we're in web mode. + app = _api_with_lan_bypass(tmp_db_path, DeploymentMode.WEB) + client = TestClient(app) + resp = client.get("/api") + assert "Strict-Transport-Security" not in resp.headers + assert "Content-Security-Policy-Report-Only" in resp.headers