"""Security headers, CSP and the JSON request log.""" from __future__ import annotations import logging import time from starlette.datastructures import MutableHeaders from starlette.types import ASGIApp, Message, Receive, Scope, Send CSP = "; ".join( [ "default-src 'self'", "script-src 'self'", "style-src 'self' 'unsafe-inline'", # React inline styles; tighten in phase 10 "img-src 'self' data: blob:", "font-src 'self'", "connect-src 'self'", "worker-src 'self' blob:", # pdf.js "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'", ] ) HEADERS: dict[str, str] = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", "Referrer-Policy": "no-referrer", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Cross-Origin-Opener-Policy": "same-origin", "Content-Security-Policy": CSP, } access_log = logging.getLogger("packetstation.access") class SecurityHeadersMiddleware: def __init__(self, app: ASGIApp): self.app = app async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return is_api = scope.get("path", "").startswith("/api") async def wrapped(message: Message) -> None: if message["type"] == "http.response.start": headers = MutableHeaders(scope=message) for k, v in HEADERS.items(): headers[k] = v if is_api: headers["Cache-Control"] = "no-store" await send(message) await self.app(scope, receive, wrapped) class RequestLogMiddleware: """One JSON line per request: method, path, status, ms, ip, build. Never bodies or query strings.""" def __init__(self, app: ASGIApp, build: str): self.app = app self.build = build async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return start = time.perf_counter() status = 500 async def wrapped(message: Message) -> None: nonlocal status if message["type"] == "http.response.start": status = message["status"] await send(message) try: await self.app(scope, receive, wrapped) finally: client = scope.get("client") access_log.info( "request", extra={ "ps": { "method": scope.get("method"), "path": scope.get("path"), "status": status, "ms": round((time.perf_counter() - start) * 1000, 1), "ip": client[0] if client else None, "build": self.build, } }, )