fix: make security headers configurable and add tests

This commit is contained in:
Simone
2026-02-24 17:36:44 +01:00
parent 93161d3800
commit 3fd5fe5f8c
5 changed files with 219 additions and 12 deletions

View File

@@ -49,6 +49,11 @@ class Settings(BaseSettings):
cors_origins: str = ""
base_url: str = ""
# Security response headers (blank disables header injection)
security_header_x_content_type_options: str = ""
security_header_x_frame_options: str = ""
security_header_referrer_policy: str = ""
security_header_permissions_policy: str = ""
# Database lifecycle
db_auto_migrate: bool = False

View File

@@ -0,0 +1,81 @@
"""ASGI middleware for configurable security response headers."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class SecurityHeadersMiddleware:
"""Inject configured security headers into every HTTP response."""
_X_CONTENT_TYPE_OPTIONS = b"X-Content-Type-Options"
_X_FRAME_OPTIONS = b"X-Frame-Options"
_REFERRER_POLICY = b"Referrer-Policy"
_PERMISSIONS_POLICY = b"Permissions-Policy"
def __init__(
self,
app: ASGIApp,
*,
x_content_type_options: str = "",
x_frame_options: str = "",
referrer_policy: str = "",
permissions_policy: str = "",
) -> None:
self._app = app
self._configured_headers = self._build_configured_headers(
x_content_type_options=x_content_type_options,
x_frame_options=x_frame_options,
referrer_policy=referrer_policy,
permissions_policy=permissions_policy,
)
@classmethod
def _build_configured_headers(
cls,
*,
x_content_type_options: str,
x_frame_options: str,
referrer_policy: str,
permissions_policy: str,
) -> tuple[tuple[bytes, bytes, bytes], ...]:
configured: list[tuple[bytes, bytes, bytes]] = []
for header_name, value in (
(cls._X_CONTENT_TYPE_OPTIONS, x_content_type_options),
(cls._X_FRAME_OPTIONS, x_frame_options),
(cls._REFERRER_POLICY, referrer_policy),
(cls._PERMISSIONS_POLICY, permissions_policy),
):
normalized = value.strip()
if not normalized:
continue
configured.append(
(
header_name.lower(),
header_name,
normalized.encode("latin-1"),
)
)
return tuple(configured)
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Append configured security headers unless already present."""
if scope["type"] != "http" or not self._configured_headers:
await self._app(scope, receive, send)
return
async def send_with_security_headers(message: Message) -> None:
if message["type"] == "http.response.start":
# Starlette uses `list[tuple[bytes, bytes]]` for raw headers.
headers: list[tuple[bytes, bytes]] = message.setdefault("headers", [])
existing = {key.lower() for key, _ in headers}
for key_lower, key, value in self._configured_headers:
if key_lower not in existing:
headers.append((key, value))
existing.add(key_lower)
await send(message)
await self._app(scope, receive, send_with_security_headers)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
from fastapi import APIRouter, FastAPI, Request, Response, status
from fastapi import APIRouter, FastAPI, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from fastapi_pagination import add_pagination
@@ -34,6 +34,7 @@ from app.api.users import router as users_router
from app.core.config import settings
from app.core.error_handling import install_error_handling
from app.core.logging import configure_logging, get_logger
from app.core.security_headers import SecurityHeadersMiddleware
from app.db.session import init_db
from app.schemas.health import HealthStatusResponse
@@ -464,20 +465,16 @@ if origins:
else:
logger.info("app.cors.disabled")
app.add_middleware(
SecurityHeadersMiddleware,
x_content_type_options=settings.security_header_x_content_type_options,
x_frame_options=settings.security_header_x_frame_options,
referrer_policy=settings.security_header_referrer_policy,
permissions_policy=settings.security_header_permissions_policy,
)
install_error_handling(app)
@app.middleware("http")
async def security_headers(request: Request, call_next: Any) -> Response:
"""Inject standard security headers into every response."""
response: Response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
return response
@app.get(
"/health",
tags=["health"],