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:
parent
c7550cbf09
commit
519e3659a1
4 changed files with 81 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
42
src/porchlight/security_headers.py
Normal file
42
src/porchlight/security_headers.py
Normal 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)
|
||||
|
|
@ -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", "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue