fix(auth): CSRF/鉴权中间件兼容反向代理路径前缀 + 无会话请求豁免

清空浏览器缓存后(尤其 token 换登录)报「CSRF token missing」。根因:
反向代理把请求转发到带前缀的路径(如 /xxx/api/v1/auth/login/token),
FastAPI 用 root_path 剥前缀做路由,但 Starlette 中间件跑在路由之前,
看到的仍是带前缀的 path,导致 CSRF 豁免名单与公开路径白名单(精确/前缀
匹配)全部失配。清缓存前靠 localStorage 残留 token 当 Bearer 绕过 CSRF,
清掉后裸奔才暴露。

- 新增 proxy_path.app_relative_path:匹配前按 root_path / X-Forwarded-Prefix
  剥掉代理前缀(无前缀时 no-op)。
- csrf_middleware:should_check_csrf / is_auth_endpoint 改用应用相对路径;
  补上漏收的 /api/v1/auth/login/token 豁免;新增「无 access_token 会话
  cookie 则跳过 CSRF」兜底(无会话请求不存在可被跨站伪造利用的会话)。
- auth_middleware:公开路径判断改用应用相对路径,避免 /xxx/api/public/*
  分享页被误判 401。
- langgraph_auth:无 access_token cookie 时跳过 CSRF,与网关链路一致。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.8 committed 2026-06-10 15:51:01 +08:00
1 parent 413f92e270
commit 3178b4c45e
4 files changed
+82 -5

No files matched your search

@@ -20,6 +20,7 @@ from app.gateway.auth.disabled_mode import get_assumed_user_for_disabled_auth, i
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
from app.gateway.authz import _ALL_PERMISSIONS, AuthContext
from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token
from app.gateway.proxy_path import app_relative_path
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Paths that never require authentication.
@@ -84,7 +85,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
super().__init__(app)
async def dispatch(self, request: Request, call_next: Callable) -> Response:
if _is_public(request.url.path):
# Match the application-relative path so a reverse-proxy prefix
# (e.g. /xxx/api/public/...) does not defeat the public whitelist.
if _is_public(app_relative_path(request)):
return await call_next(request)
if is_auth_disabled():
@@ -8,6 +8,7 @@ import secrets
from collections.abc import Callable
from app.gateway.auth.disabled_mode import is_auth_disabled
from app.gateway.proxy_path import app_relative_path
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
@@ -37,7 +38,9 @@ def should_check_csrf(request: Request) -> bool:
if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False
path = request.url.path
# 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":
@@ -56,6 +59,10 @@ _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",
@@ -69,8 +76,10 @@ 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 request.url.path.rstrip("/") in _AUTH_EXEMPT_PATHS
return app_relative_path(request).rstrip("/") in _AUTH_EXEMPT_PATHS
def _has_bearer_auth(request: Request) -> bool:
@@ -82,6 +91,22 @@ def _has_bearer_auth(request: Request) -> bool:
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."""
@@ -95,8 +120,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
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.
if should_check_csrf(request) and not _is_auth and not _has_bearer_auth(request):
# 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)
@@ -40,6 +40,13 @@ def _check_csrf(request) -> None:
# CSRF protection is for cookie-auth browser flows.
return
# No cookie-based session → nothing for a cross-site forgery to ride, so
# CSRF is moot. Enforcing it here would reject a freshly-loaded browser
# (just cleared its cookies) before it can authenticate; such a request is
# rejected as unauthenticated (401) below anyway.
if not request.cookies.get("access_token"):
return
cookie_token = request.cookies.get("csrf_token")
header_token = request.headers.get("x-csrf-token")
@@ -0,0 +1,39 @@
"""Resolve the application-relative request path behind a reverse proxy.
A reverse proxy may forward requests under a path prefix, e.g.
``/xxx/api/v1/auth/login/token``. FastAPI strips that prefix for **routing**
when it is told about it via the ASGI ``root_path`` (uvicorn ``--root-path`` /
``--proxy-headers``, or ``X-Forwarded-Prefix`` from the proxy) — which is why
the route still matches and the request reaches a handler.
Starlette middleware, however, runs *before* routing and sees the full,
prefixed ``request.url.path``. Middlewares that match that raw path against
fixed whitelists — the CSRF-exempt auth paths and the AuthMiddleware public
paths — then mis-match, so login and ``/api/public/*`` endpoints get wrongly
rejected (``CSRF token missing`` / ``401``) on a prefixed deployment.
``app_relative_path`` returns the path with the proxy prefix removed so that
whitelist matching is prefix-agnostic. It is a no-op when no prefix is present
(direct deployment), and tolerates the prefix already being stripped from the
path (it only strips when the path actually starts with the prefix).
"""
from fastapi import Request
def app_relative_path(request: Request) -> str:
"""Return ``request.url.path`` with any reverse-proxy prefix removed.
Prefix source order: ASGI ``root_path`` (set by the server when the proxy
forwards it), then the ``X-Forwarded-Prefix`` header (proxies that add a
prefix without informing the ASGI server). Returns ``"/"`` for an empty
result so downstream ``rstrip("/")`` / ``startswith`` checks stay sane.
"""
path = request.url.path or "/"
prefix = (request.scope.get("root_path") or request.headers.get("x-forwarded-prefix") or "").rstrip("/")
if prefix and path.startswith(prefix):
stripped = path[len(prefix):]
if not stripped:
return "/"
return stripped if stripped.startswith("/") else "/" + stripped
return path