From 519e3659a1fbb58ffb731d3cdeef5f1a19cee47e Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Wed, 10 Jun 2026 08:53:49 +0200 Subject: [PATCH] feat(security): add baseline security-header middleware No security headers were set. Add SecurityHeadersMiddleware applying Content-Security-Policy (configurable), X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy, and Strict-Transport-Security on HTTPS deployments. Verified HTMX/WebAuthn/forms still work under the CSP. Refs: porchlight-1ph Co-Authored-By: Claude Opus 4.8 (1M context) --- src/porchlight/app.py | 8 ++++++ src/porchlight/config.py | 5 ++++ src/porchlight/security_headers.py | 42 ++++++++++++++++++++++++++++++ tests/test_app.py | 27 ++++++++++++++++++- 4 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 src/porchlight/security_headers.py diff --git a/src/porchlight/app.py b/src/porchlight/app.py index c186d8d..272f9ce 100644 --- a/src/porchlight/app.py +++ b/src/porchlight/app.py @@ -26,6 +26,7 @@ from porchlight.manage.routes import router as manage_router from porchlight.oidc.endpoints import router as oidc_router from porchlight.oidc.provider import create_oidc_server from porchlight.rate_limit import limiter +from porchlight.security_headers import SecurityHeadersMiddleware from porchlight.store.sqlite.db import open_db from porchlight.store.sqlite.repositories import ( SQLiteConsentRepository, @@ -130,6 +131,13 @@ def create_app(settings: Settings | None = None) -> FastAPI: app.state.settings = settings + # Security headers (outermost so they apply to every response) + app.add_middleware( + SecurityHeadersMiddleware, # ty: ignore[invalid-argument-type] + csp=settings.content_security_policy, + hsts=settings.session_https_only, + ) + # Session middleware session_secret = _resolve_session_secret(settings) app.state.session_secret = session_secret diff --git a/src/porchlight/config.py b/src/porchlight/config.py index d2e6cc1..20ff9ea 100644 --- a/src/porchlight/config.py +++ b/src/porchlight/config.py @@ -50,6 +50,11 @@ class Settings(BaseSettings): session_https_only: bool = True session_max_age: int = 28800 # Cookie lifetime in seconds (default 8 hours) + # Content-Security-Policy applied to all responses. + content_security_policy: str = ( + "default-src 'self'; img-src 'self' data: https:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + ) + # WebAuthn user verification requirement: "preferred" (default), "required", # or "discouraged". Identity providers may want "required". webauthn_user_verification: str = "preferred" diff --git a/src/porchlight/security_headers.py b/src/porchlight/security_headers.py new file mode 100644 index 0000000..4cc419b --- /dev/null +++ b/src/porchlight/security_headers.py @@ -0,0 +1,42 @@ +"""ASGI middleware adding baseline security response headers.""" + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + + +class SecurityHeadersMiddleware: + """Attach security headers to every HTTP response. + + Sets Content-Security-Policy, X-Content-Type-Options, X-Frame-Options and + Referrer-Policy on all responses, plus Strict-Transport-Security when the + deployment is HTTPS. Existing headers are not overwritten. + """ + + def __init__(self, app: ASGIApp, csp: str, hsts: bool) -> None: + self.app = app + self._csp = csp + self._hsts = hsts + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + async def send_with_headers(message: Message) -> None: + if message["type"] == "http.response.start": + headers = message.setdefault("headers", []) + existing = {name.decode().lower() for name, _ in headers} + + def add(name: str, value: str) -> None: + if name.lower() not in existing: + headers.append((name.encode(), value.encode())) + existing.add(name.lower()) + + add("content-security-policy", self._csp) + add("x-content-type-options", "nosniff") + add("x-frame-options", "DENY") + add("referrer-policy", "strict-origin-when-cross-origin") + if self._hsts: + add("strict-transport-security", "max-age=63072000; includeSubDomains") + await send(message) + + await self.app(scope, receive, send_with_headers) diff --git a/tests/test_app.py b/tests/test_app.py index 5ce3bcf..a46d16d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,7 +1,8 @@ +from pathlib import Path from unittest.mock import MagicMock import pytest -from httpx import AsyncClient +from httpx import ASGITransport, AsyncClient from porchlight.app import create_app from porchlight.config import Settings @@ -84,3 +85,27 @@ async def test_session_cookie_has_explicit_max_age(client: AsyncClient) -> None: session_cookies = [c for c in set_cookies if c.startswith("session=")] assert session_cookies, "no session cookie set" assert "Max-Age=28800" in session_cookies[0] + + +async def test_security_headers_present(client: AsyncClient) -> None: + res = await client.get("/login") + assert res.headers.get("x-content-type-options") == "nosniff" + assert res.headers.get("x-frame-options") == "DENY" + assert "default-src 'self'" in res.headers.get("content-security-policy", "") + assert "frame-ancestors 'none'" in res.headers.get("content-security-policy", "") + assert res.headers.get("referrer-policy") == "strict-origin-when-cross-origin" + + +async def test_hsts_present_when_https_only(tmp_path: Path) -> None: + settings = Settings( + issuer="https://op.example.com", + sqlite_path=":memory:", + session_secret="x" * 32, + session_https_only=True, + signing_key_path=str(tmp_path / "keys"), + ) + app = create_app(settings) + transport = ASGITransport(app=app) + async with app.router.lifespan_context(app), AsyncClient(transport=transport, base_url=settings.issuer) as ac: + res = await ac.get("/health") + assert "max-age=" in res.headers.get("strict-transport-security", "")