diff --git a/offline-backend-20260512/backend/app/gateway/auth_middleware.py b/offline-backend-20260512/backend/app/gateway/auth_middleware.py index ff3bb86c..ae124dfb 100644 --- a/offline-backend-20260512/backend/app/gateway/auth_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/auth_middleware.py @@ -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(): diff --git a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py index 07b46047..c5494277 100644 --- a/offline-backend-20260512/backend/app/gateway/csrf_middleware.py +++ b/offline-backend-20260512/backend/app/gateway/csrf_middleware.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/langgraph_auth.py b/offline-backend-20260512/backend/app/gateway/langgraph_auth.py index 0929b193..581a8020 100644 --- a/offline-backend-20260512/backend/app/gateway/langgraph_auth.py +++ b/offline-backend-20260512/backend/app/gateway/langgraph_auth.py @@ -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") diff --git a/offline-backend-20260512/backend/app/gateway/proxy_path.py b/offline-backend-20260512/backend/app/gateway/proxy_path.py new file mode 100644 index 00000000..22942978 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/proxy_path.py @@ -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