File size: 2,185 Bytes
3b1ab70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
"""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