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

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