210 lines
7.8 KiB
Python
210 lines
7.8 KiB
Python
"""CSRF protection middleware for FastAPI.
|
||
|
||
Per RFC-001:
|
||
State-changing operations require CSRF protection.
|
||
"""
|
||
|
||
import secrets
|
||
from collections.abc import Callable
|
||
|
||
from fastapi import Request, Response
|
||
from starlette.middleware.base import BaseHTTPMiddleware
|
||
from starlette.responses import JSONResponse
|
||
from starlette.types import ASGIApp
|
||
|
||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||
from app.gateway.proxy_path import app_relative_path
|
||
from app.gateway.weknora_embed import (
|
||
WEKNORA_EMBED_COOKIE,
|
||
has_valid_weknora_embed_cookie,
|
||
is_weknora_embed_proxy_path,
|
||
)
|
||
|
||
CSRF_COOKIE_NAME = "csrf_token"
|
||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||
CSRF_TOKEN_LENGTH = 64 # bytes
|
||
|
||
|
||
def is_secure_request(request: Request) -> bool:
|
||
"""Detect whether the original client request was made over HTTPS."""
|
||
return request.headers.get("x-forwarded-proto", request.url.scheme) == "https"
|
||
|
||
|
||
def generate_csrf_token() -> str:
|
||
"""Generate a secure random CSRF token."""
|
||
return secrets.token_urlsafe(CSRF_TOKEN_LENGTH)
|
||
|
||
|
||
def should_check_csrf(request: Request) -> bool:
|
||
"""Determine if a request needs CSRF validation.
|
||
|
||
CSRF is checked for state-changing methods (POST, PUT, DELETE, PATCH).
|
||
GET, HEAD, OPTIONS, and TRACE are exempt per RFC 7231.
|
||
"""
|
||
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
|
||
return False
|
||
|
||
# Match the application-relative path so a reverse-proxy prefix
|
||
# (e.g. /xxx/api/v1/auth/me) does not defeat these whitelists.
|
||
path = app_relative_path(request)
|
||
stripped = path.rstrip("/")
|
||
# Exempt /api/v1/auth/me endpoint
|
||
if stripped == "/api/v1/auth/me":
|
||
return False
|
||
if stripped == "/api/knowledge/vector-search":
|
||
return False
|
||
if stripped == "/api/writing/export/docx":
|
||
return False
|
||
# Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES).
|
||
# These intentionally have no cookie session, so the Double-Submit Cookie
|
||
# pattern is both impossible (no token to compare) and unnecessary (no
|
||
# session for an attacker to ride). Without this exemption every public
|
||
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
|
||
if path.startswith("/api/public/"):
|
||
return False
|
||
# 开放问答接口同样是匿名、无会话的(见 auth_middleware._PUBLIC_PATH_PREFIXES),
|
||
# 双提交 Cookie 既无 token 可比、也无会话可被伪造,豁免之。
|
||
if path.startswith("/api/open/"):
|
||
return False
|
||
if is_coze_studio_compat_path(path):
|
||
return False
|
||
if is_weknora_embed_proxy_request(request):
|
||
return False
|
||
return True
|
||
|
||
|
||
# Coze playground 自己的 axios 不会带 X-CSRF-Token;独立编排页经
|
||
# localhost:8080 同源代理访问这些接口。Cookie 会话仍由 AuthMiddleware 校验。
|
||
_COZE_STUDIO_PREFIXES: tuple[str, ...] = (
|
||
"/api/passport/",
|
||
"/api/playground_api/",
|
||
"/api/workflow_api/",
|
||
"/api/space/",
|
||
)
|
||
|
||
|
||
def is_coze_studio_compat_path(path: str) -> bool:
|
||
stripped = path.rstrip("/")
|
||
if stripped == "/api/bot/get_type_list":
|
||
return True
|
||
return any(path.startswith(prefix) for prefix in _COZE_STUDIO_PREFIXES)
|
||
|
||
|
||
def is_weknora_embed_proxy_request(request: Request) -> bool:
|
||
path = app_relative_path(request)
|
||
if not is_weknora_embed_proxy_path(path):
|
||
return False
|
||
return has_valid_weknora_embed_cookie(request.cookies.get(WEKNORA_EMBED_COOKIE))
|
||
|
||
|
||
_AUTH_EXEMPT_PATHS: frozenset[str] = frozenset(
|
||
{
|
||
"/api/v1/auth/login/local",
|
||
"/api/v1/auth/login/username",
|
||
# Token-exchange login is sessionless on first call (no CSRF cookie yet),
|
||
# exactly like the other login endpoints — mirror auth_middleware's
|
||
# _PUBLIC_EXACT_PATHS, which already whitelists it.
|
||
"/api/v1/auth/login/token",
|
||
"/api/v1/auth/logout",
|
||
"/api/v1/auth/register",
|
||
"/api/v1/auth/initialize",
|
||
# 并行多智能体面板专用取 token 端点(公共 + CSRF 豁免)。
|
||
"/api/parallel-agents/auth/token",
|
||
}
|
||
)
|
||
|
||
|
||
def is_auth_endpoint(request: Request) -> bool:
|
||
"""Check if the request is to an auth endpoint.
|
||
|
||
Auth endpoints don't need CSRF validation on first call (no token).
|
||
Matches the application-relative path so a reverse-proxy prefix does not
|
||
defeat the whitelist.
|
||
"""
|
||
return app_relative_path(request).rstrip("/") in _AUTH_EXEMPT_PATHS
|
||
|
||
|
||
def _has_bearer_auth(request: Request) -> bool:
|
||
"""Return True when Authorization header carries a Bearer token."""
|
||
authorization = request.headers.get("authorization", "").strip()
|
||
if not authorization:
|
||
return False
|
||
scheme, _, token = authorization.partition(" ")
|
||
return scheme.lower() == "bearer" and bool(token.strip())
|
||
|
||
|
||
def _has_cookie_session(request: Request) -> bool:
|
||
"""Return True when the request carries a cookie-based session.
|
||
|
||
Double-Submit-Cookie CSRF only protects requests the browser authenticates
|
||
**automatically via cookies** — that is the exact surface a cross-site
|
||
forgery can ride. A request with no ``access_token`` session cookie has no
|
||
session for an attacker to exploit, so enforcing CSRF on it is both
|
||
pointless and harmful: a freshly-loaded browser (e.g. one that just cleared
|
||
its cache/cookies) hitting the login endpoint would otherwise be rejected
|
||
with "CSRF token missing" before it can even sign in. Such an
|
||
unauthenticated request, if forged, is rejected by the auth layer (401)
|
||
anyway.
|
||
"""
|
||
return bool(request.cookies.get("access_token"))
|
||
|
||
|
||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||
"""Middleware that implements CSRF protection using Double Submit Cookie pattern."""
|
||
|
||
def __init__(self, app: ASGIApp) -> None:
|
||
super().__init__(app)
|
||
|
||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||
_is_auth = is_auth_endpoint(request)
|
||
|
||
if is_auth_disabled():
|
||
return await call_next(request)
|
||
|
||
# Double-submit-cookie CSRF applies to cookie-based browser sessions.
|
||
# API clients using explicit Bearer tokens are not CSRF-prone, and a
|
||
# request with no session cookie at all (e.g. a fresh browser signing
|
||
# in) has no session to forge — only enforce when an ``access_token``
|
||
# cookie is actually present.
|
||
if should_check_csrf(request) and not _is_auth and not _has_bearer_auth(request) and _has_cookie_session(request):
|
||
cookie_token = request.cookies.get(CSRF_COOKIE_NAME)
|
||
header_token = request.headers.get(CSRF_HEADER_NAME)
|
||
|
||
if not cookie_token or not header_token:
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "CSRF token missing. Include X-CSRF-Token header."},
|
||
)
|
||
|
||
if not secrets.compare_digest(cookie_token, header_token):
|
||
return JSONResponse(
|
||
status_code=403,
|
||
content={"detail": "CSRF token mismatch."},
|
||
)
|
||
|
||
response = await call_next(request)
|
||
|
||
# For auth endpoints that set up session, also set CSRF cookie
|
||
if _is_auth and request.method == "POST":
|
||
# Generate a new CSRF token for the session
|
||
csrf_token = generate_csrf_token()
|
||
is_https = is_secure_request(request)
|
||
response.set_cookie(
|
||
key=CSRF_COOKIE_NAME,
|
||
value=csrf_token,
|
||
httponly=False, # Must be JS-readable for Double Submit Cookie pattern
|
||
secure=is_https,
|
||
samesite="strict",
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
def get_csrf_token(request: Request) -> str | None:
|
||
"""Get the CSRF token from the current request's cookies.
|
||
|
||
This is useful for server-side rendering where you need to embed
|
||
token in forms or headers.
|
||
"""
|
||
return request.cookies.get(CSRF_COOKIE_NAME)
|