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:
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
|
||||
Reference in new issue
Block a user