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) <noreply@anthropic.com>
This commit is contained in:
Johan Lundberg 2026-06-10 08:53:49 +02:00
parent c7550cbf09
commit 519e3659a1
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
4 changed files with 81 additions and 1 deletions

View file

@ -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

View file

@ -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"

View file

@ -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)