| """HTTP 安全 headers + 簡易 CSP(PRD §10.9)。""" |
| from __future__ import annotations |
|
|
| from starlette.middleware.base import BaseHTTPMiddleware |
| from starlette.responses import Response |
|
|
| from app.feature_flags import feature_hsts |
|
|
| |
| |
| DEFAULT_CSP = ( |
| "default-src 'self'; " |
| "script-src 'self' 'unsafe-inline'; " |
| "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " |
| "font-src 'self' https://fonts.gstatic.com data:; " |
| "img-src 'self' data: blob:; " |
| "connect-src 'self'; " |
| |
| "frame-src 'self' blob:; " |
| "object-src 'self' blob:; " |
| "frame-ancestors 'self'; " |
| "base-uri 'self'; " |
| "form-action 'self'" |
| ) |
|
|
|
|
| class SecurityHeadersMiddleware(BaseHTTPMiddleware): |
| async def dispatch(self, request, call_next) -> Response: |
| resp: Response = await call_next(request) |
| resp.headers.setdefault("X-Content-Type-Options", "nosniff") |
| |
| |
| resp.headers.setdefault("X-Frame-Options", "SAMEORIGIN") |
| resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") |
| resp.headers.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()") |
| resp.headers.setdefault("Content-Security-Policy", DEFAULT_CSP) |
| |
| if feature_hsts() and request.url.scheme == "https": |
| resp.headers.setdefault( |
| "Strict-Transport-Security", |
| "max-age=31536000; includeSubDomains", |
| ) |
| |
| if request.url.path.startswith("/api/"): |
| resp.headers["Cache-Control"] = "no-store" |
| return resp |
|
|