清空浏览器缓存后(尤其 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>
40 lines
1.9 KiB
Python
40 lines
1.9 KiB
Python
"""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
|