"""CSRF 防护中间件 — Double-Submit Cookie 模式""" import secrets from fastapi.responses import JSONResponse from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from app.config import settings SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} CSRF_EXEMPT_PREFIXES = ( "/api/v1/auth/", "/api/v1/webhooks/", "/api/v1/ws/", "/api/v1/captcha/", "/api/v1/public/", "/api/v1/features/", "/api/v1/domains/", "/api/v1/approvals/", "/api/v1/subscriptions/plans", "/docs", "/openapi.json", "/redoc", "/health", ) class CSRFProtectionMiddleware(BaseHTTPMiddleware): """双提交 Cookie CSRF 防护。 - 安全方法(GET/HEAD/OPTIONS)+ 豁免前缀:不验证,但自动注入 csrf_token cookie - Bearer Token 请求:自动豁免(SPA token-based auth 天然防 CSRF) - 其他 state-changing 请求:验证 Cookie 中的 csrf_token 与 X-CSRF-Token 请求头一致 """ async def dispatch(self, request: Request, call_next): path = request.url.path.rstrip('/') or '/' is_safe = request.method in SAFE_METHODS is_exempt = any(path.startswith(p.rstrip('/')) for p in CSRF_EXEMPT_PREFIXES) # 安全方法 + 豁免路径:确保 csrf_token cookie 已设置 if is_safe or is_exempt: response = await call_next(request) if not request.cookies.get("csrf_token"): cookie_secure = request.headers.get("x-forwarded-proto", request.url.scheme) == "https" response.set_cookie( key="csrf_token", value=secrets.token_hex(32), # NOT HttpOnly — JS 需要读取并设置 X-CSRF-Token 请求头 httponly=False, samesite="lax", secure=cookie_secure, path="/", max_age=86400 * 7, # 7 天 ) return response # Bearer Token 天然防 CSRF(浏览器不会自动附加) if request.headers.get("Authorization", "").startswith("Bearer "): return await call_next(request) # State-changing 请求:验证双提交 Token cookie_token = request.cookies.get("csrf_token") header_token = request.headers.get("X-CSRF-Token") if not cookie_token or not header_token or not _constant_time_compare(cookie_token, header_token): return JSONResponse( status_code=403, content={"success": False, "error": {"code": "CSRF_FAILED", "message": "CSRF token missing or invalid"}}, ) return await call_next(request) def _constant_time_compare(a: str, b: str) -> bool: """防止时序攻击的字符串比较""" if len(a) != len(b): return False result = 0 for x, y in zip(a, b, strict=True): result |= ord(x) ^ ord(y) return result == 0