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.endpoints import router as oidc_router
from porchlight.oidc.provider import create_oidc_server from porchlight.oidc.provider import create_oidc_server
from porchlight.rate_limit import limiter from porchlight.rate_limit import limiter
from porchlight.security_headers import SecurityHeadersMiddleware
from porchlight.store.sqlite.db import open_db from porchlight.store.sqlite.db import open_db
from porchlight.store.sqlite.repositories import ( from porchlight.store.sqlite.repositories import (
SQLiteConsentRepository, SQLiteConsentRepository,
@ -130,6 +131,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings 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 middleware
session_secret = _resolve_session_secret(settings) session_secret = _resolve_session_secret(settings)
app.state.session_secret = session_secret app.state.session_secret = session_secret

View file

@ -50,6 +50,11 @@ class Settings(BaseSettings):
session_https_only: bool = True session_https_only: bool = True
session_max_age: int = 28800 # Cookie lifetime in seconds (default 8 hours) 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", # WebAuthn user verification requirement: "preferred" (default), "required",
# or "discouraged". Identity providers may want "required". # or "discouraged". Identity providers may want "required".
webauthn_user_verification: str = "preferred" 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)

View file

@ -1,7 +1,8 @@
from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from httpx import AsyncClient from httpx import ASGITransport, AsyncClient
from porchlight.app import create_app from porchlight.app import create_app
from porchlight.config import Settings 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=")] session_cookies = [c for c in set_cookies if c.startswith("session=")]
assert session_cookies, "no session cookie set" assert session_cookies, "no session cookie set"
assert "Max-Age=28800" in session_cookies[0] 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", "")