From 1f0ea05fb5afa9bb3bba51ff19977651331eb62a Mon Sep 17 00:00:00 2001 From: JackDanger Date: Sat, 17 Oct 2026 23:44:21 +0000 Subject: [PATCH] fix(proxy): run the openai passthrough gate on the canonical forward path Forwarding adds the implicit /v1 for OpenAI when the caller omitted it (_join_url_paths), so /openai_passthrough/assistants reached the same upstream management routes as /v1/assistants while dodging the prefix list, as reported in review. The gate now strips the version prefix before matching management suffixes; both spellings are one decision. Regression tests cover list/delete of the shared assistants plus both inference spellings staying open. Generated-by: termag --- .../llm_passthrough_endpoints.py | 30 ++++++++---- .../test_llm_passthrough_endpoints.py | 47 +++++++++++++++++++ 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 2ee0636a026..9f6d229c678 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -3500,16 +3500,25 @@ async def deepgram_listen_websocket_route( _OPENAI_PASSTHROUGH_MANAGEMENT_PREFIXES: Final[tuple[str, ...]] = ( - "/v1/files", - "/v1/batches", - "/v1/vector_stores", - "/v1/fine_tuning", - "/v1/threads", - "/v1/assistants", - "/v1/models", + "/files", + "/batches", + "/vector_stores", + "/fine_tuning", + "/threads", + "/assistants", + "/models", ) +def _strip_openai_version_prefix(normalized_path: str) -> str: + """Forwarding adds the implicit /v1 for OpenAI only when the caller omitted + it (see _join_url_paths), so both spellings of the same request must hit + the gate: `/v1/assistants` and `/assistants` are the same upstream call.""" + if normalized_path == "/v1" or normalized_path.startswith("/v1/"): + return normalized_path[len("/v1"):] + return normalized_path + + def openai_proxy_gate( *, user_api_key_dict: UserAPIKeyAuth, @@ -3518,11 +3527,14 @@ def openai_proxy_gate( """The /openai passthrough speaks to the shared OpenAI account with the proxy's credentials. Inference paths stay available to granted keys; object-management paths address the shared account with no tenant records, - so they are proxy-admin-only.""" + so they are proxy-admin-only. The check runs on the canonical forward path: + a caller that omits the /v1 segment reaches the same management routes once + the forwarder injects the version prefix.""" if is_proxy_admin(user_api_key_dict): return normalized: Final = posixpath.normpath(f"/{endpoint_path.strip('/')}") - if normalized.startswith(_OPENAI_PASSTHROUGH_MANAGEMENT_PREFIXES): + canonical: Final = _strip_openai_version_prefix(normalized) + if canonical.startswith(_OPENAI_PASSTHROUGH_MANAGEMENT_PREFIXES): raise HTTPException( status_code=403, detail={ diff --git a/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py b/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py index 880fae90202..46165706809 100644 --- a/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py +++ b/tests/unit/proxy/pass_through_endpoints/test_llm_passthrough_endpoints.py @@ -568,3 +568,50 @@ def test_openai_proxy_gate_allows_inference_paths_for_any_key(): user_api_key_dict=UserAPIKeyAuth(api_key="sk-team", user_role=None, user_id="u1"), endpoint_path="/v1/responses", ) + + +def test_openai_proxy_gate_blocks_prefixless_versionless_management_paths(): + """A caller may omit the implicit /v1 segment that forwarding later adds + (_join_url_paths), so the gate must canonicalize before matching — this is + the bypass the BerriAI PR reviewer caught on the original version.""" + from fastapi import HTTPException + + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + openai_proxy_gate, + ) + + for path in ("/assistants", "/assistants/asst_123", "/files/file-1", "/threads", "/models/gpt-5.1"): + with pytest.raises(HTTPException) as exc: + openai_proxy_gate( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-team", user_role=None, user_id="u1"), + endpoint_path=path, + ) + assert int(getattr(exc.value, "status_code", 0)) == 403 + + +def test_openai_proxy_gate_canonicalizes_the_v1_spelling_identically(): + """The /v1-prefixed and prefixless shapes are the same decision.""" + from litellm.proxy._types import UserAPIKeyAuth + + openai_proxy_gate = _openai_proxy_gate_module() + + # both spellings of the inference surface stay open + openai_proxy_gate( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-team", user_role=None, user_id="u1"), + endpoint_path="/chat/completions", + ) + openai_proxy_gate( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-team", user_role=None, user_id="u1"), + endpoint_path="/v1/chat/completions", + ) + # both spellings of the management surface are gated + for path in ("/assistants", "/v1/assistants", "/files", "/v1/files"): + try: + openai_proxy_gate( + user_api_key_dict=UserAPIKeyAuth(api_key="sk-team", user_role=None, user_id="u1"), + endpoint_path=path, + ) + except Exception: + continue + raise AssertionError(f"expected the gate to fire for {path!r}")