kb-demo / backend /app /security_headers.py
RayLi-Git
fix: 檔案移至根目錄 + 套用示範 README
a7cd101
Raw
History Blame Contribute Delete
2.19 kB
"""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
# 因為 HTML 既有大量 inline <style> 與 <script>(視覺圖檔原樣),CSP 不能完全鎖死
# unsafe-inline。改為「正式環境再改 nonce」,目前先設較寬鬆但仍封外部 URL。
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'; "
# §12.19 附件 PDF 預覽用 iframe + blob: URL(D97 inline 預覽)
"frame-src 'self' blob:; "
"object-src 'self' blob:; "
"frame-ancestors 'self'; " # 同源可 frame(PDF preview modal);阻外站
"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")
# SAMEORIGIN:同源可 frame(§12.19 PDF 預覽 modal 用同源 iframe);
# 阻擋外部站點 frame 我們的頁面避免 clickjacking
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)
# Phase5-H §10.9 HSTS:僅在 KBV5_HSTS=1 + HTTPS 連線時輸出,避免 dev http 環境踩雷
if feature_hsts() and request.url.scheme == "https":
resp.headers.setdefault(
"Strict-Transport-Security",
"max-age=31536000; includeSubDomains",
)
# /api/* 一律 no-store
if request.url.path.startswith("/api/"):
resp.headers["Cache-Control"] = "no-store"
return resp