Skip to content

chore(proxy): carry ASGI path into WebSocket auth synthetic Request - #27940

Merged
yuneng-berri merged 1 commit into
BerriAI:litellm_yj_may18from
stuxf:chore/websocket-auth-scope-path
May 18, 2026
Merged

chore(proxy): carry ASGI path into WebSocket auth synthetic Request#27940
yuneng-berri merged 1 commit into
BerriAI:litellm_yj_may18from
stuxf:chore/websocket-auth-scope-path

Conversation

@stuxf

@stuxf stuxf commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

`user_api_key_auth_websocket` built a synthetic `Request` with a two-key
scope (`type` + `headers`) and set `request._url = websocket.url`.
`get_request_route` reads `scope.get("path", ...)` and falls back to
`request.url.path` only when `path` is absent. For the WebSocket flow
that fallback fires and resolves to the Host-header-derived value
(Starlette reconstructs `websocket.url` from the Host header), so a
malformed Host collapses the resolved route and lets the auth gate
compare against the wrong value.

Carry the ASGI scope's `path`, `root_path`, and `app_root_path` into
the synthetic scope so the lookup never reaches the fallback on the
legitimate path.

Test plan

  • `uv run pytest tests/proxy_unit_tests/test_user_api_key_auth.py::test_user_api_key_auth_websocket tests/proxy_unit_tests/test_user_api_key_auth.py::test_user_api_key_auth_websocket_carries_asgi_path -v` — 2 pass
  • `uv run black .`

Type

🐛 Bug Fix

@codecov

codecov Bot commented May 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a Host-header-poisoning vector in the WebSocket auth path by ensuring the ASGI scope's path (and optionally root_path/app_root_path) is copied into the synthetic HTTP Request built inside user_api_key_auth_websocket. Previously the synthetic scope lacked path, causing get_request_route to fall back to request.url.path, which Starlette reconstructs from the Host header and could be spoofed.

  • Core fix: synthetic_scope now always includes scope[\"path\"] (defaulting to \"\" when absent), so get_request_route's scope.get(\"path\", request.url.path) never reaches the Host-derived fallback.
  • root_path / app_root_path: conditionally forwarded to preserve mount-prefix stripping that get_request_route relies on.
  • New test (test_user_api_key_auth_websocket_carries_asgi_path): verifies both path and root_path are present in the synthetic scope; existing test is unchanged.

Confidence Score: 5/5

The change is narrow and surgical — two files, one targeted fix in the auth layer — with no modifications to existing test assertions and a new test that directly validates the corrected behavior.

The synthetic-scope construction is straightforward: it adds three well-understood ASGI keys that get_request_route already knows how to consume. The default of "" for a missing path is safe (an empty path matches no route patterns, so it degrades gracefully rather than silently permitting access). The request._url = websocket.url assignment is retained for fields other than path (scheme, host) and no longer influences route resolution. The new test is additive and mocks are unchanged from the original test.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/auth/user_api_key_auth.py Carries ASGI scope's path, root_path, and app_root_path into synthetic HTTP Request so get_request_route uses the real path rather than the Host-header-derived fallback.
tests/proxy_unit_tests/test_user_api_key_auth.py Adds test_user_api_key_auth_websocket_carries_asgi_path to assert the synthetic scope propagates path and root_path; existing test remains intact and unweakened.

Reviews (1): Last reviewed commit: "chore(proxy): carry ASGI path into WebSo..." | Re-trigger Greptile

@oss-pr-review-agent-shin

Copy link
Copy Markdown
Contributor

🤖 litellm-agent: Auto-merge skipped — the staging branch shin_agent_oss_staging_05_14_2026 has 3 commit(s) not in your branch. Merging as-is would produce a confusing diff on the staging PR.

Please rebase your branch onto shin_agent_oss_staging_05_14_2026 and push; the agent will re-review automatically.

@stuxf
stuxf force-pushed the chore/websocket-auth-scope-path branch from 6f04860 to 9ffe05c Compare May 14, 2026 19:52
@stuxf
stuxf requested a review from a team May 14, 2026 19:52
@CLAassistant

CLAassistant commented May 14, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@stuxf
stuxf changed the base branch from litellm_internal_staging to shin_agent_oss_staging_05_14_2026 May 15, 2026 02:25
@stuxf
stuxf changed the base branch from shin_agent_oss_staging_05_14_2026 to litellm_internal_staging May 15, 2026 02:25
``user_api_key_auth_websocket`` built a synthetic ``Request`` with a
two-key scope (``type`` + ``headers``) and set ``request._url =
websocket.url``. ``get_request_route`` reads ``scope.get("path", ...)``
and falls back to ``request.url.path`` only when ``path`` is absent.
For the WebSocket flow that fallback fires and resolves to the
Host-header-derived value (Starlette reconstructs ``websocket.url``
from the Host header), so a malformed Host collapses the resolved
route and lets the auth gate compare against the wrong value.

Carry the ASGI scope's ``path``, ``root_path``, and ``app_root_path``
into the synthetic scope so the lookup never reaches the fallback on
the legitimate path.

Regression test pins that the request handed to ``user_api_key_auth``
has ``scope["path"]`` equal to the ASGI scope's path.
@stuxf
stuxf force-pushed the chore/websocket-auth-scope-path branch from 9ffe05c to 5ee2a5b Compare May 15, 2026 02:26
@yuneng-berri
yuneng-berri changed the base branch from litellm_internal_staging to litellm_yj_may18 May 18, 2026 20:43
@yuneng-berri
yuneng-berri merged commit 57439e1 into BerriAI:litellm_yj_may18 May 18, 2026
42 checks passed
yuneng-berri added a commit that referenced this pull request May 21, 2026
* chore(proxy): strict media-type match for form bodies (#27939)

* chore(proxy): strict media-type match for form bodies

``_read_request_body`` and ``get_request_body`` routed on
``"form" in content_type`` / ``"multipart/form-data" in content_type``,
which match any header containing the literal — ``application/form-json``,
``multiform/anything``, ``application/json; xform=1``. Starlette's
``request.form()`` returns an empty ``FormData`` for any non-canonical
type without consuming the body, so the auth-time pre-read saw ``{}``
and skipped the banned-param check while the handler's later
``request.body()`` saw the original JSON payload.

Parse the media type per RFC 7231 (substring before ``;``, trimmed,
lowercased) and accept only ``application/x-www-form-urlencoded`` and
``multipart/form-data``. Replace both substring sites with the shared
``_is_form_content_type`` helper.

Tests pin: case/whitespace/charset variants of the two real types
match; ``application/form-json`` and similar substring-match traps
fall through to the JSON parse path; real form POSTs continue to
route through ``request.form()``.

* chore(proxy): extract _is_json_content_type symmetric helper

Mirror ``_is_form_content_type`` for the JSON branch of
``get_request_body`` so both classifications share the same media-type
normalisation (strip params, trim, lowercase) and any future change
to the parsing rules has one place to update.

Adds tests for ``_is_json_content_type`` and for ``get_request_body``
covering the canonical JSON / form / unsupported / non-POST paths.

* chore(proxy): surface form-parse failures instead of caching empty body

Starlette's ``request.form()`` raises ``MultiPartException`` /
``ValueError`` / ``AssertionError`` on malformed multipart input
(missing boundary, malformed chunk encoding, etc.). The outer
``except Exception: return {}`` swallowed every form-parse failure
and cached an empty parsed body — auth-time pre-reads saw ``{}`` and
skipped every banned-param check while a later raw-body re-read in
the handler still saw the original payload. Same TOCTOU shape as the
substring-match bypass: the auth gate and the handler don't agree on
what the body is.

Wrap ``request.form()`` in a narrow ``try`` that converts any parse
failure to a 400 ``ProxyException``. The outer broad ``except`` is
retained for unrelated unexpected errors but no longer covers
form-parse-side bypass shapes.

Adds a regression test parametrised over the exception classes
Starlette can raise from ``request.form()``.

* chore(proxy): drop redundant _is_json_content_type test class

``_is_json_content_type`` is a 3-line wrapper around the shared
``_normalize_media_type`` helper. Positive coverage lives in
``TestGetRequestBody.test_json_with_charset_param_parses_as_json``;
negative coverage is covered transitively by
``TestIsFormContentType``'s non-form parametrize matrix (anything that
isn't a form type falls through to the JSON branch).

* chore(proxy): carry ASGI path into WebSocket auth synthetic Request (#27940)

``user_api_key_auth_websocket`` built a synthetic ``Request`` with a
two-key scope (``type`` + ``headers``) and set ``request._url =
websocket.url``. ``get_request_route`` reads ``scope.get("path", ...)``
and falls back to ``request.url.path`` only when ``path`` is absent.
For the WebSocket flow that fallback fires and resolves to the
Host-header-derived value (Starlette reconstructs ``websocket.url``
from the Host header), so a malformed Host collapses the resolved
route and lets the auth gate compare against the wrong value.

Carry the ASGI scope's ``path``, ``root_path``, and ``app_root_path``
into the synthetic scope so the lookup never reaches the fallback on
the legitimate path.

Regression test pins that the request handed to ``user_api_key_auth``
has ``scope["path"]`` equal to the ASGI scope's path.

---------

Co-authored-by: stuxf <70670632+stuxf@users.noreply.github.com>
lorenzbaraldi pushed a commit to lorenzbaraldi/litellm that referenced this pull request May 21, 2026
* chore(proxy): strict media-type match for form bodies (BerriAI#27939)

* chore(proxy): strict media-type match for form bodies

``_read_request_body`` and ``get_request_body`` routed on
``"form" in content_type`` / ``"multipart/form-data" in content_type``,
which match any header containing the literal — ``application/form-json``,
``multiform/anything``, ``application/json; xform=1``. Starlette's
``request.form()`` returns an empty ``FormData`` for any non-canonical
type without consuming the body, so the auth-time pre-read saw ``{}``
and skipped the banned-param check while the handler's later
``request.body()`` saw the original JSON payload.

Parse the media type per RFC 7231 (substring before ``;``, trimmed,
lowercased) and accept only ``application/x-www-form-urlencoded`` and
``multipart/form-data``. Replace both substring sites with the shared
``_is_form_content_type`` helper.

Tests pin: case/whitespace/charset variants of the two real types
match; ``application/form-json`` and similar substring-match traps
fall through to the JSON parse path; real form POSTs continue to
route through ``request.form()``.

* chore(proxy): extract _is_json_content_type symmetric helper

Mirror ``_is_form_content_type`` for the JSON branch of
``get_request_body`` so both classifications share the same media-type
normalisation (strip params, trim, lowercase) and any future change
to the parsing rules has one place to update.

Adds tests for ``_is_json_content_type`` and for ``get_request_body``
covering the canonical JSON / form / unsupported / non-POST paths.

* chore(proxy): surface form-parse failures instead of caching empty body

Starlette's ``request.form()`` raises ``MultiPartException`` /
``ValueError`` / ``AssertionError`` on malformed multipart input
(missing boundary, malformed chunk encoding, etc.). The outer
``except Exception: return {}`` swallowed every form-parse failure
and cached an empty parsed body — auth-time pre-reads saw ``{}`` and
skipped every banned-param check while a later raw-body re-read in
the handler still saw the original payload. Same TOCTOU shape as the
substring-match bypass: the auth gate and the handler don't agree on
what the body is.

Wrap ``request.form()`` in a narrow ``try`` that converts any parse
failure to a 400 ``ProxyException``. The outer broad ``except`` is
retained for unrelated unexpected errors but no longer covers
form-parse-side bypass shapes.

Adds a regression test parametrised over the exception classes
Starlette can raise from ``request.form()``.

* chore(proxy): drop redundant _is_json_content_type test class

``_is_json_content_type`` is a 3-line wrapper around the shared
``_normalize_media_type`` helper. Positive coverage lives in
``TestGetRequestBody.test_json_with_charset_param_parses_as_json``;
negative coverage is covered transitively by
``TestIsFormContentType``'s non-form parametrize matrix (anything that
isn't a form type falls through to the JSON branch).

* chore(proxy): carry ASGI path into WebSocket auth synthetic Request (BerriAI#27940)

``user_api_key_auth_websocket`` built a synthetic ``Request`` with a
two-key scope (``type`` + ``headers``) and set ``request._url =
websocket.url``. ``get_request_route`` reads ``scope.get("path", ...)``
and falls back to ``request.url.path`` only when ``path`` is absent.
For the WebSocket flow that fallback fires and resolves to the
Host-header-derived value (Starlette reconstructs ``websocket.url``
from the Host header), so a malformed Host collapses the resolved
route and lets the auth gate compare against the wrong value.

Carry the ASGI scope's ``path``, ``root_path``, and ``app_root_path``
into the synthetic scope so the lookup never reaches the fallback on
the legitimate path.

Regression test pins that the request handed to ``user_api_key_auth``
has ``scope["path"]`` equal to the ASGI scope's path.

---------

Co-authored-by: stuxf <70670632+stuxf@users.noreply.github.com>
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
* chore(proxy): strict media-type match for form bodies (BerriAI#27939)

* chore(proxy): strict media-type match for form bodies

``_read_request_body`` and ``get_request_body`` routed on
``"form" in content_type`` / ``"multipart/form-data" in content_type``,
which match any header containing the literal — ``application/form-json``,
``multiform/anything``, ``application/json; xform=1``. Starlette's
``request.form()`` returns an empty ``FormData`` for any non-canonical
type without consuming the body, so the auth-time pre-read saw ``{}``
and skipped the banned-param check while the handler's later
``request.body()`` saw the original JSON payload.

Parse the media type per RFC 7231 (substring before ``;``, trimmed,
lowercased) and accept only ``application/x-www-form-urlencoded`` and
``multipart/form-data``. Replace both substring sites with the shared
``_is_form_content_type`` helper.

Tests pin: case/whitespace/charset variants of the two real types
match; ``application/form-json`` and similar substring-match traps
fall through to the JSON parse path; real form POSTs continue to
route through ``request.form()``.

* chore(proxy): extract _is_json_content_type symmetric helper

Mirror ``_is_form_content_type`` for the JSON branch of
``get_request_body`` so both classifications share the same media-type
normalisation (strip params, trim, lowercase) and any future change
to the parsing rules has one place to update.

Adds tests for ``_is_json_content_type`` and for ``get_request_body``
covering the canonical JSON / form / unsupported / non-POST paths.

* chore(proxy): surface form-parse failures instead of caching empty body

Starlette's ``request.form()`` raises ``MultiPartException`` /
``ValueError`` / ``AssertionError`` on malformed multipart input
(missing boundary, malformed chunk encoding, etc.). The outer
``except Exception: return {}`` swallowed every form-parse failure
and cached an empty parsed body — auth-time pre-reads saw ``{}`` and
skipped every banned-param check while a later raw-body re-read in
the handler still saw the original payload. Same TOCTOU shape as the
substring-match bypass: the auth gate and the handler don't agree on
what the body is.

Wrap ``request.form()`` in a narrow ``try`` that converts any parse
failure to a 400 ``ProxyException``. The outer broad ``except`` is
retained for unrelated unexpected errors but no longer covers
form-parse-side bypass shapes.

Adds a regression test parametrised over the exception classes
Starlette can raise from ``request.form()``.

* chore(proxy): drop redundant _is_json_content_type test class

``_is_json_content_type`` is a 3-line wrapper around the shared
``_normalize_media_type`` helper. Positive coverage lives in
``TestGetRequestBody.test_json_with_charset_param_parses_as_json``;
negative coverage is covered transitively by
``TestIsFormContentType``'s non-form parametrize matrix (anything that
isn't a form type falls through to the JSON branch).

* chore(proxy): carry ASGI path into WebSocket auth synthetic Request (BerriAI#27940)

``user_api_key_auth_websocket`` built a synthetic ``Request`` with a
two-key scope (``type`` + ``headers``) and set ``request._url =
websocket.url``. ``get_request_route`` reads ``scope.get("path", ...)``
and falls back to ``request.url.path`` only when ``path`` is absent.
For the WebSocket flow that fallback fires and resolves to the
Host-header-derived value (Starlette reconstructs ``websocket.url``
from the Host header), so a malformed Host collapses the resolved
route and lets the auth gate compare against the wrong value.

Carry the ASGI scope's ``path``, ``root_path``, and ``app_root_path``
into the synthetic scope so the lookup never reaches the fallback on
the legitimate path.

Regression test pins that the request handed to ``user_api_key_auth``
has ``scope["path"]`` equal to the ASGI scope's path.

---------

Co-authored-by: stuxf <70670632+stuxf@users.noreply.github.com>
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.

3 participants