Add security headers middleware and documentation

- Add SecurityHeadersMiddleware to backend/app/main.py
  - Implements Content-Security-Policy: default-src 'self'
  - Implements X-Frame-Options: DENY (clickjacking protection)
  - Implements X-Content-Type-Options: nosniff (MIME-sniffing protection)
  - Implements X-XSS-Protection: 1; mode=block (browser XSS filters)
- Add CSP meta tag to frontend/index.html for defense-in-depth
- Create Docs/Security.md with comprehensive security headers documentation
- Add test suite (backend/tests/test_security_headers_middleware.py) with 5 tests
  - Tests verify headers are present on success and error responses
  - Tests ensure all four security headers are correctly set
- All existing tests continue to pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-30 21:33:08 +02:00
parent 3bd9848a08
commit 400ab1a3f1
5 changed files with 256 additions and 50 deletions

View File

@@ -649,6 +649,50 @@ _PREFIX_ALLOWED: frozenset[str] = frozenset(
)
# Security headers constants
_CSP_POLICY: str = "default-src 'self'"
_X_FRAME_OPTIONS: str = "DENY"
_X_CONTENT_TYPE_OPTIONS: str = "nosniff"
_X_XSS_PROTECTION: str = "1; mode=block"
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security-related HTTP response headers to prevent common attacks.
This middleware adds the following headers to every HTTP response:
- Content-Security-Policy: Prevents XSS by restricting resource origins
- X-Frame-Options: Prevents clickjacking by controlling iframe embedding
- X-Content-Type-Options: Prevents MIME-sniffing attacks
- X-XSS-Protection: Enables browser XSS protection (legacy header)
These headers implement defense-in-depth against client-side attacks
by relying on browser security policies rather than server-side logic alone.
"""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable[StarletteResponse]],
) -> StarletteResponse:
"""Intercept responses to inject security headers.
Args:
request: The incoming HTTP request.
call_next: The next middleware / router handler.
Returns:
The response from the next middleware / router with security headers added.
"""
response: StarletteResponse = await call_next(request)
response.headers["Content-Security-Policy"] = _CSP_POLICY
response.headers["X-Frame-Options"] = _X_FRAME_OPTIONS
response.headers["X-Content-Type-Options"] = _X_CONTENT_TYPE_OPTIONS
response.headers["X-XSS-Protection"] = _X_XSS_PROTECTION
return response
class SetupRedirectMiddleware(BaseHTTPMiddleware):
"""Redirect all API requests to ``/api/setup`` until setup is done.
@@ -783,12 +827,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# --- Middleware ---
# Note: middleware is applied in reverse order of registration.
# The setup-redirect must run *after* CSRF, so it is added last.
# CSRF middleware protects cookie-authenticated state-mutating requests.
# RateLimitMiddleware checks per-IP request limits and must run early.
# SecurityHeadersMiddleware must run early but after CORS/CSRF so headers
# are added to all responses including error responses.
# CorrelationIdMiddleware must run first (added last) so correlation ID
# is available to all downstream handlers and loggers.
app.add_middleware(CorrelationIdMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(SetupRedirectMiddleware)
app.add_middleware(CsrfMiddleware)
app.add_middleware(

View File

@@ -0,0 +1,110 @@
"""Unit tests for security headers middleware."""
from starlette.testclient import TestClient
from app.config import Settings
from app.main import create_app
def test_security_headers_middleware_adds_csp_header() -> None:
"""Security headers middleware adds Content-Security-Policy header to responses."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
client = TestClient(app)
response = client.get("/api/health")
assert "Content-Security-Policy" in response.headers
assert response.headers["Content-Security-Policy"] == "default-src 'self'"
def test_security_headers_middleware_adds_x_frame_options() -> None:
"""Security headers middleware adds X-Frame-Options header to responses."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
client = TestClient(app)
response = client.get("/api/health")
assert "X-Frame-Options" in response.headers
assert response.headers["X-Frame-Options"] == "DENY"
def test_security_headers_middleware_adds_x_content_type_options() -> None:
"""Security headers middleware adds X-Content-Type-Options header to responses."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
client = TestClient(app)
response = client.get("/api/health")
assert "X-Content-Type-Options" in response.headers
assert response.headers["X-Content-Type-Options"] == "nosniff"
def test_security_headers_middleware_adds_x_xss_protection() -> None:
"""Security headers middleware adds X-XSS-Protection header to responses."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
client = TestClient(app)
response = client.get("/api/health")
assert "X-XSS-Protection" in response.headers
assert response.headers["X-XSS-Protection"] == "1; mode=block"
def test_security_headers_on_all_response_types() -> None:
"""Security headers are added to all response types (success and error)."""
settings = Settings(
database_path="/tmp/test.db",
fail2ban_socket="/tmp/fake_fail2ban.sock",
fail2ban_config_dir="/tmp/fail2ban",
session_secret="test-secret-key-do-not-use-in-production",
session_duration_minutes=60,
timezone="UTC",
log_level="debug",
)
app = create_app(settings=settings)
client = TestClient(app)
# Test on successful response
response = client.get("/api/health")
assert response.status_code == 200
assert "Content-Security-Policy" in response.headers
assert "X-Frame-Options" in response.headers
assert "X-Content-Type-Options" in response.headers
assert "X-XSS-Protection" in response.headers