Skip to content

chore(proxy): resolve request route from ASGI scope - #27879

Closed
stuxf wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
stuxf:chore/host-header-route-bypass
Closed

chore(proxy): resolve request route from ASGI scope#27879
stuxf wants to merge 3 commits into
BerriAI:litellm_internal_stagingfrom
stuxf:chore/host-header-route-bypass

Conversation

@stuxf

@stuxf stuxf commented May 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Read the request route from scope[\"path\"] instead of request.url.path. Starlette constructs request.url by interpolating the Host header into a URL string and re-parsing with urlsplit, so a malformed Host (e.g. one containing /? or /#) collapses url.path to \"/\" and corrupts the value the auth gate compares against.
  • Strip scope[\"root_path\"] directly rather than via request.base_url.path (also Host-derived).
  • Apply the same scope-based read at the other auth-time path lookups in the proxy: _experimental/mcp_server/auth/user_api_key_auth_mcp.py (/.well-known/ public-route handling and upstream-OAuth2 delegate lookup), auth/route_checks.py (thread/assistant route classification), vector_store_endpoints/utils.py (vector store ACL endpoint matching), management_endpoints/mcp_management_endpoints.py (PKCE /authorize / /token anonymous-bypass branch).

A fallback to request.url.path triggers only when scope isn't a dict — the case produced by MagicMock(spec=Request) test stubs. Production ASGI scope is always a dict.

@codecov

codecov Bot commented May 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.35294% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/proxy/auth/auth_utils.py 77.77% 2 Missing ⚠️
...y/management_endpoints/mcp_management_endpoints.py 50.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR closes an auth-bypass vulnerability where a malformed Host header (e.g. localhost/?x=1) caused Starlette to collapse request.url.path to "/", which is in LiteLLMRoutes.public_routes, allowing protected routes to be treated as public. The fix redirects all auth-time path reads to scope["path"] — the authoritative ASGI value FastAPI itself uses for routing — via a centralised get_request_route helper.

  • get_request_route in auth_utils.py now reads scope[\"path\"] and strips scope[\"root_path\"] directly; a request.url.path fallback is retained only for non-dict scopes (unit-test MagicMock stubs) with a hard \"/\" safety return.
  • user_api_key_auth_websocket propagates path, raw_path, root_path, and query_string from the WebSocket scope into the synthetic HTTP request, so WebSocket connections are no longer incorrectly classified as the public \"/\" route.
  • Six call sites across MCP auth, route checks, vector-store ACL matching, and MCP management endpoints are updated to call get_request_route instead of request.url.path, and a targeted regression test suite is added.

Confidence Score: 5/5

The change is safe to merge — it removes a concrete auth-bypass vector without altering any public API or breaking any existing call contracts.

All modified call sites consistently delegate to the centralised helper; the WebSocket scope propagation is correct; the test coverage directly exercises the malicious Host header variants. No logic regressions were identified in the auth paths.

No files require special attention; the two minor nits are in the test file and the fallback branch of the helper, neither of which affects production behaviour.

Important Files Changed

Filename Overview
litellm/proxy/auth/auth_utils.py Core of the fix: get_request_route now reads scope["path"] (the authoritative ASGI value) instead of request.url.path (Host-header-influenced). Logic is correct; the exception fallback loses its prior debug log.
litellm/proxy/auth/user_api_key_auth.py WebSocket synthetic HTTP request now carries path, raw_path, root_path, and query_string from the WebSocket scope so get_request_route sees the real route instead of falling back to "/". Change is correct and well-documented.
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Replaces all three direct request.url.path reads with get_request_route(request) for the /.well-known/ bypass and the OAuth2 delegate/upstream path lookups. Straightforward and correct.
litellm/proxy/auth/route_checks.py One-line change: request.url.path replaced by get_request_route(request) in the thread/assistant route classifier. Safe.
litellm/proxy/vector_store_endpoints/utils.py Four request.url.path reads in the read/write ACL matchers replaced with a single get_request_route call per function. Correct; avoids redundant calls.
litellm/proxy/management_endpoints/mcp_management_endpoints.py PKCE /authorize/token anonymous-bypass branch now uses get_request_route. The removed or "" guard is safely covered by get_request_route always returning at least "/". Correct.
tests/test_litellm/proxy/auth/test_request_route_resolution.py New regression test suite covering Host-header bypass variants, root_path stripping, and the WebSocket scope propagation. Uses deprecated asyncio.get_event_loop() in the WebSocket test which can cause warnings or failures on Python 3.10+.

Reviews (2): Last reviewed commit: "fix(proxy): carry path / root_path throu..." | Re-trigger Greptile

Comment thread tests/test_litellm/proxy/auth/test_request_route_resolution.py Outdated
@stuxf
stuxf force-pushed the chore/host-header-route-bypass branch from 4d396ef to 25cc0b0 Compare May 13, 2026 23:30
``get_request_route`` previously read ``request.url.path``, which
Starlette builds by interpolating the ``Host`` header into a URL
string and re-parsing with ``urlsplit``. A ``Host`` value containing
``/?`` or ``/#`` (e.g. ``localhost/?x=1``) collapses ``url.path`` to
``"/"`` — the real path falls into the query/fragment. ``"/"`` is in
``LiteLLMRoutes.public_routes``, so route-based auth gates would treat
protected routes as public; FastAPI's router uses ``scope["path"]``
for dispatch, so the protected handler still executes.

Read ``scope["path"]`` directly (the authoritative ASGI path that
FastAPI uses for routing) and strip ``scope["root_path"]`` rather
than relying on ``request.base_url.path`` (also Host-derived).

The other auth-time path reads in the proxy use the same helper:
- ``_experimental/mcp_server/auth/user_api_key_auth_mcp.py`` —
  ``/.well-known/`` public-route bypass and upstream-OAuth2 delegate
  lookup
- ``auth/route_checks.py`` — ``thread`` / ``assistant`` route
  classification
- ``vector_store_endpoints/utils.py`` — vector store ACL endpoint
  matching
- ``management_endpoints/mcp_management_endpoints.py`` — PKCE
  ``/authorize`` / ``/token`` anonymous-bypass branch

A fallback to ``request.url.path`` triggers only when ``scope`` is not
a dict — the case produced by ``MagicMock(spec=Request)`` test stubs.
Production ASGI scope is always a dict, so the secure read above is
the only path that runs in real requests.

Regression test covers the four malformed-Host shapes plus three
additional variants (``user@``, ``[::1]``, backslash). The end-to-end
case dispatches through the real FastAPI app via ``TestClient`` and
asserts protected admin routes still return ``401``.
@stuxf
stuxf force-pushed the chore/host-header-route-bypass branch from 25cc0b0 to 3ab068b Compare May 13, 2026 23:30
@stuxf stuxf changed the title fix(proxy): close Host-header auth bypass in get_request_route chore(proxy): resolve request route from ASGI scope May 13, 2026
stuxf added 2 commits May 13, 2026 23:49
``os.environ.setdefault`` only writes when the key is absent, but if it
was absent the value persists for the worker lifetime — a later test
asserting ``LITELLM_MASTER_KEY`` is unset would see a stale value.
Snapshot and restore the affected keys in a ``finally`` block.
…c request

``user_api_key_auth_websocket`` previously built the synthetic HTTP
``Request`` from only the WebSocket scope headers. After the helper
switched to reading ``scope["path"]`` rather than ``request.url.path``,
the route resolved to an empty string and the auth-side classification
treated the connection as the public ``/`` route.

Copy ``path``, ``raw_path``, ``root_path``, and ``query_string`` from
the WebSocket scope so the synthetic request carries the same routing
fields a real HTTP request would. Regression test asserts the
downstream auth helper sees the actual route.
@stuxf

stuxf commented May 14, 2026

Copy link
Copy Markdown
Collaborator Author

@greptileai please re-review — the os.environ.setdefault leak was addressed in 952c080 (env snapshot + restore in fixture finalizer), and the websocket synthetic-request regression was closed in 4084911.

@oss-pr-review-agent-shin

Copy link
Copy Markdown
Contributor

🤖 litellm-agent: This PR is currently BLOCKED from merge.

Score: 3/5

Why blocked:

  • 1 PR-related CI failure (Size gate: tests (+180) exceed code (+54) by more than 3× — over-specified or feature too thin. Add the oversized-ok label if intentional.) (pr_related_failures, -2 pts)

Details: Score docked for: 1 PR-related CI failure (Size gate: tests (+180) exceed code (+54) by more than 3× — over-specified or feature too thin. Add the oversized-ok label if intentional.).

Fix the issues above and push an update — the bot will re-review automatically.

Note: This bot is still in beta and might not always work as expected. Please share any feedback via Slack.

@stuxf

stuxf commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #28547, which landed the call-site sweep through get_request_route(). Closing.

@stuxf stuxf closed this May 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant