From 0166992f6b15c541f53dfb8d3a7a61d3c7463791 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:09:37 +0000 Subject: [PATCH 01/47] fix(proxy): contain UI_LOGO_PATH and LITELLM_FAVICON_URL to allowed asset roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unauthenticated ``/get_image`` and ``/get_favicon`` endpoints accept the admin-set env vars ``UI_LOGO_PATH`` and ``LITELLM_FAVICON_URL`` and return whatever bytes they resolve to, with a hard-coded ``image/jpeg`` or ``image/x-icon`` content-type. Two attack shapes: * ``UI_LOGO_PATH=/etc/passwd`` (or any other readable file path) — any unauthenticated caller exfiltrates the file via ``GET /get_image``. The previous gate was ``os.path.exists(logo_path)`` which fires on every readable file. Same shape for the favicon endpoint. * ``UI_LOGO_PATH=http://169.254.169.254/iam`` (or any internal HTTP service the admin pointed at) — the proxy fetches it server-side and streams the response body to the unauthenticated caller. No URL validation, no Content-Type validation; ``application/json`` AWS metadata gets tunneled out under the ``image/jpeg`` wrapper. New helper module ``litellm/proxy/common_utils/static_asset_utils.py``: * ``resolve_local_asset_path(candidate, allowed_roots)`` — returns the resolved absolute path only if it lives within one of the allowed asset roots. Uses ``realpath`` so symlinks pointing outside the roots are caught. * ``fetch_validated_image_bytes(url)`` — runs the URL through ``validate_url`` (rejecting private / cloud-metadata / loopback targets) and only returns the response body if the upstream Content-Type is in a small allowlist of image MIME types. Both ``/get_image`` and ``/get_favicon`` are wired through the helpers. The SSRF gate is enforced unconditionally — these endpoints are unauthenticated, so the admin-facing ``litellm.user_url_validation`` toggle does not apply (an admin who opted out of URL validation for LLM provider paths shouldn't also expose ``/get_image`` to SSRF). Tests: - ``TestResolveLocalAssetPath``: 10 cases covering legitimate paths, ``/etc/passwd``, ``/proc/self/environ``, symlink-out, ``..`` traversal, directories, missing files, and root list edge cases. - ``TestFetchValidatedImageBytes``: 7 cases covering SSRF block, non- image content-type rejection, valid image passthrough, non-200 response, fetch exception, empty URL, and parametrized coverage of every allowed image MIME type. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proxy/common_utils/static_asset_utils.py | 132 +++++++++ litellm/proxy/proxy_server.py | 121 ++++----- .../common_utils/test_static_asset_utils.py | 252 ++++++++++++++++++ 3 files changed, 442 insertions(+), 63 deletions(-) create mode 100644 litellm/proxy/common_utils/static_asset_utils.py create mode 100644 tests/test_litellm/proxy/common_utils/test_static_asset_utils.py diff --git a/litellm/proxy/common_utils/static_asset_utils.py b/litellm/proxy/common_utils/static_asset_utils.py new file mode 100644 index 0000000000..0643572118 --- /dev/null +++ b/litellm/proxy/common_utils/static_asset_utils.py @@ -0,0 +1,132 @@ +""" +Helpers for the unauthenticated logo / favicon endpoints (``/get_image`` and +``/get_favicon``). Both read an admin-set environment variable that may be a +local filesystem path or an HTTP URL, fetch the resource, and return the +bytes verbatim to any unauthenticated caller. + +Without these helpers: + +* a misconfigured / hostile env var like ``UI_LOGO_PATH=/etc/passwd`` lets + any unauthenticated caller exfiltrate the file (LFI — GHSA-3pcp-536p-ghjc). +* a legitimate-looking ``UI_LOGO_PATH=http://internal-service/branding.png`` + pointing at a private host lets any unauthenticated caller exfiltrate + whatever that host returns (SSRF — GHSA-pjc9-2hw6-78rr), regardless of + whether the body is actually an image. +""" + +import os +from typing import List, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.url_utils import SSRFError, validate_url +from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.types.llms.custom_http import httpxSpecialProvider + +# Conservative allowlist of image MIME types. Anything else is refused — +# without this, an admin-configured URL whose upstream returns +# ``application/json`` (e.g. cloud metadata, internal API) would still be +# served back to the caller verbatim. +ALLOWED_IMAGE_CONTENT_TYPES = frozenset( + { + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/svg+xml", + "image/webp", + "image/x-icon", + "image/vnd.microsoft.icon", + } +) + + +def resolve_local_asset_path(candidate: str, allowed_roots: List[str]) -> Optional[str]: + """ + Resolve ``candidate`` and return its absolute path only if it lives + within one of ``allowed_roots``. Returns None on any miss (caller + falls back to the bundled default asset). + + Resolution uses ``realpath`` to follow symlinks, so a symlink inside + ``allowed_roots`` pointing at ``/etc/passwd`` is rejected the same as + a direct ``/etc/passwd`` config. + """ + if not candidate: + return None + try: + resolved = os.path.realpath(os.path.expanduser(candidate)) + except (OSError, ValueError): + return None + if not os.path.isfile(resolved): + return None + for root in allowed_roots: + if not root: + continue + try: + root_resolved = os.path.realpath(root) + except (OSError, ValueError): + continue + if resolved == root_resolved: + return resolved + if resolved.startswith(root_resolved + os.sep): + return resolved + return None + + +async def fetch_validated_image_bytes( + url: str, *, timeout_s: float = 5.0 +) -> Optional[bytes]: + """ + Fetch ``url`` with SSRF protection (always-on) and Content-Type + validation. Returns the raw bytes on success, ``None`` on any + failure (blocked target, non-200, or non-image response). + + The SSRF guard is enforced unconditionally — these endpoints are + unauthenticated, so the admin-facing ``litellm.user_url_validation`` + toggle does not apply. An admin who opted out of URL validation for + LLM provider paths should not also expose ``/get_image`` to SSRF. + """ + if not url: + return None + try: + rewritten_url, host_header = validate_url(url) + except SSRFError as exc: + verbose_proxy_logger.warning( + "Blocked unauthenticated asset fetch — SSRF guard rejected %r: %s", + url, + exc, + ) + return None + + # ``validate_url`` rewrites HTTP URLs to point at a validated IP and + # returns the original hostname for the Host header. For HTTPS with + # ssl_verify enabled, it returns the URL unchanged (TLS hostname + # validation handles DNS rebinding). + request_kwargs = {} + if rewritten_url != url: + request_kwargs["headers"] = {"host": host_header} + + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.UI, + params={"timeout": timeout_s}, + ) + try: + response = await async_client.get(rewritten_url, **request_kwargs) + except Exception as exc: + verbose_proxy_logger.debug("Asset fetch failed for %r: %s", url, exc) + return None + + if response.status_code != 200: + return None + + content_type = ( + (response.headers.get("content-type") or "").split(";")[0].strip().lower() + ) + if content_type not in ALLOWED_IMAGE_CONTENT_TYPES: + verbose_proxy_logger.warning( + "Asset fetch from %r returned non-image content-type %r — refusing to serve.", + url, + content_type, + ) + return None + + return response.content diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 870ea78f17..bbd528072f 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12270,13 +12270,25 @@ async def get_image(): logo_path = os.getenv("UI_LOGO_PATH", default_logo) verbose_proxy_logger.debug("Reading logo from path: %s", logo_path) - # If UI_LOGO_PATH points to a local file, serve it directly (skip cache) + # ``/get_image`` is unauthenticated. Validate any admin-configured local + # path against an allowlist of asset roots — without this guard, an + # env var like ``UI_LOGO_PATH=/etc/passwd`` lets any caller exfiltrate + # the file via this endpoint. + from litellm.proxy.common_utils.static_asset_utils import ( + fetch_validated_image_bytes, + resolve_local_asset_path, + ) + + allowed_local_roots = [assets_dir, current_dir] + if logo_path != default_logo and not logo_path.startswith(("http://", "https://")): - if os.path.exists(logo_path): - return FileResponse(logo_path, media_type="image/jpeg") - # Custom path doesn't exist — fall back to default + safe_logo = resolve_local_asset_path(logo_path, allowed_local_roots) + if safe_logo is not None: + return FileResponse(safe_logo, media_type="image/jpeg") verbose_proxy_logger.warning( - f"UI_LOGO_PATH '{logo_path}' does not exist, falling back to default logo" + "UI_LOGO_PATH %r is outside the allowed asset roots or does not " + "exist, falling back to default logo", + logo_path, ) logo_path = default_logo @@ -12286,32 +12298,21 @@ async def get_image(): # Check if the logo path is an HTTP/HTTPS URL if logo_path.startswith(("http://", "https://")): - try: - # Download the image and cache it - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client - from litellm.types.llms.custom_http import httpxSpecialProvider - - async_client = get_async_httpx_client( - llm_provider=httpxSpecialProvider.UI, - params={"timeout": 5.0}, - ) - response = await async_client.get(logo_path) - if response.status_code == 200: - # Save the image to a local file + # SSRF + content-type validation — the helper rejects + # private/internal/cloud-metadata targets and non-image responses. + image_bytes = await fetch_validated_image_bytes(logo_path) + if image_bytes is not None: + try: with open(cache_path, "wb") as f: - f.write(response.content) - - # Return the cached image as a FileResponse + f.write(image_bytes) return FileResponse(cache_path, media_type="image/jpeg") - else: - # Handle the case when the image cannot be downloaded - return FileResponse(default_logo, media_type="image/jpeg") - except Exception as e: - # Handle any exceptions during the download (e.g., timeout, connection error) - verbose_proxy_logger.debug(f"Error downloading logo from {logo_path}: {e}") - return FileResponse(default_logo, media_type="image/jpeg") + except OSError as e: + verbose_proxy_logger.debug( + "Could not write logo cache to %s: %s", cache_path, e + ) + return FileResponse(default_logo, media_type="image/jpeg") else: - # Return the local image file if the logo path is not an HTTP/HTTPS URL + # Default logo (resolved from the bundled asset, not user-controlled). return FileResponse(logo_path, media_type="image/jpeg") @@ -12320,8 +12321,14 @@ async def get_favicon(): """Get custom favicon for the admin UI.""" from fastapi.responses import Response + from litellm.proxy.common_utils.static_asset_utils import ( + fetch_validated_image_bytes, + resolve_local_asset_path, + ) + current_dir = os.path.dirname(os.path.abspath(__file__)) default_favicon = os.path.join(current_dir, "_experimental", "out", "favicon.ico") + favicon_assets_dir = os.path.dirname(default_favicon) favicon_url = os.getenv("LITELLM_FAVICON_URL", "") @@ -12331,42 +12338,30 @@ async def get_favicon(): raise HTTPException(status_code=404, detail="Default favicon not found") if favicon_url.startswith(("http://", "https://")): - try: - from litellm.llms.custom_httpx.http_handler import get_async_httpx_client - from litellm.types.llms.custom_http import httpxSpecialProvider - - async_client = get_async_httpx_client( - llm_provider=httpxSpecialProvider.UI, - params={"timeout": 5.0}, - ) - response = await async_client.get(favicon_url) - if response.status_code == 200: - content_type = response.headers.get("content-type", "image/x-icon") - return Response( - content=response.content, - media_type=content_type, - ) - else: - verbose_proxy_logger.warning( - "Failed to fetch favicon from %s: status %s", - favicon_url, - response.status_code, - ) - if os.path.exists(default_favicon): - return FileResponse(default_favicon, media_type="image/x-icon") - raise HTTPException(status_code=404, detail="Favicon not found") - except HTTPException: - raise - except Exception as e: - verbose_proxy_logger.debug( - "Error downloading favicon from %s: %s", favicon_url, e - ) - if os.path.exists(default_favicon): - return FileResponse(default_favicon, media_type="image/x-icon") - raise HTTPException(status_code=404, detail="Favicon not found") + # SSRF + content-type validation — the helper rejects + # private/internal/cloud-metadata targets and non-image responses. + image_bytes = await fetch_validated_image_bytes(favicon_url) + if image_bytes is not None: + return Response(content=image_bytes, media_type="image/x-icon") + verbose_proxy_logger.warning( + "Failed to fetch favicon from %s — falling back to default", favicon_url + ) + if os.path.exists(default_favicon): + return FileResponse(default_favicon, media_type="image/x-icon") + raise HTTPException(status_code=404, detail="Favicon not found") else: - if os.path.exists(favicon_url): - return FileResponse(favicon_url, media_type="image/x-icon") + # ``/get_favicon`` is unauthenticated. Validate any admin-configured + # local path against an allowlist of asset roots — see ``/get_image`` + # for the LFI threat-model rationale. + allowed_local_roots = [favicon_assets_dir, current_dir] + safe_favicon = resolve_local_asset_path(favicon_url, allowed_local_roots) + if safe_favicon is not None: + return FileResponse(safe_favicon, media_type="image/x-icon") + verbose_proxy_logger.warning( + "LITELLM_FAVICON_URL %r is outside the allowed asset roots or " + "does not exist, falling back to default favicon", + favicon_url, + ) if os.path.exists(default_favicon): return FileResponse(default_favicon, media_type="image/x-icon") raise HTTPException(status_code=404, detail="Favicon not found") diff --git a/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py new file mode 100644 index 0000000000..6fe3b04bf0 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py @@ -0,0 +1,252 @@ +""" +Unit tests for the unauthenticated logo / favicon endpoint helpers. + +Closes the LFI half of GHSA-3pcp-536p-ghjc and the SSRF half of +GHSA-pjc9-2hw6-78rr — both endpoints accept an admin-set env var and +return its contents unauthenticated, so the helpers must reject: + +* local paths outside the allowed asset roots (LFI) +* HTTP URLs resolving to private / cloud-metadata addresses (SSRF) +* non-image responses (smuggling JSON / credentials through the + ``image/jpeg`` response wrapper) +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.litellm_core_utils.url_utils import SSRFError +from litellm.proxy.common_utils.static_asset_utils import ( + ALLOWED_IMAGE_CONTENT_TYPES, + fetch_validated_image_bytes, + resolve_local_asset_path, +) + + +class TestResolveLocalAssetPath: + @pytest.fixture + def assets_dir(self, tmp_path): + d = tmp_path / "assets" + d.mkdir() + return d + + def test_returns_resolved_path_for_file_inside_allowed_root(self, assets_dir): + logo = assets_dir / "logo.jpg" + logo.write_bytes(b"\xff\xd8\xff") # JPEG header + + result = resolve_local_asset_path(str(logo), [str(assets_dir)]) + assert result == str(logo.resolve()) + + def test_rejects_path_outside_allowed_roots(self, tmp_path, assets_dir): + outside = tmp_path / "secret.txt" + outside.write_text("password=hunter2") + + result = resolve_local_asset_path(str(outside), [str(assets_dir)]) + assert result is None + + def test_rejects_etc_passwd(self, assets_dir): + # The canonical LFI shape from GHSA-3pcp-536p-ghjc. + result = resolve_local_asset_path("/etc/passwd", [str(assets_dir)]) + assert result is None + + def test_rejects_proc_self_environ(self, assets_dir): + # Process environment exfil — same shape as /etc/passwd attack. + result = resolve_local_asset_path("/proc/self/environ", [str(assets_dir)]) + assert result is None + + def test_rejects_symlink_pointing_outside_allowed_roots(self, tmp_path, assets_dir): + secret = tmp_path / "secret.txt" + secret.write_text("password=hunter2") + sneaky = assets_dir / "logo.jpg" + os.symlink(str(secret), str(sneaky)) + + result = resolve_local_asset_path(str(sneaky), [str(assets_dir)]) + assert result is None + + def test_rejects_path_traversal_with_dotdot(self, tmp_path, assets_dir): + outside = tmp_path / "secret.txt" + outside.write_text("nope") + traversal = str(assets_dir / ".." / "secret.txt") + + result = resolve_local_asset_path(traversal, [str(assets_dir)]) + assert result is None + + def test_rejects_directory(self, assets_dir): + # Path containment requires the resolved entry to be a regular file. + result = resolve_local_asset_path(str(assets_dir), [str(assets_dir)]) + assert result is None + + def test_rejects_nonexistent_file_inside_allowed_root(self, assets_dir): + # Even a path that *would* be inside the allowed root must point at + # an existing file — otherwise we shouldn't pretend it resolves. + result = resolve_local_asset_path( + str(assets_dir / "missing.jpg"), [str(assets_dir)] + ) + assert result is None + + def test_rejects_empty_or_none(self, assets_dir): + assert resolve_local_asset_path("", [str(assets_dir)]) is None + + def test_skips_empty_or_invalid_roots(self, assets_dir): + logo = assets_dir / "logo.jpg" + logo.write_bytes(b"\xff\xd8\xff") + result = resolve_local_asset_path( + str(logo), ["", str(assets_dir), "/nonexistent/root"] + ) + assert result == str(logo.resolve()) + + +class TestFetchValidatedImageBytes: + @pytest.fixture + def mock_async_client(self): + client = MagicMock() + client.get = AsyncMock() + return client + + @pytest.mark.asyncio + async def test_blocks_private_ip_via_validate_url(self, mock_async_client): + # The SSRF half of GHSA-pjc9-2hw6-78rr — admin sets logo URL to + # http://169.254.169.254/iam, attacker hits /get_image, exfils creds. + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + side_effect=SSRFError("blocked: 169.254.169.254"), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("http://169.254.169.254/iam") + + assert result is None + # The fetch must not be attempted when the URL is rejected. + mock_async_client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_rejects_non_image_content_type(self, mock_async_client): + # Even when the URL passes SSRF, the upstream response must be an + # image. Otherwise an attacker could redirect to an upstream that + # returns ``application/json`` AWS creds and have them tunneled + # through the ``image/jpeg`` response wrapper. + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.content = b'{"AccessKeyId": "..."}' + mock_async_client.get.return_value = mock_response + + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + return_value=("http://cdn.example/logo", "cdn.example"), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("http://cdn.example/logo") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_bytes_for_valid_image_response(self, mock_async_client): + png_bytes = b"\x89PNG\r\n\x1a\nfake png body" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "image/png; charset=binary"} + mock_response.content = png_bytes + mock_async_client.get.return_value = mock_response + + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + return_value=( + "https://cdn.example/logo.png", + "cdn.example", + ), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("https://cdn.example/logo.png") + + assert result == png_bytes + + @pytest.mark.asyncio + async def test_returns_none_on_non_200_response(self, mock_async_client): + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.headers = {"content-type": "image/png"} + mock_async_client.get.return_value = mock_response + + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + return_value=("https://cdn.example/logo", "cdn.example"), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("https://cdn.example/logo") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_fetch_exception(self, mock_async_client): + mock_async_client.get.side_effect = Exception("connection reset") + + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + return_value=("https://cdn.example/logo", "cdn.example"), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("https://cdn.example/logo") + + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_for_empty_url(self): + result = await fetch_validated_image_bytes("") + assert result is None + + @pytest.mark.parametrize( + "content_type", + sorted(ALLOWED_IMAGE_CONTENT_TYPES), + ) + @pytest.mark.asyncio + async def test_accepts_each_allowed_image_content_type( + self, mock_async_client, content_type + ): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": content_type} + mock_response.content = b"image-bytes" + mock_async_client.get.return_value = mock_response + + with ( + patch( + "litellm.proxy.common_utils.static_asset_utils.validate_url", + return_value=("https://cdn.example/logo", "cdn.example"), + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=mock_async_client, + ), + ): + result = await fetch_validated_image_bytes("https://cdn.example/logo") + + assert result == b"image-bytes" From 9ef8572d6701ac5bcc7cdb53a4b3810dfc712471 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:15:21 +0000 Subject: [PATCH 02/47] fix(proxy): /get_logo_url no longer discloses local UI_LOGO_PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unauthenticated ``/get_logo_url`` endpoint returned the ``UI_LOGO_PATH`` env var verbatim. For HTTP(S) URLs this is intended — the dashboard loads the logo directly from a public/internal CDN. For local filesystem paths it was an information disclosure: any caller could fetch ``/get_logo_url`` and read admin-only filesystem details like ``UI_LOGO_PATH=/etc/litellm/secret-config.json``. Now the endpoint returns the URL only when it begins with ``http://`` or ``https://``. For local paths (or unset) it returns an empty string — the dashboard falls back to ``/get_image`` which serves the file via the path-containment guard added in the previous commit. Tests parametrize the disclosure-blocked cases (``/etc/...``, ``/proc/self/environ``, relative paths) and confirm HTTP / HTTPS URLs still pass through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/proxy_server.py | 15 +++++- tests/test_litellm/proxy/test_proxy_server.py | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index bbd528072f..ead8e8f6b9 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12223,9 +12223,20 @@ async def claim_onboarding_link(data: InvitationClaim): @app.get("/get_logo_url", include_in_schema=False) def get_logo_url(): - """Get the current logo URL from environment""" + """Get the current logo URL from environment. + + Only HTTP(S) URLs are returned — those are intended to be loaded + directly by the browser from a public/internal CDN. Local file + paths set via ``UI_LOGO_PATH`` are NOT returned: they are admin- + only filesystem details, the dashboard falls back to ``/get_image`` + which serves the file (with path containment) instead. Without + this filter, the unauthenticated endpoint would disclose internal + hostnames or filesystem paths to any caller. + """ logo_path = os.getenv("UI_LOGO_PATH", "") - return {"logo_url": logo_path} + if logo_path.startswith(("http://", "https://")): + return {"logo_url": logo_path} + return {"logo_url": ""} @app.get("/get_image", include_in_schema=False) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1f4f82a64e..da3f963f9d 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -457,6 +457,60 @@ def test_fallback_login_has_no_deprecation_banner(client_no_auth): assert " Date: Wed, 29 Apr 2026 21:18:07 +0000 Subject: [PATCH 03/47] fix(proxy): also accept LITELLM_ASSETS_PATH for /get_favicon local path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align ``/get_favicon``'s allowed-root list with ``/get_image``'s. Both endpoints now accept paths under any of: * ``LITELLM_ASSETS_PATH`` (or its default — ``/var/lib/litellm/assets`` for non-root, the package dir otherwise) * the package's bundled-asset dir (``proxy/_experimental/out`` for the default favicon, ``proxy/`` for the default logo) * the proxy package dir (``current_dir``) as a final fallback Without this, an admin who put a custom favicon under ``LITELLM_ASSETS_PATH`` (e.g. mounted into the container at ``/var/lib/litellm/assets/favicon.ico``) would have the favicon endpoint silently fall back to the default after the previous commit's path-containment guard. The logo endpoint already accepted this root. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/proxy_server.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ead8e8f6b9..ef17aab73e 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12339,7 +12339,13 @@ async def get_favicon(): current_dir = os.path.dirname(os.path.abspath(__file__)) default_favicon = os.path.join(current_dir, "_experimental", "out", "favicon.ico") - favicon_assets_dir = os.path.dirname(default_favicon) + favicon_default_dir = os.path.dirname(default_favicon) + + # Admin-managed asset directory (parallels ``/get_image``). Custom + # favicons placed here remain readable post-fix. + is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true" + default_assets_dir = "/var/lib/litellm/assets" if is_non_root else current_dir + assets_dir = os.getenv("LITELLM_ASSETS_PATH", default_assets_dir) favicon_url = os.getenv("LITELLM_FAVICON_URL", "") @@ -12364,7 +12370,7 @@ async def get_favicon(): # ``/get_favicon`` is unauthenticated. Validate any admin-configured # local path against an allowlist of asset roots — see ``/get_image`` # for the LFI threat-model rationale. - allowed_local_roots = [favicon_assets_dir, current_dir] + allowed_local_roots = [assets_dir, favicon_default_dir, current_dir] safe_favicon = resolve_local_asset_path(favicon_url, allowed_local_roots) if safe_favicon is not None: return FileResponse(safe_favicon, media_type="image/x-icon") From 55d393d77d34b58e802e00c9d645f9aa0a338300 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:47:41 +0000 Subject: [PATCH 04/47] =?UTF-8?q?fix(static-assets):=20unblock=20CI=20?= =?UTF-8?q?=E2=80=94=20pass=20headers=20explicitly=20+=20harden=20+=20upda?= =?UTF-8?q?te=20legacy=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI failures from the previous push, all addressed: * ``lint`` (mypy): ``async_client.get(url, **request_kwargs)`` confused mypy because ``AsyncHTTPHandler.get``'s second positional arg is typed ``bool | None``. Switched to an explicit branch: ``await async_client.get(rewritten_url, headers={"host": host_header})`` for the HTTP-rewritten case, plain ``get(rewritten_url)`` otherwise. * ``proxy-infra`` / ``test_get_image_custom_local_logo_bypasses_cache``: the existing test set ``UI_LOGO_PATH=/app/custom_logo.jpg`` with no ``LITELLM_ASSETS_PATH``, asserting the path was served verbatim. That was the LFI behaviour the new path-containment guard closes. Updated the test to set ``LITELLM_ASSETS_PATH=/app`` so the path is inside an allowed root, and patched the helper's ``realpath`` / ``isfile`` to go along with the mocked filesystem. Test intent (bypass cache when ``UI_LOGO_PATH`` is local) is preserved. * ``auth-and-jwt`` / ``test_get_image_cache_logic``: existing test built a ``Mock`` response without ``headers``, so the new Content-Type check tripped on ``Mock().split(";")[0]``. Two fixes: 1. Set ``mock_response.headers = {"content-type": "image/jpeg"}`` on the test (matches the real upstream contract — a logo CDN always sets a Content-Type). 2. Make ``fetch_validated_image_bytes`` defensive: if the Content-Type header is missing or non-string, treat as non-image and fall back to default. Closes a subtle hole — pre-fix, an upstream that omits Content-Type entirely would have served arbitrary bytes under the ``image/jpeg`` wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proxy/common_utils/static_asset_utils.py | 21 ++++++++++++------- tests/proxy_unit_tests/test_get_image.py | 5 ++++- tests/test_litellm/proxy/test_proxy_server.py | 15 ++++++++++++- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/common_utils/static_asset_utils.py b/litellm/proxy/common_utils/static_asset_utils.py index 0643572118..ffead95dcf 100644 --- a/litellm/proxy/common_utils/static_asset_utils.py +++ b/litellm/proxy/common_utils/static_asset_utils.py @@ -101,16 +101,17 @@ async def fetch_validated_image_bytes( # returns the original hostname for the Host header. For HTTPS with # ssl_verify enabled, it returns the URL unchanged (TLS hostname # validation handles DNS rebinding). - request_kwargs = {} - if rewritten_url != url: - request_kwargs["headers"] = {"host": host_header} - async_client = get_async_httpx_client( llm_provider=httpxSpecialProvider.UI, params={"timeout": timeout_s}, ) try: - response = await async_client.get(rewritten_url, **request_kwargs) + if rewritten_url != url: + response = await async_client.get( + rewritten_url, headers={"host": host_header} + ) + else: + response = await async_client.get(rewritten_url) except Exception as exc: verbose_proxy_logger.debug("Asset fetch failed for %r: %s", url, exc) return None @@ -118,9 +119,15 @@ async def fetch_validated_image_bytes( if response.status_code != 200: return None - content_type = ( - (response.headers.get("content-type") or "").split(";")[0].strip().lower() + raw_content_type = ( + response.headers.get("content-type") if hasattr(response, "headers") else None ) + if not isinstance(raw_content_type, str): + # Defensive: if upstream omits Content-Type entirely, treat as + # non-image. (Also keeps ``Mock`` responses without a configured + # ``headers`` from blowing up the content-type check.) + return None + content_type = raw_content_type.split(";")[0].strip().lower() if content_type not in ALLOWED_IMAGE_CONTENT_TYPES: verbose_proxy_logger.warning( "Asset fetch from %r returned non-image content-type %r — refusing to serve.", diff --git a/tests/proxy_unit_tests/test_get_image.py b/tests/proxy_unit_tests/test_get_image.py index ad8c267275..f14c6da553 100644 --- a/tests/proxy_unit_tests/test_get_image.py +++ b/tests/proxy_unit_tests/test_get_image.py @@ -64,10 +64,13 @@ async def test_get_image_cache_logic(): if os.path.exists(cache_path): os.remove(cache_path) - # Mock response + # Mock response — set headers explicitly so the Content-Type + # validation added for GHSA-pjc9-2hw6-78rr accepts the response + # as a legitimate image. mock_response = mock.Mock() mock_response.status_code = 200 mock_response.content = b"fake image data" + mock_response.headers = {"content-type": "image/jpeg"} with mock.patch( "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index da3f963f9d..1a77e39aa8 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -4047,9 +4047,12 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch): from litellm.proxy.proxy_server import get_image + # Use a path inside the allowlisted ``LITELLM_ASSETS_PATH`` — the + # path-containment guard added for GHSA-3pcp-536p-ghjc rejects any + # local UI_LOGO_PATH outside the allowed asset roots. + monkeypatch.setenv("LITELLM_ASSETS_PATH", "/app") monkeypatch.setenv("UI_LOGO_PATH", "/app/custom_logo.jpg") monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) - monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False) calls_to_file_response = [] @@ -4063,6 +4066,16 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch): patch( "litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response ), + # The path-containment helper calls ``os.path.realpath`` and + # ``os.path.isfile`` — make them play along for the test path. + patch( + "litellm.proxy.common_utils.static_asset_utils.os.path.realpath", + side_effect=lambda p: p, + ), + patch( + "litellm.proxy.common_utils.static_asset_utils.os.path.isfile", + return_value=True, + ), ): await get_image() From 75d1a0116e82e6d53a3d8f54552a391e2d364eab Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:57:22 +0000 Subject: [PATCH 05/47] fix(static-assets): use async_safe_get; drop SVG; serve bytes inline on cache miss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review items addressed: * **Veria (Medium): SSRF via redirect.** ``fetch_validated_image_bytes`` was calling ``validate_url(url)`` once and then fetching with the default httpx client, so a 3xx to an internal IP would have been followed unvalidated. Switched to ``async_safe_get`` (the existing SSRF primitive used elsewhere in the codebase) which walks each redirect hop, re-validates, and rejects redirects to blocked networks. Default ``litellm.user_url_validation`` is True so protection is on out of the box. * **Greptile (P2): SVG can embed JS.** Removed ``image/svg+xml`` from the allowed-Content-Type set. The hardcoded response media type (``image/jpeg`` / ``image/x-icon``) means a real SVG body wouldn't render as SVG anyway in modern browsers — the allowlist entry was giving up XSS surface for no actual SVG-rendering benefit. If real SVG support is wanted later, that's a deliberate feature PR with CSP / nosniff bundled. * **Greptile (P2): cache-write OSError drops validated bytes.** When the upstream fetch succeeded but ``open(cache_path, "wb")`` raised (read-only assets dir), the bytes were discarded and the default logo was served — a silent regression for that deployment. Now serve the validated bytes inline via ``Response(...)`` as a fallback before falling back to default. Tests: - Replaced low-level mocks of ``validate_url`` with mocks of ``async_safe_get`` directly, exercising the helper's contract rather than the SSRF primitive's internals. - New ``test_rejects_svg_content_type`` confirms SVG is blocked. - ``test_get_image_cache_logic`` fixture now sets ``mock_response.is_redirect = False`` so ``async_safe_get`` doesn't treat the Mock's truthy attribute as a redirect. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../proxy/common_utils/static_asset_utils.py | 50 +++--- litellm/proxy/proxy_server.py | 25 +-- tests/proxy_unit_tests/test_get_image.py | 6 +- .../common_utils/test_static_asset_utils.py | 146 +++++++----------- 4 files changed, 101 insertions(+), 126 deletions(-) diff --git a/litellm/proxy/common_utils/static_asset_utils.py b/litellm/proxy/common_utils/static_asset_utils.py index ffead95dcf..74fe9939aa 100644 --- a/litellm/proxy/common_utils/static_asset_utils.py +++ b/litellm/proxy/common_utils/static_asset_utils.py @@ -18,7 +18,7 @@ import os from typing import List, Optional from litellm._logging import verbose_proxy_logger -from litellm.litellm_core_utils.url_utils import SSRFError, validate_url +from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider @@ -26,13 +26,19 @@ from litellm.types.llms.custom_http import httpxSpecialProvider # without this, an admin-configured URL whose upstream returns # ``application/json`` (e.g. cloud metadata, internal API) would still be # served back to the caller verbatim. +# +# ``image/svg+xml`` is intentionally NOT in this list: SVG is the only +# common image format that can embed JavaScript, and the endpoint is +# unauthenticated. An admin-configured CDN serving a crafted SVG would +# otherwise reach unauthenticated callers; removing SVG closes the +# residual XSS surface even though the response is served with a +# hardcoded ``image/jpeg`` / ``image/x-icon`` media type. ALLOWED_IMAGE_CONTENT_TYPES = frozenset( { "image/jpeg", "image/jpg", "image/png", "image/gif", - "image/svg+xml", "image/webp", "image/x-icon", "image/vnd.microsoft.icon", @@ -76,19 +82,27 @@ async def fetch_validated_image_bytes( url: str, *, timeout_s: float = 5.0 ) -> Optional[bytes]: """ - Fetch ``url`` with SSRF protection (always-on) and Content-Type - validation. Returns the raw bytes on success, ``None`` on any - failure (blocked target, non-200, or non-image response). + Fetch ``url`` with SSRF protection and Content-Type validation. + Returns the raw bytes on success, ``None`` on any failure (blocked + target, redirect to a blocked target, non-200, or non-image + response). - The SSRF guard is enforced unconditionally — these endpoints are - unauthenticated, so the admin-facing ``litellm.user_url_validation`` - toggle does not apply. An admin who opted out of URL validation for - LLM provider paths should not also expose ``/get_image`` to SSRF. + Delegates to ``async_safe_get`` so each redirect hop is re-validated + against ``BLOCKED_NETWORKS`` (a 3xx to ``169.254.169.254`` is + rejected, not followed). Honours ``litellm.user_url_validation`` + like every other SSRF-aware fetch in the codebase; the toggle + defaults to True, and an admin who has explicitly disabled URL + validation has opted out of SSRF protection globally. """ if not url: return None + + async_client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.UI, + params={"timeout": timeout_s}, + ) try: - rewritten_url, host_header = validate_url(url) + response = await async_safe_get(async_client, url) except SSRFError as exc: verbose_proxy_logger.warning( "Blocked unauthenticated asset fetch — SSRF guard rejected %r: %s", @@ -96,22 +110,6 @@ async def fetch_validated_image_bytes( exc, ) return None - - # ``validate_url`` rewrites HTTP URLs to point at a validated IP and - # returns the original hostname for the Host header. For HTTPS with - # ssl_verify enabled, it returns the URL unchanged (TLS hostname - # validation handles DNS rebinding). - async_client = get_async_httpx_client( - llm_provider=httpxSpecialProvider.UI, - params={"timeout": timeout_s}, - ) - try: - if rewritten_url != url: - response = await async_client.get( - rewritten_url, headers={"host": host_header} - ) - else: - response = await async_client.get(rewritten_url) except Exception as exc: verbose_proxy_logger.debug("Asset fetch failed for %r: %s", url, exc) return None diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ef17aab73e..736f259f23 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12312,16 +12312,21 @@ async def get_image(): # SSRF + content-type validation — the helper rejects # private/internal/cloud-metadata targets and non-image responses. image_bytes = await fetch_validated_image_bytes(logo_path) - if image_bytes is not None: - try: - with open(cache_path, "wb") as f: - f.write(image_bytes) - return FileResponse(cache_path, media_type="image/jpeg") - except OSError as e: - verbose_proxy_logger.debug( - "Could not write logo cache to %s: %s", cache_path, e - ) - return FileResponse(default_logo, media_type="image/jpeg") + if image_bytes is None: + return FileResponse(default_logo, media_type="image/jpeg") + try: + with open(cache_path, "wb") as f: + f.write(image_bytes) + return FileResponse(cache_path, media_type="image/jpeg") + except OSError as e: + # Read-only assets dir: serve the validated bytes inline + # rather than dropping them and returning the default logo. + from fastapi.responses import Response + + verbose_proxy_logger.debug( + "Could not write logo cache to %s: %s — serving inline", cache_path, e + ) + return Response(content=image_bytes, media_type="image/jpeg") else: # Default logo (resolved from the bundled asset, not user-controlled). return FileResponse(logo_path, media_type="image/jpeg") diff --git a/tests/proxy_unit_tests/test_get_image.py b/tests/proxy_unit_tests/test_get_image.py index f14c6da553..bdc7743faa 100644 --- a/tests/proxy_unit_tests/test_get_image.py +++ b/tests/proxy_unit_tests/test_get_image.py @@ -65,12 +65,14 @@ async def test_get_image_cache_logic(): os.remove(cache_path) # Mock response — set headers explicitly so the Content-Type - # validation added for GHSA-pjc9-2hw6-78rr accepts the response - # as a legitimate image. + # validation accepts the response as a legitimate image, and set + # ``is_redirect=False`` so ``async_safe_get`` doesn't try to walk + # a redirect chain. mock_response = mock.Mock() mock_response.status_code = 200 mock_response.content = b"fake image data" mock_response.headers = {"content-type": "image/jpeg"} + mock_response.is_redirect = False with mock.patch( "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" diff --git a/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py index 6fe3b04bf0..0c6b7f1897 100644 --- a/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py @@ -101,121 +101,89 @@ class TestResolveLocalAssetPath: class TestFetchValidatedImageBytes: - @pytest.fixture - def mock_async_client(self): - client = MagicMock() - client.get = AsyncMock() - return client + """ + The helper now delegates to ``async_safe_get`` for the SSRF guard + + redirect handling. Tests mock ``async_safe_get`` directly so they + exercise the helper's contract (Content-Type validation, status code + handling, exception fallthrough) without depending on the SSRF + primitive's internals. + """ - @pytest.mark.asyncio - async def test_blocks_private_ip_via_validate_url(self, mock_async_client): - # The SSRF half of GHSA-pjc9-2hw6-78rr — admin sets logo URL to - # http://169.254.169.254/iam, attacker hits /get_image, exfils creds. - with ( + @staticmethod + def _patches(*, async_safe_get_return=None, async_safe_get_side_effect=None): + return [ patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - side_effect=SSRFError("blocked: 169.254.169.254"), + "litellm.proxy.common_utils.static_asset_utils.async_safe_get", + new_callable=AsyncMock, + return_value=async_safe_get_return, + side_effect=async_safe_get_side_effect, ), patch( "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, + return_value=MagicMock(), ), - ): - result = await fetch_validated_image_bytes("http://169.254.169.254/iam") - - assert result is None - # The fetch must not be attempted when the URL is rejected. - mock_async_client.get.assert_not_called() + ] @pytest.mark.asyncio - async def test_rejects_non_image_content_type(self, mock_async_client): + async def test_blocks_ssrf_target(self): + # The SSRF half of GHSA-pjc9-2hw6-78rr — admin sets logo URL to + # http://169.254.169.254/iam, attacker hits /get_image, exfils creds. + # ``async_safe_get`` raises SSRFError on private/metadata targets + # and on redirect hops to those targets (covers the redirect + # bypass Veria flagged on the previous iteration). + with ( + self._patches( + async_safe_get_side_effect=SSRFError("blocked: 169.254.169.254") + )[0], + self._patches()[1], + ): + result = await fetch_validated_image_bytes("http://169.254.169.254/iam") + assert result is None + + @pytest.mark.asyncio + async def test_rejects_non_image_content_type(self): # Even when the URL passes SSRF, the upstream response must be an - # image. Otherwise an attacker could redirect to an upstream that + # image. Otherwise an attacker could point at an upstream that # returns ``application/json`` AWS creds and have them tunneled # through the ``image/jpeg`` response wrapper. mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} mock_response.content = b'{"AccessKeyId": "..."}' - mock_async_client.get.return_value = mock_response - with ( - patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - return_value=("http://cdn.example/logo", "cdn.example"), - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, - ), - ): + with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: result = await fetch_validated_image_bytes("http://cdn.example/logo") - assert result is None @pytest.mark.asyncio - async def test_returns_bytes_for_valid_image_response(self, mock_async_client): + async def test_returns_bytes_for_valid_image_response(self): png_bytes = b"\x89PNG\r\n\x1a\nfake png body" mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "image/png; charset=binary"} mock_response.content = png_bytes - mock_async_client.get.return_value = mock_response - with ( - patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - return_value=( - "https://cdn.example/logo.png", - "cdn.example", - ), - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, - ), - ): + with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: result = await fetch_validated_image_bytes("https://cdn.example/logo.png") - assert result == png_bytes @pytest.mark.asyncio - async def test_returns_none_on_non_200_response(self, mock_async_client): + async def test_returns_none_on_non_200_response(self): mock_response = MagicMock() mock_response.status_code = 404 mock_response.headers = {"content-type": "image/png"} - mock_async_client.get.return_value = mock_response - with ( - patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - return_value=("https://cdn.example/logo", "cdn.example"), - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, - ), - ): + with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: result = await fetch_validated_image_bytes("https://cdn.example/logo") - assert result is None @pytest.mark.asyncio - async def test_returns_none_on_fetch_exception(self, mock_async_client): - mock_async_client.get.side_effect = Exception("connection reset") - + async def test_returns_none_on_fetch_exception(self): with ( - patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - return_value=("https://cdn.example/logo", "cdn.example"), - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, - ), + self._patches(async_safe_get_side_effect=Exception("connection reset"))[0], + self._patches()[1], ): result = await fetch_validated_image_bytes("https://cdn.example/logo") - assert result is None @pytest.mark.asyncio @@ -223,30 +191,32 @@ class TestFetchValidatedImageBytes: result = await fetch_validated_image_bytes("") assert result is None + @pytest.mark.asyncio + async def test_rejects_svg_content_type(self): + # ``image/svg+xml`` is intentionally NOT in the allowlist for + # unauthenticated endpoints — SVG is the only common image + # format that can embed JavaScript. + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "image/svg+xml"} + mock_response.content = b"" + + with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + result = await fetch_validated_image_bytes("https://cdn.example/x.svg") + assert result is None + @pytest.mark.parametrize( "content_type", sorted(ALLOWED_IMAGE_CONTENT_TYPES), ) @pytest.mark.asyncio - async def test_accepts_each_allowed_image_content_type( - self, mock_async_client, content_type - ): + async def test_accepts_each_allowed_image_content_type(self, content_type): mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": content_type} mock_response.content = b"image-bytes" - mock_async_client.get.return_value = mock_response - with ( - patch( - "litellm.proxy.common_utils.static_asset_utils.validate_url", - return_value=("https://cdn.example/logo", "cdn.example"), - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=mock_async_client, - ), - ): + with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: result = await fetch_validated_image_bytes("https://cdn.example/logo") assert result == b"image-bytes" From c112bdf2c15efe20c25939e280f000b2555efde4 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:01:57 +0000 Subject: [PATCH 06/47] =?UTF-8?q?chore(static-assets):=20/simplify=20pass?= =?UTF-8?q?=20=E2=80=94=20top-level=20Response=20import=20+=20cleaner=20te?= =?UTF-8?q?st=20fixture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cleanups from the /simplify review pass: * ``Response`` was imported inside the ``except OSError`` branch in ``/get_image`` and at the top of ``/get_favicon``. Per the project's no-inline-imports rule (CLAUDE.md), hoisted to the existing ``from fastapi.responses import (...)`` block at the top of ``proxy_server.py``. * The test class's ``_patches()`` helper returned a 2-element list of patch context managers and tests indexed into them via ``self._patches(...)[0], self._patches()[1]`` — two distinct calls with confusing aliasing semantics. Restructured to: - module-level ``_patch_async_safe_get(...)`` that returns a single patch context manager - autouse fixture that patches ``get_async_httpx_client`` for every test in the file (it's the same patch in every case) - small ``_image_response(...)`` factory to deduplicate Mock setup Tests now read as ``with _patch_async_safe_get(return_value=...):`` with no list-indexing or duplicate Mock construction. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/proxy_server.py | 5 +- .../common_utils/test_static_asset_utils.py | 121 +++++++++--------- 2 files changed, 58 insertions(+), 68 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 736f259f23..b5288f7165 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -605,6 +605,7 @@ from fastapi.responses import ( JSONResponse, ORJSONResponse, RedirectResponse, + Response, StreamingResponse, ) from fastapi.routing import APIRouter @@ -12321,8 +12322,6 @@ async def get_image(): except OSError as e: # Read-only assets dir: serve the validated bytes inline # rather than dropping them and returning the default logo. - from fastapi.responses import Response - verbose_proxy_logger.debug( "Could not write logo cache to %s: %s — serving inline", cache_path, e ) @@ -12335,8 +12334,6 @@ async def get_image(): @app.get("/get_favicon", include_in_schema=False) async def get_favicon(): """Get custom favicon for the admin UI.""" - from fastapi.responses import Response - from litellm.proxy.common_utils.static_asset_utils import ( fetch_validated_image_bytes, resolve_local_asset_path, diff --git a/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py index 0c6b7f1897..ad9ac0b833 100644 --- a/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_static_asset_utils.py @@ -100,89 +100,86 @@ class TestResolveLocalAssetPath: assert result == str(logo.resolve()) +def _image_response(*, status_code=200, content_type="image/png", body=b"image-bytes"): + response = MagicMock() + response.status_code = status_code + response.headers = {"content-type": content_type} + response.content = body + return response + + +def _patch_async_safe_get(*, return_value=None, side_effect=None): + return patch( + "litellm.proxy.common_utils.static_asset_utils.async_safe_get", + new_callable=AsyncMock, + return_value=return_value, + side_effect=side_effect, + ) + + +@pytest.fixture(autouse=True) +def _patch_httpx_client(): + # The helper builds the client first, then hands it to async_safe_get + # — patch it once for every test so we never accidentally instantiate + # a real client. + with patch( + "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", + return_value=MagicMock(), + ): + yield + + class TestFetchValidatedImageBytes: """ - The helper now delegates to ``async_safe_get`` for the SSRF guard + + The helper delegates to ``async_safe_get`` for the SSRF guard + redirect handling. Tests mock ``async_safe_get`` directly so they exercise the helper's contract (Content-Type validation, status code handling, exception fallthrough) without depending on the SSRF primitive's internals. """ - @staticmethod - def _patches(*, async_safe_get_return=None, async_safe_get_side_effect=None): - return [ - patch( - "litellm.proxy.common_utils.static_asset_utils.async_safe_get", - new_callable=AsyncMock, - return_value=async_safe_get_return, - side_effect=async_safe_get_side_effect, - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.get_async_httpx_client", - return_value=MagicMock(), - ), - ] - @pytest.mark.asyncio async def test_blocks_ssrf_target(self): - # The SSRF half of GHSA-pjc9-2hw6-78rr — admin sets logo URL to - # http://169.254.169.254/iam, attacker hits /get_image, exfils creds. # ``async_safe_get`` raises SSRFError on private/metadata targets - # and on redirect hops to those targets (covers the redirect - # bypass Veria flagged on the previous iteration). - with ( - self._patches( - async_safe_get_side_effect=SSRFError("blocked: 169.254.169.254") - )[0], - self._patches()[1], - ): + # and on redirect hops to those targets — closes the SSRF half of + # GHSA-pjc9-2hw6-78rr including the redirect-bypass variant. + with _patch_async_safe_get(side_effect=SSRFError("blocked: 169.254.169.254")): result = await fetch_validated_image_bytes("http://169.254.169.254/iam") assert result is None @pytest.mark.asyncio async def test_rejects_non_image_content_type(self): - # Even when the URL passes SSRF, the upstream response must be an - # image. Otherwise an attacker could point at an upstream that - # returns ``application/json`` AWS creds and have them tunneled - # through the ``image/jpeg`` response wrapper. - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "application/json"} - mock_response.content = b'{"AccessKeyId": "..."}' - - with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + # Without this, an upstream that returns ``application/json`` AWS + # creds would be tunneled through the ``image/jpeg`` response + # wrapper. + with _patch_async_safe_get( + return_value=_image_response( + content_type="application/json", body=b'{"AccessKeyId": "..."}' + ), + ): result = await fetch_validated_image_bytes("http://cdn.example/logo") assert result is None @pytest.mark.asyncio async def test_returns_bytes_for_valid_image_response(self): png_bytes = b"\x89PNG\r\n\x1a\nfake png body" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "image/png; charset=binary"} - mock_response.content = png_bytes - - with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + with _patch_async_safe_get( + return_value=_image_response( + content_type="image/png; charset=binary", body=png_bytes + ), + ): result = await fetch_validated_image_bytes("https://cdn.example/logo.png") assert result == png_bytes @pytest.mark.asyncio async def test_returns_none_on_non_200_response(self): - mock_response = MagicMock() - mock_response.status_code = 404 - mock_response.headers = {"content-type": "image/png"} - - with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + with _patch_async_safe_get(return_value=_image_response(status_code=404)): result = await fetch_validated_image_bytes("https://cdn.example/logo") assert result is None @pytest.mark.asyncio async def test_returns_none_on_fetch_exception(self): - with ( - self._patches(async_safe_get_side_effect=Exception("connection reset"))[0], - self._patches()[1], - ): + with _patch_async_safe_get(side_effect=Exception("connection reset")): result = await fetch_validated_image_bytes("https://cdn.example/logo") assert result is None @@ -196,12 +193,12 @@ class TestFetchValidatedImageBytes: # ``image/svg+xml`` is intentionally NOT in the allowlist for # unauthenticated endpoints — SVG is the only common image # format that can embed JavaScript. - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": "image/svg+xml"} - mock_response.content = b"" - - with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + with _patch_async_safe_get( + return_value=_image_response( + content_type="image/svg+xml", + body=b"", + ), + ): result = await fetch_validated_image_bytes("https://cdn.example/x.svg") assert result is None @@ -211,12 +208,8 @@ class TestFetchValidatedImageBytes: ) @pytest.mark.asyncio async def test_accepts_each_allowed_image_content_type(self, content_type): - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.headers = {"content-type": content_type} - mock_response.content = b"image-bytes" - - with self._patches(async_safe_get_return=mock_response)[0], self._patches()[1]: + with _patch_async_safe_get( + return_value=_image_response(content_type=content_type), + ): result = await fetch_validated_image_bytes("https://cdn.example/logo") - assert result == b"image-bytes" From 89aa13fdf3910eb1c3c97c78bc1b00f6cb7a3395 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:47:43 +0000 Subject: [PATCH 07/47] fix(static-assets): also wrap admin-only Vault token verification in async_safe_get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Variant analysis on the unauthenticated /get_image SSRF surfaced one related sink in an admin-only endpoint: ``test_hashicorp_vault_connection`` in ``config_override_endpoints.py:402`` calls ``async_client.get(f"{vault_addr}/v1/auth/token/lookup-self")`` with no SSRF guard. ``vault_addr`` is admin-set, so the threat model is "admin misconfig (or attacker with admin creds) pivots Vault calls to cloud metadata or another internal IP." Same fix shape as the unauthenticated endpoints: wrap in ``async_safe_get`` so each redirect hop is re-validated and private networks are rejected. Admins running against a legitimate internal Vault should add the host to ``litellm.user_url_allowed_hosts`` — the existing escape hatch already used elsewhere in the codebase. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../management_endpoints/config_override_endpoints.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py index d78c5526e6..6e7cedd632 100644 --- a/litellm/proxy/management_endpoints/config_override_endpoints.py +++ b/litellm/proxy/management_endpoints/config_override_endpoints.py @@ -391,7 +391,14 @@ async def test_hashicorp_vault_connection( detail=f"Vault authentication failed: {e}", ) - # Step 2: Verify the token is valid via token/lookup-self + # Step 2: Verify the token is valid via token/lookup-self. + # ``vault_addr`` is admin-set; wrapping in ``async_safe_get`` prevents + # a misconfigured (or attacker-influenced) value from pivoting the + # request to cloud metadata or another internal IP. Admins running + # against an internal Vault should add the host to + # ``litellm.user_url_allowed_hosts``. + from litellm.litellm_core_utils.url_utils import async_safe_get + try: async_client = get_async_httpx_client( llm_provider=httpxSpecialProvider.SecretManager @@ -399,7 +406,7 @@ async def test_hashicorp_vault_connection( lookup_url = f"{client.vault_addr}/v1/auth/token/lookup-self" if client.vault_namespace: headers["X-Vault-Namespace"] = client.vault_namespace - response = await async_client.get(lookup_url, headers=headers) + response = await async_safe_get(async_client, lookup_url, headers=headers) response.raise_for_status() except Exception as e: raise HTTPException( From 14473ed8f964e1e345f90a7a190680fadf86b45b Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:04:34 +0000 Subject: [PATCH 08/47] =?UTF-8?q?fix(static-assets):=20ruff=20F811=20?= =?UTF-8?q?=E2=80=94=20drop=20duplicate=20Response=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``Response`` is already imported from the top-level ``fastapi`` package via the multi-line ``from fastapi import (...)`` block at the top of the file (along with ``Depends``, ``HTTPException``, etc.) — ``fastapi.Response`` is the same class that ``fastapi.responses`` re-exports. The earlier ``from fastapi.responses import Response`` addition triggered ruff F811 for redefinition. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/proxy_server.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b5288f7165..776c05dfcc 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -605,7 +605,6 @@ from fastapi.responses import ( JSONResponse, ORJSONResponse, RedirectResponse, - Response, StreamingResponse, ) from fastapi.routing import APIRouter From 148485c2a24b739074f9416cf6fb66d5d7adb759 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:10:59 +0000 Subject: [PATCH 09/47] fix(passthrough): default auth=True; drop enterprise gate on the safe option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-through endpoints configured in ``general_settings.pass_through_endpoints`` defaulted to ``auth: false`` and the safe ``auth: true`` setting was rejected at startup unless the operator had a LiteLLM Enterprise license. Net result: OSS deployments had **no safe configuration** — every pass-through admins added without remembering ``auth: true`` shipped an unauthenticated forwarder, and remembering ``auth: true`` raised a hard "enterprise-only" error. Three changes: * ``litellm/proxy/_types.py`` — flip ``PassThroughGenericEndpoint.auth`` default to ``True``. Operators who add a pass-through with no explicit ``auth`` value now get a safe, authenticated forwarder by default. Setting ``auth: false`` remains supported for genuine public-forwarder use cases (e.g. webhook receivers). * ``litellm/proxy/pass_through_endpoints/pass_through_endpoints.py`` — drop the ``premium_user`` gate around ``auth: true``. An unauthenticated forwarder is a deployment choice operators should be allowed to make explicitly, but the safe option must always be free. The product-tier decision (which features sit behind the enterprise license) is separate from "OSS users must always have a safe option." * ``litellm/proxy/auth/user_api_key_auth.py`` — the runtime dispatch pulls pass-through endpoints from ``general_settings`` as raw dicts, so the Pydantic default doesn't apply. Switched ``endpoint.get("auth")`` to ``endpoint.get("auth", True)`` so a config dict without an explicit ``auth`` key still requires authentication at request time. Tests: - ``test_passthrough_auth_defaults_to_true`` — Pydantic default is now safe. - ``test_passthrough_auth_can_still_be_explicitly_disabled`` — opt-in to ``auth=False`` still works for legitimate public-forwarder use cases. - ``test_register_passthrough_with_auth_true_works_for_oss`` — ``premium_user=False`` no longer rejects ``auth=true``. - ``test_runtime_check_treats_missing_auth_key_as_authenticated`` — raw dict without an ``auth`` key now requires auth (the previously-unauthenticated forwarder). - ``test_runtime_check_explicit_auth_false_still_skips_validation`` — explicit opt-in still works. Closes GHSA-7h34-mmrh-6g58. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/_types.py | 4 +- litellm/proxy/auth/user_api_key_auth.py | 7 +- .../pass_through_endpoints.py | 14 +- .../test_passthrough_auth_default.py | 136 ++++++++++++++++++ 4 files changed, 152 insertions(+), 9 deletions(-) create mode 100644 tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_auth_default.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 92c920ca59..1b401308c7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2154,8 +2154,8 @@ class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase): description="The USD cost per request to the target endpoint. This is used to calculate the cost of the request to the target endpoint.", ) auth: bool = Field( - default=False, - description="Whether authentication is required for the pass-through endpoint. If True, requests to the endpoint will require a valid LiteLLM API key.", + default=True, + description="Whether authentication is required for the pass-through endpoint. Defaults to True so a pass-through silently created without an explicit value still requires a valid LiteLLM API key — set to False only if the endpoint is meant to be a public forwarder (e.g. an unauthenticated webhook target).", ) guardrails: Optional[PassThroughGuardrailsConfig] = Field( default=None, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index b8db3cd2a7..f99db3aad7 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -472,7 +472,12 @@ async def check_api_key_for_custom_headers_or_pass_through_endpoints( for endpoint in pass_through_endpoints: if isinstance(endpoint, dict) and endpoint.get("path", "") == route: ## IF AUTH DISABLED - if endpoint.get("auth") is not True: + # Default to True: a config dict with no ``auth`` key + # otherwise produced an unauthenticated forwarder. The + # Pydantic ``PassThroughGenericEndpoint.auth`` default + # is also True, but raw config dicts skip that path — + # so this runtime check has to default to True too. + if endpoint.get("auth", True) is not True: return UserAPIKeyAuth() ## IF AUTH ENABLED ### IF CUSTOM PARSER REQUIRED diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 77eb3a5ee0..d55174b3dc 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -2325,12 +2325,14 @@ async def _register_pass_through_endpoint( dependencies = None if auth is not None and str(auth).lower() == "true": - if premium_user is not True: - raise ValueError( - "Error Setting Authentication on Pass Through Endpoint: {}".format( - CommonProxyErrors.not_premium_user.value - ) - ) + # Authentication on a pass-through endpoint used to be enterprise- + # only — which left the OSS tier with no safe configuration: the + # default was ``auth=False`` (unauthenticated forwarder) and the + # safe ``auth=True`` raised at startup unless the operator had a + # license. The default is now ``True`` (safe-by-default), and + # turning it on no longer requires a license: an unauthenticated + # forwarder is a deployment choice the operator should be allowed + # to make explicitly, but the safe option must always be free. dependencies = [Depends(user_api_key_auth)] if path not in LiteLLMRoutes.openai_routes.value: LiteLLMRoutes.openai_routes.value.append(path) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_auth_default.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_auth_default.py new file mode 100644 index 0000000000..4cac1cb4d3 --- /dev/null +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_auth_default.py @@ -0,0 +1,136 @@ +""" +Regression tests for the pass-through endpoint auth-default fix +(GHSA-7h34-mmrh-6g58). + +Two failures the fix closes: + +1. ``PassThroughGenericEndpoint.auth`` defaulted to ``False`` — an + admin who added a pass-through to ``general_settings`` without + explicitly setting ``auth: true`` shipped an unauthenticated + forwarder. +2. Setting ``auth: true`` was rejected at startup unless the operator + had a LiteLLM Enterprise license, leaving OSS deployments with no + safe configuration. + +The fix flips the default to ``True`` (safe-by-default) and removes +the enterprise gate so OSS operators can register an authenticated +pass-through. The runtime check in ``user_api_key_auth.py`` also now +defaults to ``True`` so a config dict (raw, not Pydantic) without an +``auth`` key still requires authentication. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI + +sys.path.insert(0, os.path.abspath("../../../..")) + +from litellm.proxy._types import PassThroughGenericEndpoint +from litellm.proxy.auth.user_api_key_auth import ( + check_api_key_for_custom_headers_or_pass_through_endpoints, +) +from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _register_pass_through_endpoint, +) + + +def test_passthrough_auth_defaults_to_true(): + # Regression: an admin who configures a pass-through without setting + # auth explicitly used to ship an unauthenticated forwarder. The + # default is now safe. + endpoint = PassThroughGenericEndpoint( + path="/canary-forwarder", + target="https://postman-echo.com/get", + ) + assert endpoint.auth is True + + +def test_passthrough_auth_can_still_be_explicitly_disabled(): + # Operators who genuinely need an unauthenticated forwarder (e.g. + # public webhook receiver) can opt in explicitly. + endpoint = PassThroughGenericEndpoint( + path="/public-webhook", + target="https://example.com/webhook", + auth=False, + ) + assert endpoint.auth is False + + +@pytest.mark.asyncio +async def test_register_passthrough_with_auth_true_works_for_oss(monkeypatch): + # Regression: setting ``auth: true`` used to raise at startup + # unless ``premium_user`` was True, leaving OSS with no safe + # configuration. + app = MagicMock(spec=FastAPI) + visited: set = set() + + endpoint = PassThroughGenericEndpoint( + path="/forwarder", + target="https://example.com", + auth=True, + ) + + # Should not raise; OSS premium_user=False is allowed to use auth=True. + await _register_pass_through_endpoint( + endpoint=endpoint, + app=app, + premium_user=False, + visited_endpoints=visited, + ) + + +@pytest.mark.asyncio +async def test_runtime_check_treats_missing_auth_key_as_authenticated(): + # The runtime dispatch in user_api_key_auth pulls + # pass_through_endpoints from general_settings as raw dicts (the + # Pydantic default never applies). A dict without an ``auth`` key + # must default to "authenticated" — without this, the previous + # behaviour (``endpoint.get("auth") is not True`` -> True -> empty + # auth) ships an unauthenticated forwarder. + request = MagicMock() + request.headers = {} + raw_endpoint_no_auth_key = { + "path": "/forwarder", + "target": "https://example.com", + # ``auth`` deliberately omitted + } + + result = await check_api_key_for_custom_headers_or_pass_through_endpoints( + request=request, + route="/forwarder", + pass_through_endpoints=[raw_endpoint_no_auth_key], + api_key="sk-1234", + ) + + # Result is the api_key string (auth is REQUIRED for this endpoint + # — flow continues to normal key validation), NOT an empty + # ``UserAPIKeyAuth()`` (which was the unauthenticated-forwarder + # bug). + assert result == "sk-1234" + + +@pytest.mark.asyncio +async def test_runtime_check_explicit_auth_false_still_skips_validation(): + # Operators who explicitly set ``auth: False`` get the legacy + # behaviour — an empty UserAPIKeyAuth, no key required. + from litellm.proxy._types import UserAPIKeyAuth + + request = MagicMock() + request.headers = {} + raw_endpoint_auth_false = { + "path": "/public-webhook", + "target": "https://example.com", + "auth": False, + } + + result = await check_api_key_for_custom_headers_or_pass_through_endpoints( + request=request, + route="/public-webhook", + pass_through_endpoints=[raw_endpoint_auth_false], + api_key="", + ) + + assert isinstance(result, UserAPIKeyAuth) From 7c4ef97239c20b66454593ad441cd676c7cf2d77 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:18:43 +0000 Subject: [PATCH 10/47] fix(passthrough): drop now-unused CommonProxyErrors top-level import The previous commit removed the only top-level use of ``CommonProxyErrors`` (the enterprise-gate ``raise ValueError``). Ruff F401 flagged the import as unused; the function-local import at line 2601 in a separate handler is the only remaining caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/pass_through_endpoints/pass_through_endpoints.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index d55174b3dc..714b5f3c7b 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -41,7 +41,6 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.passthrough import BasePassthroughUtils from litellm.proxy._types import ( - CommonProxyErrors, ConfigFieldInfo, ConfigFieldUpdate, LiteLLMRoutes, From 88d8a8076157c8d30beeedf40ff467d03ef993c8 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:13:25 -0700 Subject: [PATCH 11/47] tighten cli sso session flow --- litellm/constants.py | 1 + litellm/proxy/client/README.md | 33 +- litellm/proxy/client/cli/commands/auth.py | 54 ++- litellm/proxy/management_endpoints/ui_sso.py | 303 ++++++++++++++--- .../proxy/client/cli/test_auth_commands.py | 68 +++- .../proxy/management_endpoints/test_ui_sso.py | 313 ++++++++++++++---- 6 files changed, 605 insertions(+), 167 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index a0e99dd16b..e2b7c864d4 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1421,6 +1421,7 @@ LITELLM_PROXY_ADMIN_NAME = "default_user_id" LITELLM_CLI_SOURCE_IDENTIFIER = "litellm-cli" LITELLM_CLI_SESSION_TOKEN_PREFIX = "litellm-session-token" CLI_SSO_SESSION_CACHE_KEY_PREFIX = "cli_sso_session" +CLI_SSO_SESSION_TTL_SECONDS = 600 CLI_JWT_TOKEN_NAME = "cli-jwt-token" # Support both CLI_JWT_EXPIRATION_HOURS and LITELLM_CLI_JWT_EXPIRATION_HOURS for backwards compatibility CLI_JWT_EXPIRATION_HOURS = int( diff --git a/litellm/proxy/client/README.md b/litellm/proxy/client/README.md index 5dcc88cacb..adf562d69c 100644 --- a/litellm/proxy/client/README.md +++ b/litellm/proxy/client/README.md @@ -313,23 +313,24 @@ sequenceDiagram participant Proxy as LiteLLM Proxy participant SSO as SSO Provider - CLI->>CLI: Generate key ID (sk-uuid) - CLI->>Browser: Open /sso/key/generate?source=litellm-cli&key=sk-uuid + CLI->>Proxy: POST /sso/cli/start + Proxy->>CLI: Return login_id, poll_secret, user_code + CLI->>Browser: Open /sso/key/generate?source=litellm-cli&key=login_id - Browser->>Proxy: GET /sso/key/generate?source=litellm-cli&key=sk-uuid - Proxy->>Proxy: Set cli_state = litellm-session-token:sk-uuid - Proxy->>SSO: Redirect with state=litellm-session-token:sk-uuid + Browser->>Proxy: GET /sso/key/generate?source=litellm-cli&key=login_id + Proxy->>Proxy: Set cli_state = litellm-session-token:login_id + Proxy->>SSO: Redirect with state=litellm-session-token:login_id SSO->>Browser: Show login page Browser->>SSO: User authenticates - SSO->>Proxy: Redirect to /sso/callback?state=litellm-session-token:sk-uuid + SSO->>Proxy: Redirect to /sso/callback?state=litellm-session-token:login_id Proxy->>Proxy: Check if state starts with "litellm-session-token:" - Proxy->>Proxy: Generate API key with ID=sk-uuid - Proxy->>Browser: Show success page + Proxy->>Browser: Prompt for user_code + Browser->>Proxy: POST /sso/cli/complete/login_id - CLI->>Proxy: Poll /sso/cli/poll/sk-uuid - Proxy->>CLI: Return {"status": "ready", "key": "sk-uuid"} + CLI->>Proxy: Poll /sso/cli/poll/login_id with poll_secret header + Proxy->>CLI: Return {"status": "ready", "key": "jwt"} CLI->>CLI: Save key to ~/.litellm/token.json ``` @@ -343,13 +344,13 @@ The CLI provides three authentication commands: ### Authentication Flow Steps -1. **Generate Session ID**: CLI generates a unique key ID (`sk-{uuid}`) -2. **Open Browser**: CLI opens browser to `/sso/key/generate` with CLI source and key parameters -3. **SSO Redirect**: Proxy sets the formatted state (`litellm-session-token:sk-uuid`) as OAuth state parameter and redirects to SSO provider +1. **Start Session**: CLI creates a short-lived login session with `/sso/cli/start` +2. **Open Browser**: CLI opens browser to `/sso/key/generate` with CLI source and login ID parameters +3. **SSO Redirect**: Proxy sets the formatted state (`litellm-session-token:{login_id}`) as OAuth state parameter and redirects to SSO provider 4. **User Authentication**: User completes SSO authentication in browser 5. **Callback Processing**: SSO provider redirects back to proxy with state parameter -6. **Key Generation**: Proxy detects CLI login (state starts with "litellm-session-token:") and generates API key with pre-specified ID -7. **Polling**: CLI polls `/sso/cli/poll/{key_id}` endpoint until key is ready +6. **User Code Verification**: Browser confirms the verification code shown in the CLI +7. **Polling**: CLI polls `/sso/cli/poll/{login_id}` with the polling secret header until the JWT is ready 8. **Token Storage**: CLI saves the authentication token to `~/.litellm/token.json` ### Benefits of This Approach @@ -357,7 +358,7 @@ The CLI provides three authentication commands: - **No Local Server**: No need to run a local callback server - **Standard OAuth**: Uses OAuth 2.0 state parameter correctly - **Remote Compatible**: Works with remote proxy servers -- **Secure**: Uses UUID session identifiers +- **Secure**: Keeps the polling secret out of the browser handoff - **Simple Setup**: No additional OAuth redirect URL configuration needed ### Token Storage diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index aeb59e78a5..e9b370e4c0 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -5,6 +5,7 @@ import time import webbrowser from pathlib import Path from typing import Any, Dict, List, Optional +from urllib.parse import urlencode import click import requests @@ -241,7 +242,7 @@ def prompt_team_selection(teams: List[Dict[str, Any]]) -> Optional[Dict[str, Any def prompt_team_selection_fallback( - teams: List[Dict[str, Any]] + teams: List[Dict[str, Any]], ) -> Optional[Dict[str, Any]]: """Fallback team selection for non-interactive environments""" if not teams: @@ -279,6 +280,7 @@ def prompt_team_selection_fallback( def _poll_for_ready_data( url: str, *, + headers: Optional[Dict[str, str]] = None, total_timeout: int = 300, poll_interval: int = 2, request_timeout: int = 10, @@ -291,7 +293,7 @@ def _poll_for_ready_data( ) -> Optional[Dict[str, Any]]: for attempt in range(total_timeout // poll_interval): try: - response = requests.get(url, timeout=request_timeout) + response = requests.get(url, headers=headers, timeout=request_timeout) if response.status_code == 200: data = response.json() status = data.get("status") @@ -346,7 +348,23 @@ def _normalize_teams(teams, team_details): return [] -def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]: +def _start_cli_sso_flow(base_url: str) -> Dict[str, Any]: + response = requests.post(f"{base_url}/sso/cli/start", timeout=10) + response.raise_for_status() + data = response.json() + required_fields = ("login_id", "poll_secret", "user_code") + if not all(isinstance(data.get(field), str) for field in required_fields): + raise ValueError("Invalid CLI SSO start response") + return data + + +def _get_cli_sso_poll_headers(poll_secret: str) -> Dict[str, str]: + return {"x-litellm-cli-poll-secret": poll_secret} + + +def _poll_for_authentication( + base_url: str, key_id: str, poll_secret: str +) -> Optional[dict]: """ Poll the server for authentication completion and handle team selection. @@ -356,6 +374,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]: poll_url = f"{base_url}/sso/cli/poll/{key_id}" data = _poll_for_ready_data( poll_url, + headers=_get_cli_sso_poll_headers(poll_secret), pending_message="Still waiting for authentication...", ) if not data: @@ -373,6 +392,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]: jwt_with_team = _handle_team_selection_during_polling( base_url=base_url, key_id=key_id, + poll_secret=poll_secret, teams=normalized_teams, ) @@ -410,7 +430,7 @@ def _poll_for_authentication(base_url: str, key_id: str) -> Optional[dict]: def _handle_team_selection_during_polling( - base_url: str, key_id: str, teams: List[Dict[str, Any]] + base_url: str, key_id: str, poll_secret: str, teams: List[Dict[str, Any]] ) -> Optional[str]: """ Handle team selection and re-poll with selected team_id. @@ -441,6 +461,7 @@ def _handle_team_selection_during_polling( poll_url = f"{base_url}/sso/cli/poll/{key_id}?team_id={team_id}" data = _poll_for_ready_data( poll_url, + headers=_get_cli_sso_poll_headers(poll_secret), pending_message="Still waiting for team authentication...", other_status_message="Waiting for team authentication to complete...", http_error_log_every=10, @@ -514,29 +535,24 @@ def _render_and_prompt_for_team_selection(teams: List[Dict[str, Any]]) -> Option @click.pass_context def login(ctx: click.Context): """Login to LiteLLM proxy using SSO authentication""" - from litellm._uuid import uuid from litellm.constants import LITELLM_CLI_SOURCE_IDENTIFIER from litellm.proxy.client.cli.interface import show_commands base_url = ctx.obj["base_url"] - # Check if we have an existing key to regenerate - existing_key = get_stored_api_key() - - # Generate unique key ID for this login session - key_id = f"sk-{str(uuid.uuid4())}" - try: - # Construct SSO login URL with CLI source and pre-generated key - sso_url = f"{base_url}/sso/key/generate?source={LITELLM_CLI_SOURCE_IDENTIFIER}&key={key_id}" + cli_sso_flow = _start_cli_sso_flow(base_url=base_url) + key_id = cli_sso_flow["login_id"] + poll_secret = cli_sso_flow["poll_secret"] + user_code = cli_sso_flow["user_code"] - # If we have an existing key, include it as a parameter to the login endpoint - # The server will encode it in the OAuth state parameter for the SSO flow - if existing_key: - sso_url += f"&existing_key={existing_key}" + sso_url = f"{base_url}/sso/key/generate?" + urlencode( + {"source": LITELLM_CLI_SOURCE_IDENTIFIER, "key": key_id} + ) click.echo(f"Opening browser to: {sso_url}") click.echo("Please complete the SSO authentication in your browser...") + click.echo(f"Verification code: {user_code}") click.echo(f"Session ID: {key_id}") # Open browser @@ -545,7 +561,9 @@ def login(ctx: click.Context): # Poll for authentication completion click.echo("Waiting for authentication...") - auth_result = _poll_for_authentication(base_url=base_url, key_id=key_id) + auth_result = _poll_for_authentication( + base_url=base_url, key_id=key_id, poll_secret=poll_secret + ) if auth_result: api_key = auth_result["api_key"] diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 46e7963da7..5485d618d5 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -14,6 +14,7 @@ import hashlib import inspect import os import secrets +from html import escape from copy import deepcopy from typing import ( TYPE_CHECKING, @@ -27,13 +28,13 @@ from typing import ( Union, cast, ) -from urllib.parse import urlencode, urlparse +from urllib.parse import parse_qs, urlencode, urlparse if TYPE_CHECKING: import httpx import jwt -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from fastapi.responses import RedirectResponse import litellm @@ -41,6 +42,9 @@ from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid from litellm.caching import DualCache from litellm.constants import ( + CLI_SSO_SESSION_CACHE_KEY_PREFIX, + CLI_SSO_SESSION_TTL_SECONDS, + LITELLM_CLI_SOURCE_IDENTIFIER, LITELLM_UI_SESSION_DURATION, MAX_SPENDLOG_ROWS_TO_QUERY, MICROSOFT_USER_DISPLAY_NAME_ATTRIBUTE, @@ -123,6 +127,207 @@ router = APIRouter() # Metadata fields (token_type, expires_in, scope) are intentionally kept so # response convertors see the same fields in the PKCE path as in the non-PKCE path. _OAUTH_TOKEN_FIELDS = frozenset({"access_token", "id_token", "refresh_token"}) +_CLI_SSO_FLOW_CACHE_KEY_PREFIX = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:flow" +_CLI_SSO_USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + + +def _hash_cli_sso_secret(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def _normalize_cli_sso_user_code(user_code: str) -> str: + return "".join(ch for ch in user_code.upper() if ch.isalnum()) + + +def _generate_cli_sso_user_code() -> str: + user_code = "".join(secrets.choice(_CLI_SSO_USER_CODE_ALPHABET) for _ in range(8)) + return f"{user_code[:4]}-{user_code[4:]}" + + +def _get_cli_sso_flow_cache_key(login_id: str) -> str: + return f"{_CLI_SSO_FLOW_CACHE_KEY_PREFIX}:{login_id}" + + +def _is_valid_cli_sso_login_id(login_id: Optional[str]) -> bool: + return ( + isinstance(login_id, str) + and login_id.startswith("cli-") + and 16 <= len(login_id) <= 128 + ) + + +def _get_cli_sso_flow_or_raise(login_id: Optional[str], cache: DualCache) -> dict: + if not _is_valid_cli_sso_login_id(login_id): + raise HTTPException(status_code=400, detail="Invalid CLI login session") + + cache_key = _get_cli_sso_flow_cache_key(cast(str, login_id)) + flow = cache.get_cache(key=cache_key) + if not isinstance(flow, dict) or "poll_secret_hash" not in flow: + raise HTTPException(status_code=400, detail="Invalid CLI login session") + return flow + + +def _set_cli_sso_flow(login_id: str, cache: DualCache, flow: dict) -> None: + cache.set_cache( + key=_get_cli_sso_flow_cache_key(login_id), + value=flow, + ttl=CLI_SSO_SESSION_TTL_SECONDS, + ) + + +def _verify_cli_sso_poll_secret(flow: dict, poll_secret: Optional[str]) -> bool: + expected_poll_secret_hash = flow.get("poll_secret_hash") + if not isinstance(expected_poll_secret_hash, str) or not isinstance( + poll_secret, str + ): + return False + supplied_poll_secret_hash = _hash_cli_sso_secret(poll_secret) + return secrets.compare_digest(supplied_poll_secret_hash, expected_poll_secret_hash) + + +def _render_cli_sso_verification_page( + verify_url: str, browser_complete_token: str +) -> str: + escaped_verify_url = escape(verify_url, quote=True) + escaped_browser_complete_token = escape(browser_complete_token, quote=True) + return f""" + + + + LiteLLM CLI Login + + + +
+

Complete CLI Login

+

Enter the verification code shown in your terminal to finish this login.

+
+ + + + +
+
+ + + """ + + +@router.post("/sso/cli/start", tags=["experimental"], include_in_schema=False) +async def cli_sso_start(): + from litellm.proxy.proxy_server import user_api_key_cache + + login_id = f"cli-{secrets.token_urlsafe(24)}" + poll_secret = secrets.token_urlsafe(32) + user_code = _generate_cli_sso_user_code() + + flow = { + "poll_secret_hash": _hash_cli_sso_secret(poll_secret), + "user_code_hash": _hash_cli_sso_secret(_normalize_cli_sso_user_code(user_code)), + "sso_complete": False, + "user_code_verified": False, + "session_data": None, + } + _set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow) + + return { + "login_id": login_id, + "poll_secret": poll_secret, + "user_code": user_code, + "expires_in": CLI_SSO_SESSION_TTL_SECONDS, + } + + +@router.post( + "/sso/cli/complete/{login_id}", tags=["experimental"], include_in_schema=False +) +async def cli_sso_complete(request: Request, login_id: str): + from fastapi.responses import HTMLResponse + + from litellm.proxy.common_utils.html_forms.cli_sso_success import ( + render_cli_sso_success_page, + ) + from litellm.proxy.proxy_server import user_api_key_cache + + flow = _get_cli_sso_flow_or_raise(login_id=login_id, cache=user_api_key_cache) + body = (await request.body()).decode("utf-8") + form_values = parse_qs(body) + supplied_user_code = (form_values.get("user_code") or [""])[0] + supplied_browser_complete_token = ( + form_values.get("browser_complete_token") or [""] + )[0] + supplied_user_code_hash = _hash_cli_sso_secret( + _normalize_cli_sso_user_code(supplied_user_code) + ) + supplied_browser_complete_token_hash = _hash_cli_sso_secret( + supplied_browser_complete_token + ) + + expected_user_code_hash = flow.get("user_code_hash") + if not isinstance(expected_user_code_hash, str) or not secrets.compare_digest( + supplied_user_code_hash, expected_user_code_hash + ): + raise HTTPException(status_code=400, detail="Invalid verification code") + + expected_browser_complete_token_hash = flow.get("browser_complete_token_hash") + if not isinstance( + expected_browser_complete_token_hash, str + ) or not secrets.compare_digest( + supplied_browser_complete_token_hash, expected_browser_complete_token_hash + ): + raise HTTPException(status_code=400, detail="Invalid verification code") + + if not flow.get("sso_complete") or not flow.get("session_data"): + raise HTTPException(status_code=400, detail="CLI login is not ready") + + flow["user_code_verified"] = True + _set_cli_sso_flow(login_id=login_id, cache=user_api_key_cache, flow=flow) + + html_content = render_cli_sso_success_page() + return HTMLResponse(content=html_content, status_code=200) def normalize_email(email: Optional[str]) -> Optional[str]: @@ -333,6 +538,7 @@ async def google_login( from litellm.proxy.proxy_server import ( premium_user, prisma_client, + user_api_key_cache, user_custom_ui_sso_sign_in_handler, ) @@ -382,14 +588,15 @@ async def google_login( redirect_url = SSOAuthenticationHandler.get_redirect_url_for_sso( request=request, sso_callback_route="sso/callback", - existing_key=existing_key, ) - # Store CLI key in state for OAuth flow + if source == LITELLM_CLI_SOURCE_IDENTIFIER: + _get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache) + + # Store CLI login handle in state for OAuth flow cli_state: Optional[str] = SSOAuthenticationHandler._get_cli_state( source=source, key=key, - existing_key=existing_key, ) # check if user defined a custom auth sso sign in handler, if yes, use it @@ -1392,18 +1599,12 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: ) if state and state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"): - # Extract the key ID and existing_key from the state - # State format: {PREFIX}:{key}:{existing_key} or {PREFIX}:{key} - state_parts = state.split(":", 2) # Split into max 3 parts + # State format: {PREFIX}:{login_id} + state_parts = state.split(":", 1) key_id = state_parts[1] if len(state_parts) > 1 else None - existing_key = state_parts[2] if len(state_parts) > 2 else None - verbose_proxy_logger.info( - f"CLI SSO callback detected for key: {key_id}, existing_key: {existing_key}" - ) - return await cli_sso_callback( - request=request, key=key_id, existing_key=existing_key, result=result - ) + verbose_proxy_logger.info("CLI SSO callback detected") + return await cli_sso_callback(request=request, key=key_id, result=result) # Control-plane cross-origin: read return_to from cookie. # Starlette's cookie_parser already handles RFC 2109 unquoting. @@ -1424,13 +1625,10 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa: async def cli_sso_callback( request: Request, key: Optional[str] = None, - existing_key: Optional[str] = None, result: Optional[Union[OpenID, dict]] = None, ): """CLI SSO callback - stores session info for JWT generation on polling""" - verbose_proxy_logger.info( - f"CLI SSO callback for key: {key}, existing_key: {existing_key}" - ) + verbose_proxy_logger.info("CLI SSO callback") from litellm.proxy.proxy_server import ( prisma_client, @@ -1438,11 +1636,7 @@ async def cli_sso_callback( user_api_key_cache, ) - if not key or not key.startswith("sk-"): - raise HTTPException( - status_code=400, - detail="Invalid key parameter. Must be a valid key ID starting with 'sk-'", - ) + flow = _get_cli_sso_flow_or_raise(login_id=key, cache=user_api_key_cache) if prisma_client is None: raise HTTPException( @@ -1480,9 +1674,6 @@ async def cli_sso_callback( status_code=500, detail="Failed to retrieve user information from SSO" ) - # Store session info in cache (10 min TTL) - from litellm.constants import CLI_SSO_SESSION_CACHE_KEY_PREFIX - # Get all teams from user_info - CLI will let user select which one teams: List[str] = [] if hasattr(user_info, "teams") and user_info.teams: @@ -1523,21 +1714,25 @@ async def cli_sso_callback( "team_details": team_details, } - cache_key = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:{key}" - user_api_key_cache.set_cache(key=cache_key, value=session_data, ttl=600) + flow["session_data"] = session_data + flow["sso_complete"] = True + browser_complete_token = secrets.token_urlsafe(32) + flow["browser_complete_token_hash"] = _hash_cli_sso_secret( + browser_complete_token + ) + _set_cli_sso_flow(login_id=cast(str, key), cache=user_api_key_cache, flow=flow) verbose_proxy_logger.info( f"Stored CLI SSO session for user: {user_info.user_id}, teams: {teams}, num_teams: {len(teams)}" ) - # Return success page from fastapi.responses import HTMLResponse - from litellm.proxy.common_utils.html_forms.cli_sso_success import ( - render_cli_sso_success_page, + verify_url = str(request.url_for("cli_sso_complete", login_id=key)) + html_content = _render_cli_sso_verification_page( + verify_url=verify_url, + browser_complete_token=browser_complete_token, ) - - html_content = render_cli_sso_success_page() return HTMLResponse(content=html_content, status_code=200) except Exception as e: @@ -1548,7 +1743,11 @@ async def cli_sso_callback( @router.get("/sso/cli/poll/{key_id}", tags=["experimental"], include_in_schema=False) -async def cli_poll_key(key_id: str, team_id: Optional[str] = None): +async def cli_poll_key( + key_id: str, + team_id: Optional[str] = None, + x_litellm_cli_poll_secret: Optional[str] = Header(default=None), +): """ CLI polling endpoint - retrieves session from cache and generates JWT. @@ -1557,22 +1756,25 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None): 2. Second poll (with team_id): Generates JWT with selected team and deletes session Args: - key_id: The session key ID + key_id: The CLI login session ID team_id: Optional team ID to assign to the JWT. If provided, must be one of user's teams. """ - from litellm.constants import CLI_SSO_SESSION_CACHE_KEY_PREFIX from litellm.proxy.auth.auth_checks import ExperimentalUIJWTToken from litellm.proxy.proxy_server import user_api_key_cache - if not key_id.startswith("sk-"): - raise HTTPException(status_code=400, detail="Invalid key ID format") - try: - # Look up session in cache - cache_key = f"{CLI_SSO_SESSION_CACHE_KEY_PREFIX}:{key_id}" - session_data = user_api_key_cache.get_cache(key=cache_key) + flow = _get_cli_sso_flow_or_raise(login_id=key_id, cache=user_api_key_cache) + if not _verify_cli_sso_poll_secret( + flow=flow, poll_secret=x_litellm_cli_poll_secret + ): + raise HTTPException(status_code=403, detail="Invalid CLI polling secret") - if session_data: + if not flow.get("sso_complete") or not flow.get("user_code_verified"): + return {"status": "pending"} + + session_data = flow.get("session_data") + + if isinstance(session_data, dict): user_teams = session_data.get("teams", []) user_team_details = session_data.get("team_details") user_id = session_data["user_id"] @@ -1632,7 +1834,7 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None): ) # Delete cache entry (single-use) - user_api_key_cache.delete_cache(key=cache_key) + user_api_key_cache.delete_cache(key=_get_cli_sso_flow_cache_key(key_id)) verbose_proxy_logger.info( f"CLI JWT generated for user: {user_id}, team: {team_id}" @@ -1650,6 +1852,8 @@ async def cli_poll_key(key_id: str, team_id: Optional[str] = None): else: return {"status": "pending"} + except HTTPException: + raise except Exception as e: verbose_proxy_logger.error(f"Error polling for CLI JWT: {e}") raise HTTPException( @@ -2393,20 +2597,15 @@ class SSOAuthenticationHandler: This is used to authenticate through the CLI login flow. - The state parameter format is: {PREFIX}:{key}:{existing_key} - - If existing_key is provided, it's included in the state + The state parameter format is: {PREFIX}:{login_id} - The state parameter is used to pass data through the OAuth flow without changing the callback URL """ from litellm.constants import ( LITELLM_CLI_SESSION_TOKEN_PREFIX, - LITELLM_CLI_SOURCE_IDENTIFIER, ) if source == LITELLM_CLI_SOURCE_IDENTIFIER and key: - if existing_key: - return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}:{existing_key}" - else: - return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}" + return f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:{key}" else: return None diff --git a/tests/test_litellm/proxy/client/cli/test_auth_commands.py b/tests/test_litellm/proxy/client/cli/test_auth_commands.py index 45d55a8d06..f7cb4d72d9 100644 --- a/tests/test_litellm/proxy/client/cli/test_auth_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_auth_commands.py @@ -1,17 +1,15 @@ import json import os import sys -import tempfile import time from pathlib import Path -from unittest.mock import MagicMock, Mock, mock_open, patch +from unittest.mock import Mock, mock_open, patch sys.path.insert( 0, os.path.abspath("../../..") ) # Adds the parent directory to the system path -import pytest from click.testing import CliRunner from litellm.proxy.client.cli.commands.auth import ( @@ -26,6 +24,22 @@ from litellm.proxy.client.cli.commands.auth import ( ) +def _mock_cli_sso_start_response( + login_id: str = "cli-session-uuid-456", + poll_secret: str = "poll-secret", + user_code: str = "ABCD-EFGH", +) -> Mock: + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "login_id": login_id, + "poll_secret": poll_secret, + "user_code": user_code, + } + mock_response.raise_for_status = Mock() + return mock_response + + class TestTokenUtilities: """Test token file utility functions""" @@ -243,12 +257,15 @@ class TestLoginCommand: with ( patch("webbrowser.open") as mock_browser, + patch( + "requests.post", + return_value=_mock_cli_sso_start_response(login_id="cli-test-uuid-123"), + ) as mock_post, patch("requests.get", return_value=mock_response) as mock_get, patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save, patch( "litellm.proxy.client.cli.interface.show_commands" ) as mock_show_commands, - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -261,7 +278,13 @@ class TestLoginCommand: mock_browser.assert_called_once() call_args = mock_browser.call_args[0][0] assert "https://test.example.com/sso/key/generate" in call_args - assert "sk-test-uuid-123" in call_args + assert "cli-test-uuid-123" in call_args + assert "Verification code: ABCD-EFGH" in result.output + mock_post.assert_called_once() + mock_get.assert_called() + assert mock_get.call_args.kwargs["headers"] == { + "x-litellm-cli-poll-secret": "poll-secret" + } # Verify JWT was saved mock_save.assert_called_once() @@ -284,9 +307,9 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch("requests.get", return_value=mock_response), - patch("time.sleep") as mock_sleep, - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), + patch("time.sleep"), ): # Mock time.sleep to avoid actual delays in tests @@ -306,9 +329,9 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch("requests.get", return_value=mock_response), patch("time.sleep"), - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -325,12 +348,12 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch( "requests.get", side_effect=requests.RequestException("Connection failed"), ), patch("time.sleep"), - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -345,8 +368,8 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch("requests.get", side_effect=KeyboardInterrupt), - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -369,9 +392,9 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch("requests.get", return_value=mock_response), patch("time.sleep"), - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -386,8 +409,8 @@ class TestLoginCommand: with ( patch("webbrowser.open"), + patch("requests.post", return_value=_mock_cli_sso_start_response()), patch("requests.get", side_effect=ValueError("Invalid value")), - patch("litellm._uuid.uuid.uuid4", return_value="test-uuid-123"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -556,6 +579,12 @@ class TestCLIKeyRegenerationFlow: # Simulate user selecting team #2 (team-beta) with ( patch("webbrowser.open") as mock_browser, + patch( + "requests.post", + return_value=_mock_cli_sso_start_response( + login_id="cli-session-uuid-456" + ), + ), patch( "requests.get", side_effect=[mock_first_response, mock_second_response] ) as mock_get, @@ -563,7 +592,6 @@ class TestCLIKeyRegenerationFlow: patch( "litellm.proxy.client.cli.interface.show_commands" ) as mock_show_commands, - patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-456"), patch("click.prompt", return_value="2"), ): # User selects index 2 @@ -585,8 +613,11 @@ class TestCLIKeyRegenerationFlow: # First poll should be without team_id first_poll_url = mock_get.call_args_list[0][0][0] - assert "sk-session-uuid-456" in first_poll_url + assert "cli-session-uuid-456" in first_poll_url assert "team_id=" not in first_poll_url + assert mock_get.call_args_list[0].kwargs["headers"] == { + "x-litellm-cli-poll-secret": "poll-secret" + } # Second poll should include team_id=team-beta second_poll_url = mock_get.call_args_list[1][0][0] @@ -621,10 +652,15 @@ class TestCLIKeyRegenerationFlow: with ( patch("webbrowser.open") as mock_browser, + patch( + "requests.post", + return_value=_mock_cli_sso_start_response( + login_id="cli-session-uuid-solo" + ), + ), patch("requests.get", return_value=mock_response), patch("litellm.proxy.client.cli.commands.auth.save_token") as mock_save, patch("litellm.proxy.client.cli.interface.show_commands"), - patch("litellm._uuid.uuid.uuid4", return_value="session-uuid-solo"), ): result = self.runner.invoke(login, obj=mock_context.obj) @@ -637,7 +673,7 @@ class TestCLIKeyRegenerationFlow: call_args = mock_browser.call_args[0][0] assert "https://test.example.com/sso/key/generate" in call_args assert "source=litellm-cli" in call_args - assert "key=sk-session-uuid-solo" in call_args + assert "key=cli-session-uuid-solo" in call_args # Verify JWT was saved mock_save.assert_called_once() diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index eecfcaa035..b4c843d0b8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -4,7 +4,6 @@ import os import sys from unittest.mock import AsyncMock, MagicMock, patch -import httpx import pytest from fastapi import HTTPException, Request @@ -25,7 +24,6 @@ from litellm.proxy.management_endpoints.ui_sso import ( SSOAuthenticationHandler, _setup_team_mappings, _sync_user_role_from_jwt_role_map, - determine_role_from_groups, normalize_email, process_sso_jwt_access_token, ) @@ -1471,13 +1469,13 @@ class TestAuthCallbackRouting: from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX # Test CLI state detection logic - cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123" + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-test1234567890" # This mimics the logic in auth_callback if cli_state and cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:"): - # Extract the key ID from the state + # Extract the login ID from the state key_id = cli_state.split(":", 1)[1] - assert key_id == "sk-test123" + assert key_id == "cli-test1234567890" else: assert False, "CLI state should have been detected" @@ -1510,13 +1508,13 @@ class TestGoogleLoginCLIIntegration: # Test the CLI state generation logic used in google_login source = "litellm-cli" - key = "sk-test123" + key = "cli-test1234567890" cli_state = SSOAuthenticationHandler._get_cli_state(source=source, key=key) assert cli_state is not None assert cli_state.startswith("litellm-session-token:") - assert "sk-test123" in cli_state + assert "cli-test1234567890" in cli_state def test_google_login_no_cli_state_when_missing_params(self): """Test that google_login doesn't generate CLI state when CLI parameters are missing""" @@ -1526,8 +1524,8 @@ class TestGoogleLoginCLIIntegration: test_cases = [ (None, None), ("litellm-cli", None), - (None, "sk-test123"), - ("wrong-source", "sk-test123"), + (None, "cli-test1234567890"), + ("wrong-source", "cli-test1234567890"), ] for source, key in test_cases: @@ -1634,19 +1632,19 @@ class TestSSOStateHandling: from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler state = SSOAuthenticationHandler._get_cli_state( - source="litellm-cli", key="sk-test123" + source="litellm-cli", key="cli-test1234567890" ) assert state is not None assert state.startswith("litellm-session-token:") - assert "sk-test123" in state + assert "cli-test1234567890" in state def test_get_cli_state_invalid_source(self): """Test generating CLI state with invalid source""" from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler state = SSOAuthenticationHandler._get_cli_state( - source="invalid_source", key="sk-test123" + source="invalid_source", key="cli-test1234567890" ) assert state is None @@ -1663,40 +1661,40 @@ class TestSSOStateHandling: """Test generating CLI state without source""" from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler - state = SSOAuthenticationHandler._get_cli_state(source=None, key="sk-test123") + state = SSOAuthenticationHandler._get_cli_state( + source=None, key="cli-test1234567890" + ) assert state is None - def test_get_cli_state_with_existing_key(self): - """Test generating CLI state with existing_key embedded in state parameter""" + def test_get_cli_state_ignores_existing_key(self): + """Test CLI state does not embed an existing key""" from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler state = SSOAuthenticationHandler._get_cli_state( source="litellm-cli", - key="sk-new-key-123", + key="cli-new-key-1234567890", existing_key="sk-existing-key-456", ) assert state is not None assert state.startswith("litellm-session-token:") - assert "sk-new-key-123" in state - assert "sk-existing-key-456" in state - # Verify the format: {PREFIX}:{key}:{existing_key} - assert state == "litellm-session-token:sk-new-key-123:sk-existing-key-456" + assert "cli-new-key-1234567890" in state + assert "sk-existing-key-456" not in state + assert state == "litellm-session-token:cli-new-key-1234567890" def test_get_cli_state_without_existing_key(self): """Test generating CLI state without existing_key""" from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler state = SSOAuthenticationHandler._get_cli_state( - source="litellm-cli", key="sk-new-key-789", existing_key=None + source="litellm-cli", key="cli-new-key-789123456", existing_key=None ) assert state is not None assert state.startswith("litellm-session-token:") - assert "sk-new-key-789" in state - # Verify the format: {PREFIX}:{key} (no third part) - assert state == "litellm-session-token:sk-new-key-789" + assert "cli-new-key-789123456" in state + assert state == "litellm-session-token:cli-new-key-789123456" assert state.count(":") == 1 # Only one colon separator @@ -1708,44 +1706,37 @@ class TestStateRouting: from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX # Test CLI state format - cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-test123" + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-test1234567890" assert cli_state.startswith(f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:") # Test extraction of key from state key_id = cli_state.split(":", 1)[1] - assert key_id == "sk-test123" + assert key_id == "cli-test1234567890" - def test_cli_state_parsing_with_existing_key(self): - """Test parsing CLI state with existing_key embedded""" + def test_cli_state_parsing_uses_single_login_id(self): + """Test parsing CLI state with a single login ID""" from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX - # State format: {PREFIX}:{key}:{existing_key} - cli_state = ( - f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-key-456:sk-existing-key-789" - ) + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-key-456123" # Parse as done in auth_callback - state_parts = cli_state.split(":", 2) # Split into max 3 parts + state_parts = cli_state.split(":", 1) key_id = state_parts[1] if len(state_parts) > 1 else None - existing_key = state_parts[2] if len(state_parts) > 2 else None - assert key_id == "sk-new-key-456" - assert existing_key == "sk-existing-key-789" + assert key_id == "cli-new-key-456123" - def test_cli_state_parsing_without_existing_key(self): - """Test parsing CLI state without existing_key""" + def test_cli_state_parsing_without_extra_segments(self): + """Test parsing CLI state uses a single login ID""" from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX # State format: {PREFIX}:{key} - cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-key-999" + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-key-999123" # Parse as done in auth_callback - state_parts = cli_state.split(":", 2) # Split into max 3 parts + state_parts = cli_state.split(":", 1) key_id = state_parts[1] if len(state_parts) > 1 else None - existing_key = state_parts[2] if len(state_parts) > 2 else None - assert key_id == "sk-new-key-999" - assert existing_key is None + assert key_id == "cli-new-key-999123" def test_non_cli_state_detection(self): """Test detection of non-CLI state parameters""" @@ -2007,6 +1998,107 @@ class TestCustomUISSO: class TestCLIKeyRegenerationFlow: """Test the end-to-end CLI key regeneration flow""" + @pytest.mark.asyncio + async def test_cli_sso_start_creates_bound_flow(self): + """Test CLI SSO start creates a polling secret bound flow""" + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + _normalize_cli_sso_user_code, + cli_sso_start, + ) + + mock_cache = MagicMock() + + with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache): + result = await cli_sso_start() + + assert result["login_id"].startswith("cli-") + assert result["poll_secret"] + assert result["user_code"] + + mock_cache.set_cache.assert_called_once() + flow_data = mock_cache.set_cache.call_args.kwargs["value"] + assert flow_data["poll_secret_hash"] == _hash_cli_sso_secret( + result["poll_secret"] + ) + assert flow_data["user_code_hash"] == _hash_cli_sso_secret( + _normalize_cli_sso_user_code(result["user_code"]) + ) + assert flow_data["poll_secret_hash"] != result["poll_secret"] + assert flow_data["user_code_hash"] != result["user_code"] + + @pytest.mark.asyncio + async def test_cli_sso_complete_verifies_user_code(self): + """Test CLI SSO complete marks a session as verified""" + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + _normalize_cli_sso_user_code, + cli_sso_complete, + ) + + mock_request = MagicMock(spec=Request) + mock_request.body = AsyncMock( + return_value=b"user_code=ABCD-EFGH&browser_complete_token=browser-token" + ) + mock_cache = MagicMock() + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "user_code_hash": _hash_cli_sso_secret( + _normalize_cli_sso_user_code("ABCD-EFGH") + ), + "browser_complete_token_hash": _hash_cli_sso_secret("browser-token"), + "sso_complete": True, + "user_code_verified": False, + "session_data": {"user_id": "test-user-123"}, + } + + with ( + patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), + patch( + "litellm.proxy.common_utils.html_forms.cli_sso_success.render_cli_sso_success_page", + return_value="Success", + ), + ): + result = await cli_sso_complete( + request=mock_request, login_id="cli-session-4567890" + ) + + assert result.status_code == 200 + flow_data = mock_cache.set_cache.call_args.kwargs["value"] + assert flow_data["user_code_verified"] is True + + @pytest.mark.asyncio + async def test_cli_sso_complete_requires_callback_token(self): + """Test CLI SSO complete requires the callback-delivered token""" + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + _normalize_cli_sso_user_code, + cli_sso_complete, + ) + + mock_request = MagicMock(spec=Request) + mock_request.body = AsyncMock(return_value=b"user_code=ABCD-EFGH") + mock_cache = MagicMock() + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "user_code_hash": _hash_cli_sso_secret( + _normalize_cli_sso_user_code("ABCD-EFGH") + ), + "browser_complete_token_hash": _hash_cli_sso_secret("browser-token"), + "sso_complete": True, + "user_code_verified": False, + "session_data": {"user_id": "test-user-123"}, + } + + with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache): + with pytest.raises(HTTPException) as exc_info: + await cli_sso_complete( + request=mock_request, login_id="cli-session-4567890" + ) + + assert exc_info.value.status_code == 400 + mock_cache.set_cache.assert_not_called() + @pytest.mark.asyncio async def test_cli_sso_callback_stores_session(self): """Test CLI SSO callback stores session data in cache for JWT generation""" @@ -2017,7 +2109,7 @@ class TestCLIKeyRegenerationFlow: mock_request = MagicMock(spec=Request) # Test data - session_key = "sk-session-456" + session_key = "cli-session-4567890" # Mock user info mock_user_info = LiteLLM_UserTable( @@ -2032,6 +2124,16 @@ class TestCLIKeyRegenerationFlow: # Mock cache mock_cache = MagicMock() + mock_cache.get_cache.return_value = { + "poll_secret_hash": "poll-secret-hash", + "user_code_hash": "user-code-hash", + "sso_complete": False, + "user_code_verified": False, + "session_data": None, + } + mock_request.url_for.return_value = ( + "https://test.example.com/sso/cli/complete/cli-session-4567890" + ) with ( patch( @@ -2049,7 +2151,6 @@ class TestCLIKeyRegenerationFlow: result = await cli_sso_callback( request=mock_request, key=session_key, - existing_key=None, result=mock_sso_result, ) @@ -2062,14 +2163,18 @@ class TestCLIKeyRegenerationFlow: assert session_key in call_args.kwargs["key"] # Verify session data structure - session_data = call_args.kwargs["value"] + flow_data = call_args.kwargs["value"] + session_data = flow_data["session_data"] + assert flow_data["sso_complete"] is True + assert flow_data["user_code_verified"] is False + assert isinstance(flow_data["browser_complete_token_hash"], str) assert session_data["user_id"] == "test-user-123" assert session_data["user_role"] == "internal_user" assert session_data["teams"] == ["team1", "team2"] assert session_data["models"] == ["gpt-4"] # Verify TTL - assert call_args.kwargs["ttl"] == 600 # 10 minutes + assert call_args.kwargs["ttl"] == 600 assert result.status_code == 200 # Verify response contains success message (response is HTML) @@ -2078,10 +2183,13 @@ class TestCLIKeyRegenerationFlow: @pytest.mark.asyncio async def test_cli_poll_key_returns_teams_for_selection(self): """Test CLI poll endpoint returns teams for user selection when multiple teams exist""" - from litellm.proxy.management_endpoints.ui_sso import cli_poll_key + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + cli_poll_key, + ) # Test data - session_key = "sk-session-789" + session_key = "cli-session-789123" session_data = { "user_id": "test-user-456", "user_role": "internal_user", @@ -2091,11 +2199,20 @@ class TestCLIKeyRegenerationFlow: # Mock cache mock_cache = MagicMock() - mock_cache.get_cache.return_value = session_data + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "sso_complete": True, + "user_code_verified": True, + "session_data": session_data, + } with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache): # Act - First poll without team_id - result = await cli_poll_key(key_id=session_key, team_id=None) + result = await cli_poll_key( + key_id=session_key, + team_id=None, + x_litellm_cli_poll_secret="poll-secret", + ) # Assert - should return teams list for selection assert result["status"] == "ready" @@ -2108,16 +2225,72 @@ class TestCLIKeyRegenerationFlow: mock_cache.delete_cache.assert_not_called() @pytest.mark.asyncio - async def test_auth_callback_routes_to_cli_with_existing_key(self): - """Test that auth_callback properly routes CLI requests and extracts existing_key from state parameter""" + async def test_cli_poll_key_requires_poll_secret(self): + """Test CLI poll endpoint rejects callers without the polling secret""" + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + cli_poll_key, + ) + + mock_cache = MagicMock() + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "sso_complete": True, + "user_code_verified": True, + "session_data": { + "user_id": "test-user-456", + "user_role": "internal_user", + "teams": [], + "models": ["gpt-4"], + }, + } + + with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache): + with pytest.raises(HTTPException) as exc_info: + await cli_poll_key(key_id="cli-session-789123", team_id=None) + + assert exc_info.value.status_code == 403 + + @pytest.mark.asyncio + async def test_cli_poll_key_waits_for_user_code_verification(self): + """Test CLI poll endpoint stays pending until user code verification""" + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + cli_poll_key, + ) + + mock_cache = MagicMock() + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "sso_complete": True, + "user_code_verified": False, + "session_data": { + "user_id": "test-user-456", + "user_role": "internal_user", + "teams": [], + "models": ["gpt-4"], + }, + } + + with patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache): + result = await cli_poll_key( + key_id="cli-session-789123", + team_id=None, + x_litellm_cli_poll_secret="poll-secret", + ) + + assert result == {"status": "pending"} + + @pytest.mark.asyncio + async def test_auth_callback_routes_to_cli(self): + """Test that auth_callback properly routes CLI requests""" from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX from litellm.proxy.management_endpoints.ui_sso import auth_callback - # Mock request (no query params needed - existing_key is in state) + # Mock request mock_request = MagicMock(spec=Request) - # CLI state with existing_key embedded: {PREFIX}:{key}:{existing_key} - cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:sk-new-session-key-456:sk-existing-cli-key-123" + cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-session-key-456" # Mock the CLI callback and required proxy server components mock_result = {"user_id": "test-user", "email": "test@example.com"} @@ -2142,16 +2315,14 @@ class TestCLIKeyRegenerationFlow: # Act await auth_callback(request=mock_request, state=cli_state) - # Assert - existing_key should be extracted from state parameter mock_cli_callback.assert_called_once_with( request=mock_request, - key="sk-new-session-key-456", - existing_key="sk-existing-cli-key-123", + key="cli-new-session-key-456", result=mock_result, ) def test_get_redirect_url_does_not_include_existing_key_in_url(self): - """Test that redirect URL generation does NOT include existing_key in URL (uses state parameter instead)""" + """Test that redirect URL generation does NOT include existing_key in URL""" from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler # Mock request @@ -2194,10 +2365,13 @@ class TestCLIKeyRegenerationFlow: async def test_cli_poll_key_generates_jwt_with_team(self): """Test CLI poll endpoint generates JWT when team_id is provided""" from litellm.proxy._types import LiteLLM_UserTable - from litellm.proxy.management_endpoints.ui_sso import cli_poll_key + from litellm.proxy.management_endpoints.ui_sso import ( + _hash_cli_sso_secret, + cli_poll_key, + ) # Test data - session_key = "sk-session-999" + session_key = "cli-session-999123" selected_team = "team-b" session_data = { "user_id": "test-user-789", @@ -2217,7 +2391,12 @@ class TestCLIKeyRegenerationFlow: # Mock cache mock_cache = MagicMock() - mock_cache.get_cache.return_value = session_data + mock_cache.get_cache.return_value = { + "poll_secret_hash": _hash_cli_sso_secret("poll-secret"), + "sso_complete": True, + "user_code_verified": True, + "session_data": session_data, + } mock_jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token" @@ -2235,7 +2414,11 @@ class TestCLIKeyRegenerationFlow: ) # Act - Second poll with team_id - result = await cli_poll_key(key_id=session_key, team_id=selected_team) + result = await cli_poll_key( + key_id=session_key, + team_id=selected_team, + x_litellm_cli_poll_secret="poll-secret", + ) # Assert - should return JWT assert result["status"] == "ready" @@ -2901,7 +3084,7 @@ class TestGetGenericSSORedirectParams: from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler # Arrange - cli_state = "litellm-session-token:sk-test123" + cli_state = "litellm-session-token:cli-test1234567890" with patch.dict(os.environ, {"GENERIC_CLIENT_STATE": "env_state_value"}): # Act From 0f8dd28542051e9c10f1e716ee322c9ed14b2c95 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Sat, 25 Apr 2026 15:27:56 -0700 Subject: [PATCH 12/47] lazy-load optional feature routers on first request --- litellm/proxy/_lazy_features.py | 307 ++++++++++++++++++ litellm/proxy/proxy_server.py | 126 ++----- tests/proxy_unit_tests/test_proxy_routes.py | 14 + tests/test_litellm/proxy/test_proxy_server.py | 252 ++++++++++++++ .../test_vector_store_endpoints.py | 15 + 5 files changed, 609 insertions(+), 105 deletions(-) create mode 100644 litellm/proxy/_lazy_features.py diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py new file mode 100644 index 0000000000..450c9483f3 --- /dev/null +++ b/litellm/proxy/_lazy_features.py @@ -0,0 +1,307 @@ +""" +Lazy registration for optional feature routers. Each LAZY_FEATURES entry +imports its module only on the first request matching its path prefix, +saving ~700 MB at idle for deployments that don't use these features. +First hit pays the import cost (1-3 s for heavy modules); /openapi.json +omits each feature's routes until the feature is warmed. +""" + +import asyncio +import importlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Callable, Tuple + +from starlette.types import Receive, Scope, Send + +from litellm._logging import verbose_proxy_logger + +if TYPE_CHECKING: + from fastapi import FastAPI + + +def _include_router(attr_name: str = "router") -> Callable[["FastAPI", object], None]: + def _register(app: "FastAPI", module: object) -> None: + app.include_router(getattr(module, attr_name)) + + return _register + + +def _mount_app( + prefix: str, attr_name: str = "app" +) -> Callable[["FastAPI", object], None]: + def _register(app: "FastAPI", module: object) -> None: + app.mount(path=prefix, app=getattr(module, attr_name)) + + return _register + + +@dataclass(frozen=True) +class LazyFeature: + name: str + module_path: str + path_prefixes: Tuple[str, ...] + register_fn: Callable[["FastAPI", object], None] = field( + default_factory=lambda: _include_router("router") + ) + # For routes whose path has a leading parameter (e.g. /{server}/authorize) + # — startswith can't match those, so the matcher also checks endswith. + path_suffixes: Tuple[str, ...] = () + + +LAZY_FEATURES: Tuple[LazyFeature, ...] = ( + LazyFeature( + name="guardrails", + module_path="litellm.proxy.guardrails.guardrail_endpoints", + path_prefixes=( + "/guardrails", + "/v2/guardrails", + "/apply_guardrail", + "/policies/usage", + ), + ), + LazyFeature( + name="policies", + module_path="litellm.proxy.management_endpoints.policy_endpoints", + # Trailing slash to avoid matching /policies/... (policy_engine). + path_prefixes=("/policy/", "/utils/test_policies_and_guardrails"), + ), + LazyFeature( + name="policy_engine", + module_path="litellm.proxy.policy_engine.policy_endpoints", + path_prefixes=("/policies",), + ), + LazyFeature( + name="policy_resolve", + module_path="litellm.proxy.policy_engine.policy_resolve_endpoints", + path_prefixes=("/policies/resolve", "/policies/attachments/estimate-impact"), + ), + LazyFeature( + name="agents", + module_path="litellm.proxy.agent_endpoints.endpoints", + path_prefixes=("/v1/agents", "/agents", "/agent/"), + ), + LazyFeature( + name="a2a", + module_path="litellm.proxy.agent_endpoints.a2a_endpoints", + path_prefixes=("/a2a", "/v1/a2a"), + ), + LazyFeature( + name="vector_stores", + module_path="litellm.proxy.vector_store_endpoints.endpoints", + path_prefixes=("/v1/vector_stores", "/vector_stores", "/v1/indexes"), + ), + LazyFeature( + name="vector_store_management", + module_path="litellm.proxy.vector_store_endpoints.management_endpoints", + # Trailing slash to avoid matching /vector_stores/... (vector_stores). + path_prefixes=("/vector_store/", "/v1/vector_store/"), + ), + LazyFeature( + name="vector_store_files", + # Routes appear under both /v1/vector_stores/{id}/files and the + # un-versioned form, so both prefixes must trigger the load. + module_path="litellm.proxy.vector_store_files_endpoints.endpoints", + path_prefixes=("/v1/vector_stores", "/vector_stores"), + ), + LazyFeature( + name="tools", + module_path="litellm.proxy.management_endpoints.tool_management_endpoints", + path_prefixes=("/v1/tool", "/tool"), + ), + LazyFeature( + name="search_tools", + module_path="litellm.proxy.search_endpoints.search_tool_management", + path_prefixes=("/search_tools",), + ), + # mcp_management owns most /v1/mcp/* admin routes; mcp_app is the mounted + # streaming sub-app at /mcp. + LazyFeature( + name="mcp_management", + module_path="litellm.proxy.management_endpoints.mcp_management_endpoints", + path_prefixes=("/v1/mcp/",), + ), + LazyFeature( + # Also serves /.well-known/oauth-* (OAuth metadata discovery). + # No /mcp/oauth prefix here: the mounted /mcp sub-app would + # shadow it, and there are no actual routes there anyway. + name="mcp_byok_oauth", + module_path="litellm.proxy._experimental.mcp_server.byok_oauth_endpoints", + path_prefixes=("/v1/mcp/oauth", "/.well-known/oauth-"), + ), + LazyFeature( + # Serves OAuth dance endpoints (/authorize, /token, /callback, + # /register) plus several /.well-known/ discovery URLs at the proxy + # root — needed for MCP-over-OAuth flows even before /mcp is hit. + name="mcp_discoverable", + module_path="litellm.proxy._experimental.mcp_server.discoverable_endpoints", + path_prefixes=( + "/.well-known/oauth-", + "/.well-known/openid-configuration", + "/.well-known/jwks.json", + "/authorize", + "/token", + "/callback", + "/register", + ), + # Catches the /{mcp_server_name}/authorize|token|register variants. + path_suffixes=("/authorize", "/token", "/register"), + ), + LazyFeature( + name="mcp_rest", + module_path="litellm.proxy._experimental.mcp_server.rest_endpoints", + path_prefixes=("/mcp-rest",), + ), + LazyFeature( + # Hardcoded /mcp matches BASE_MCP_ROUTE; importing the constant + # here would defeat lazy loading. + name="mcp_app", + module_path="litellm.proxy._experimental.mcp_server.server", + path_prefixes=("/mcp",), + register_fn=_mount_app("/mcp", attr_name="app"), + ), + LazyFeature( + name="config_overrides", + module_path="litellm.proxy.management_endpoints.config_override_endpoints", + path_prefixes=("/config_overrides",), + ), + LazyFeature( + name="realtime", + module_path="litellm.proxy.realtime_endpoints.endpoints", + path_prefixes=("/openai/v1/realtime", "/v1/realtime", "/realtime"), + ), + LazyFeature( + name="anthropic_passthrough", + module_path="litellm.proxy.anthropic_endpoints.endpoints", + path_prefixes=("/v1/messages", "/anthropic", "/api/event_logging"), + ), + LazyFeature( + name="anthropic_skills", + module_path="litellm.proxy.anthropic_endpoints.skills_endpoints", + path_prefixes=("/v1/skills", "/skills"), + ), + LazyFeature( + name="langfuse_passthrough", + module_path="litellm.proxy.vertex_ai_endpoints.langfuse_endpoints", + path_prefixes=("/langfuse",), + ), + LazyFeature( + name="evals", + module_path="litellm.proxy.openai_evals_endpoints.endpoints", + path_prefixes=("/v1/evals", "/evals"), + ), + LazyFeature( + name="claude_code_marketplace", + module_path="litellm.proxy.anthropic_endpoints.claude_code_endpoints", + path_prefixes=("/claude-code",), + register_fn=_include_router("claude_code_marketplace_router"), + ), + LazyFeature( + name="scim", + module_path="litellm.proxy.management_endpoints.scim.scim_v2", + path_prefixes=("/scim",), + register_fn=_include_router("scim_router"), + ), + LazyFeature( + name="cloudzero", + module_path="litellm.proxy.spend_tracking.cloudzero_endpoints", + path_prefixes=("/cloudzero",), + ), + LazyFeature( + name="vantage", + module_path="litellm.proxy.spend_tracking.vantage_endpoints", + path_prefixes=("/vantage",), + ), + LazyFeature( + name="usage_ai", + module_path="litellm.proxy.management_endpoints.usage_endpoints", + path_prefixes=("/usage/ai",), + ), + LazyFeature( + name="prompts", + module_path="litellm.proxy.prompts.prompt_endpoints", + path_prefixes=("/prompts", "/utils/dotprompt_json_converter"), + ), + LazyFeature( + name="jwt_mappings", + module_path="litellm.proxy.management_endpoints.jwt_key_mapping_endpoints", + path_prefixes=("/jwt/key/mapping",), + ), + LazyFeature( + name="compliance", + module_path="litellm.proxy.management_endpoints.compliance_endpoints", + path_prefixes=("/compliance",), + ), + LazyFeature( + name="access_groups", + module_path="litellm.proxy.management_endpoints.access_group_endpoints", + path_prefixes=("/access_group", "/v1/access_group", "/v1/unified_access_group"), + ), +) + + +class LazyFeatureMiddleware: + """ASGI middleware that imports + registers a feature router on first + matching request. Idempotent; once loaded, subsequent requests skip.""" + + def __init__( + self, + app, + fastapi_app: "FastAPI", + features: Tuple[LazyFeature, ...] = LAZY_FEATURES, + ): + self.app = app + self._fastapi_app = fastapi_app + self._features = features + self._loaded: set = set() + # Per-feature locks so independent features can load in parallel. + self._locks: dict = {} + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + # Short-circuit once every feature has loaded. + if scope["type"] in ("http", "websocket") and len(self._loaded) < len( + self._features + ): + path = scope.get("path", "") + for feat in self._features: + if feat.module_path in self._loaded: + continue + if any(path.startswith(p) for p in feat.path_prefixes) or any( + path.endswith(s) for s in feat.path_suffixes + ): + await self._load(feat) + await self.app(scope, receive, send) + + async def _load(self, feat: LazyFeature) -> None: + lock = self._locks.setdefault(feat.module_path, asyncio.Lock()) + async with lock: + if feat.module_path in self._loaded: + return + try: + # Import on a thread (heavy modules take 1-3 s). register_fn + # mutates app.router.routes, so it stays on the loop thread. + loop = asyncio.get_running_loop() + module = await loop.run_in_executor( + None, importlib.import_module, feat.module_path + ) + feat.register_fn(self._fastapi_app, module) + self._loaded.add(feat.module_path) + self._fastapi_app.openapi_schema = None + verbose_proxy_logger.info( + "Lazy-loaded optional feature %r (module: %s)", + feat.name, + feat.module_path, + ) + except Exception as exc: + # Mark loaded anyway so we don't retry on every request. + self._loaded.add(feat.module_path) + verbose_proxy_logger.warning( + "Failed to lazy-load optional feature %r (module: %s): %s. " + "This feature's endpoints will return 404 until restart.", + feat.name, + feat.module_path, + exc, + ) + + +def attach_lazy_features(app: "FastAPI") -> None: + app.add_middleware(LazyFeatureMiddleware, fastapi_app=app) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1222995529..ebc3cdfddd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -235,37 +235,11 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.vertex_ai.vertex_llm_base import VertexBase -from litellm.proxy._experimental.mcp_server.byok_oauth_endpoints import ( - router as mcp_byok_oauth_router, -) -from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( - router as mcp_discoverable_endpoints_router, -) -from litellm.proxy._experimental.mcp_server.rest_endpoints import ( - router as mcp_rest_endpoints_router, -) -from litellm.proxy._experimental.mcp_server.server import app as mcp_app -from litellm.proxy._experimental.mcp_server.tool_registry import ( - global_mcp_tool_registry, -) from litellm.proxy._types import * -from litellm.proxy.agent_endpoints.a2a_endpoints import router as a2a_router -from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry -from litellm.proxy.agent_endpoints.endpoints import router as agent_endpoints_router -from litellm.proxy.agent_endpoints.model_list_helpers import ( - append_agents_to_model_group, - append_agents_to_model_info, -) +from litellm.proxy._lazy_features import attach_lazy_features from litellm.proxy.analytics_endpoints.analytics_endpoints import ( router as analytics_router, ) -from litellm.proxy.anthropic_endpoints.claude_code_endpoints import ( - claude_code_marketplace_router, -) -from litellm.proxy.anthropic_endpoints.endpoints import router as anthropic_router -from litellm.proxy.anthropic_endpoints.skills_endpoints import ( - router as anthropic_skills_router, -) from litellm.proxy.auth.auth_checks import ( ExperimentalUIJWTToken, get_team_object, @@ -328,7 +302,6 @@ from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config from litellm.proxy.google_endpoints.endpoints import router as google_router -from litellm.proxy.guardrails.guardrail_endpoints import router as guardrails_router from litellm.proxy.guardrails.init_guardrails import ( init_guardrails_v2, initialize_guardrails, @@ -344,9 +317,6 @@ from litellm.proxy.hooks.prompt_injection_detection import ( from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger from litellm.proxy.image_endpoints.endpoints import router as image_router from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request -from litellm.proxy.management_endpoints.access_group_endpoints import ( - router as access_group_router, -) from litellm.proxy.management_endpoints.budget_management_endpoints import ( router as budget_management_router, ) @@ -360,12 +330,6 @@ from litellm.proxy.management_endpoints.common_utils import ( _user_has_admin_privileges, admin_can_invite_user, ) -from litellm.proxy.management_endpoints.compliance_endpoints import ( - router as compliance_router, -) -from litellm.proxy.management_endpoints.config_override_endpoints import ( - router as config_override_router, -) from litellm.proxy.management_endpoints.cost_tracking_settings import ( router as cost_tracking_settings_router, ) @@ -379,9 +343,6 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ( router as internal_user_router, ) from litellm.proxy.management_endpoints.internal_user_endpoints import user_update -from litellm.proxy.management_endpoints.jwt_key_mapping_endpoints import ( - router as jwt_key_mapping_router, -) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -390,9 +351,6 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( from litellm.proxy.management_endpoints.key_management_endpoints import ( router as key_management_router, ) -from litellm.proxy.management_endpoints.mcp_management_endpoints import ( - router as mcp_management_router, -) from litellm.proxy.management_endpoints.model_access_group_management_endpoints import ( router as model_access_group_management_router, ) @@ -407,11 +365,9 @@ from litellm.proxy.management_endpoints.model_management_endpoints import ( from litellm.proxy.management_endpoints.organization_endpoints import ( router as organization_router, ) -from litellm.proxy.management_endpoints.policy_endpoints import router as policy_router from litellm.proxy.management_endpoints.router_settings_endpoints import ( router as router_settings_router, ) -from litellm.proxy.management_endpoints.scim.scim_v2 import scim_router from litellm.proxy.management_endpoints.tag_management_endpoints import ( router as tag_management_router, ) @@ -423,9 +379,6 @@ from litellm.proxy.management_endpoints.team_endpoints import ( update_team, validate_membership, ) -from litellm.proxy.management_endpoints.tool_management_endpoints import ( - router as tool_management_router, -) from litellm.proxy.management_endpoints.workflow_management_endpoints import ( router as workflow_management_router, ) @@ -434,7 +387,6 @@ from litellm.proxy.management_endpoints.ui_sso import ( get_disabled_non_admin_personal_key_creation, ) from litellm.proxy.management_endpoints.ui_sso import router as ui_sso_router -from litellm.proxy.management_endpoints.usage_endpoints import router as usage_ai_router from litellm.proxy.management_endpoints.user_agent_analytics_endpoints import ( router as user_agent_analytics_router, ) @@ -444,7 +396,6 @@ from litellm.proxy.middleware.in_flight_requests_middleware import ( ) from litellm.proxy.middleware.prometheus_auth_middleware import PrometheusAuthMiddleware from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router -from litellm.proxy.openai_evals_endpoints.endpoints import router as evals_router from litellm.proxy.openai_files_endpoints.files_endpoints import ( router as openai_files_router, ) @@ -464,27 +415,16 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( router as pass_through_router, ) -from litellm.proxy.policy_engine.policy_endpoints import router as policy_crud_router -from litellm.proxy.policy_engine.policy_resolve_endpoints import ( - router as policy_resolve_router, -) -from litellm.proxy.prompts.prompt_endpoints import router as prompts_router from litellm.proxy.public_endpoints import router as public_endpoints_router from litellm.proxy.rag_endpoints.endpoints import router as rag_router -from litellm.proxy.realtime_endpoints.endpoints import router as webrtc_router from litellm.proxy.rerank_endpoints.endpoints import router as rerank_router from litellm.proxy.response_api_endpoints.endpoints import router as response_router from litellm.proxy.route_llm_request import route_request from litellm.proxy.search_endpoints.endpoints import router as search_router -from litellm.proxy.search_endpoints.search_tool_management import ( - router as search_tool_management_router, -) -from litellm.proxy.spend_tracking.cloudzero_endpoints import router as cloudzero_router from litellm.proxy.spend_tracking.spend_management_endpoints import ( router as spend_management_router, ) from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload -from litellm.proxy.spend_tracking.vantage_endpoints import router as vantage_router from litellm.proxy.types_utils.utils import get_instance_fn from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( router as ui_crud_endpoints_router, @@ -514,16 +454,6 @@ from litellm.proxy.utils import ( prefetch_config_params, update_spend, ) -from litellm.proxy.vector_store_endpoints.endpoints import router as vector_store_router -from litellm.proxy.vector_store_endpoints.management_endpoints import ( - router as vector_store_management_router, -) -from litellm.proxy.vector_store_files_endpoints.endpoints import ( - router as vector_store_files_router, -) -from litellm.proxy.vertex_ai_endpoints.langfuse_endpoints import ( - router as langfuse_router, -) from litellm.proxy.video_endpoints.endpoints import router as video_router from litellm.router import ( AssistantsTypedDict, @@ -3864,11 +3794,19 @@ class ProxyConfig: ## MCP TOOLS mcp_tools_config = config.get("mcp_tools", None) if mcp_tools_config: + from litellm.proxy._experimental.mcp_server.tool_registry import ( + global_mcp_tool_registry, + ) + global_mcp_tool_registry.load_tools_from_config(mcp_tools_config) ## AGENTS agent_config = config.get("agent_list", None) if agent_config: + from litellm.proxy.agent_endpoints.agent_registry import ( + global_agent_registry, + ) + global_agent_registry.load_agents_from_config(agent_config) # type: ignore mcp_servers_config = config.get("mcp_servers", None) @@ -10586,6 +10524,10 @@ async def model_info_v2( verbose_proxy_logger.debug("all_models: %s", all_models) # Append A2A agents to models list + from litellm.proxy.agent_endpoints.model_list_helpers import ( + append_agents_to_model_info, + ) + all_models = await append_agents_to_model_info( models=all_models, user_api_key_dict=user_api_key_dict, @@ -11435,6 +11377,10 @@ async def model_group_info( ) # Append A2A agents to model groups + from litellm.proxy.agent_endpoints.model_list_helpers import ( + append_agents_to_model_group, + ) + model_groups = await append_agents_to_model_group( model_groups=model_groups, user_api_key_dict=user_api_key_dict, @@ -14240,66 +14186,41 @@ app.include_router(container_router) app.include_router(search_router) app.include_router(image_router) app.include_router(fine_tuning_router) -app.include_router(vector_store_router) -app.include_router(vector_store_management_router) -app.include_router(vector_store_files_router) app.include_router(credential_router) app.include_router(llm_passthrough_router) -app.include_router(webrtc_router) -app.include_router(mcp_management_router) -app.include_router(mcp_byok_oauth_router) -app.include_router(anthropic_router) -app.include_router(anthropic_skills_router) -app.include_router(evals_router) -app.include_router(claude_code_marketplace_router) -app.include_router(google_router) -app.include_router(langfuse_router) app.include_router(pass_through_router) app.include_router(health_router) app.include_router(key_management_router) app.include_router(internal_user_router) app.include_router(team_router) app.include_router(ui_sso_router) -app.include_router(scim_router) app.include_router(organization_router) app.include_router(customer_router) app.include_router(spend_management_router) -app.include_router(cloudzero_router) -app.include_router(vantage_router) app.include_router(caching_router) app.include_router(analytics_router) -app.include_router(guardrails_router) -app.include_router(policy_router) -app.include_router(usage_ai_router) -app.include_router(policy_crud_router) -app.include_router(policy_resolve_router) -app.include_router(search_tool_management_router) -app.include_router(prompts_router) app.include_router(callback_management_endpoints_router) app.include_router(debugging_endpoints_router) app.include_router(ui_crud_endpoints_router) app.include_router(openai_files_router) app.include_router(team_callback_router) -app.include_router(jwt_key_mapping_router) app.include_router(budget_management_router) app.include_router(model_management_router) app.include_router(model_access_group_management_router) app.include_router(tag_management_router) -app.include_router(tool_management_router) app.include_router(workflow_management_router) app.include_router(memory_router) app.include_router(cost_tracking_settings_router) app.include_router(router_settings_router) app.include_router(fallback_management_router) app.include_router(cache_settings_router) -app.include_router(config_override_router) app.include_router(user_agent_analytics_router) app.include_router(enterprise_router) app.include_router(ui_discovery_endpoints_router) -app.include_router(agent_endpoints_router) -app.include_router(compliance_router) -app.include_router(a2a_router) -app.include_router(access_group_router) +# Eager: /models/{name}:method overlaps with the OpenAI /models endpoint. +app.include_router(google_router) + +attach_lazy_features(app) async def _stream_mcp_asgi_response( @@ -14532,8 +14453,3 @@ async def dynamic_mcp_route(mcp_server_name: str, request: Request): f"Error handling dynamic MCP route for {mcp_server_name}: {str(e)}" ) raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") - - -app.mount(path=BASE_MCP_ROUTE, app=mcp_app) -app.include_router(mcp_rest_endpoints_router) -app.include_router(mcp_discoverable_endpoints_router) diff --git a/tests/proxy_unit_tests/test_proxy_routes.py b/tests/proxy_unit_tests/test_proxy_routes.py index 812e4e1ac4..67eca5206d 100644 --- a/tests/proxy_unit_tests/test_proxy_routes.py +++ b/tests/proxy_unit_tests/test_proxy_routes.py @@ -39,6 +39,20 @@ def test_routes_on_litellm_proxy(): this prevents accidentelly deleting /threads, or /batches etc """ + # Force-load lazy features so the test sees the full route set. Continue + # on per-feature import failure — the assertion below still catches + # missing-route regressions. + import importlib + + from litellm.proxy._lazy_features import LAZY_FEATURES + + for feat in LAZY_FEATURES: + try: + module = importlib.import_module(feat.module_path) + feat.register_fn(app, module) + except Exception as exc: + print(f"warning: failed to force-load {feat.name}: {exc}") + _all_routes = [] for route in app.routes: diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1f4f82a64e..7a96f6cbd1 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5471,3 +5471,255 @@ async def test_reseed_warms_cache_even_on_zero_db_spend(): finally: ps.spend_counter_cache = orig_counter ps.prisma_client = orig_prisma + + +# --------------------------------------------------------------------------- +# Lazy feature loading (LazyFeatureMiddleware) — verifies that optional +# routers are NOT imported at module load and ARE imported on first request +# to a matching path prefix. The same module isn't re-imported on subsequent +# requests. +# --------------------------------------------------------------------------- + + +import sys + + +class TestLazyFeatureRegistry: + """Sanity checks on the registry shape — guards against accidental edits.""" + + def test_registry_entries_have_required_fields(self): + from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature + + assert len(LAZY_FEATURES) > 0 + for feat in LAZY_FEATURES: + assert isinstance(feat, LazyFeature) + assert feat.name + assert feat.module_path + assert feat.path_prefixes + assert all(p.startswith("/") for p in feat.path_prefixes) + assert callable(feat.register_fn) + + def test_registry_names_unique(self): + from litellm.proxy._lazy_features import LAZY_FEATURES + + names = [f.name for f in LAZY_FEATURES] + assert len(names) == len(set(names)), "duplicate feature names" + + +class TestLazyFeaturesNotImportedAtStartup: + """ + The whole point of the refactor: gated feature modules must NOT be + present in `sys.modules` immediately after `proxy_server` imports. + """ + + def test_heavy_modules_absent_at_startup(self): + # Force a fresh `proxy_server` import in a subprocess so other tests + # in this run (which may have triggered lazy loads via the TestClient) + # don't pollute the result. + import subprocess + + check = ( + "import sys; " + "from litellm.proxy.proxy_server import app; " # noqa: F401 + "heavy = [" + "'litellm.proxy._experimental.mcp_server.rest_endpoints'," + "'litellm.proxy._experimental.mcp_server.server'," + "'litellm.proxy.management_endpoints.config_override_endpoints'," + "'litellm.proxy.guardrails.guardrail_endpoints'," + "'litellm.proxy.openai_evals_endpoints.endpoints'," + "]; " + "still_present = [m for m in heavy if m in sys.modules]; " + "print('PRESENT_AT_STARTUP:', still_present)" + ) + result = subprocess.run( + [sys.executable, "-c", check], + capture_output=True, + text=True, + timeout=120, + ) + # Last non-empty line of stdout (skip warnings printed before) + out_lines = [ + line for line in result.stdout.strip().splitlines() if line.strip() + ] + report = next((line for line in out_lines if "PRESENT_AT_STARTUP" in line), "") + assert report, f"no report emitted (stderr: {result.stderr[-500:]})" + assert ( + "PRESENT_AT_STARTUP: []" in report + ), f"expected no heavy modules at startup, got: {report}" + + +class TestLazyFeatureMiddleware: + """Behavior of the middleware itself, exercised in isolation.""" + + @pytest.mark.asyncio + async def test_first_request_triggers_load_subsequent_does_not(self): + from fastapi import FastAPI + + from litellm.proxy._lazy_features import ( + LazyFeature, + LazyFeatureMiddleware, + ) + + loads = [] + + def fake_register(app, module): + loads.append(getattr(module, "__name__", "?")) + + feat = LazyFeature( + name="dummy", + module_path="json", # any always-importable stdlib module + path_prefixes=("/dummy",), + register_fn=fake_register, + ) + + # Build a minimal ASGI receiver to satisfy the middleware contract + async def downstream(scope, receive, send): + # echo back; no-op handler + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + target_app = FastAPI() + mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,)) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + sent: list = [] + + async def send(message): + sent.append(message) + + # First request matching the prefix triggers register + await mw( + {"type": "http", "path": "/dummy/x", "method": "GET", "headers": []}, + receive, + send, + ) + assert loads == ["json"] + + # Second matching request must NOT re-register + sent.clear() + await mw( + {"type": "http", "path": "/dummy/y", "method": "GET", "headers": []}, + receive, + send, + ) + assert loads == ["json"], "register_fn called twice for the same feature" + + # Non-matching path must not trigger anything + await mw( + {"type": "http", "path": "/unrelated", "method": "GET", "headers": []}, + receive, + send, + ) + assert loads == ["json"] + + @pytest.mark.asyncio + async def test_concurrent_first_requests_only_register_once(self): + """ + Two requests to the same prefix arriving in parallel must result in + exactly one `register_fn` invocation — the lock prevents the import + + register from racing with itself. + """ + from fastapi import FastAPI + + from litellm.proxy._lazy_features import ( + LazyFeature, + LazyFeatureMiddleware, + ) + + loads = [] + + def slow_register(app, module): + loads.append(getattr(module, "__name__", "?")) + + feat = LazyFeature( + name="dummy_concurrent", + module_path="json", + path_prefixes=("/dummy_c",), + register_fn=slow_register, + ) + + async def downstream(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + target_app = FastAPI() + mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,)) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + sent: list = [] + + async def send(message): + sent.append(message) + + async def hit(): + await mw( + { + "type": "http", + "path": "/dummy_c/x", + "method": "GET", + "headers": [], + }, + receive, + send, + ) + + await asyncio.gather(hit(), hit(), hit(), hit(), hit()) + assert loads == [ + "json" + ], f"expected one registration despite concurrent first hits, got {loads}" + + @pytest.mark.asyncio + async def test_failing_import_does_not_loop(self): + """ + If a feature's module can't be imported, the middleware should mark it + loaded anyway so subsequent requests don't repeatedly retry the failing + import (which would amplify the cost on every request). + """ + from fastapi import FastAPI + + from litellm.proxy._lazy_features import ( + LazyFeature, + LazyFeatureMiddleware, + ) + + attempts = [] + + def fail_register(app, module): + attempts.append("called") + raise RuntimeError("boom") + + feat = LazyFeature( + name="failing", + module_path="json", + path_prefixes=("/fail",), + register_fn=fail_register, + ) + + async def downstream(scope, receive, send): + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b""}) + + target_app = FastAPI() + mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,)) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + sent: list = [] + + async def send(message): + sent.append(message) + + for _ in range(3): + await mw( + {"type": "http", "path": "/fail/x", "method": "GET", "headers": []}, + receive, + send, + ) + assert attempts == [ + "called" + ], f"failing register_fn should be invoked once, not on every request; got {attempts}" diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index 44cc5cc445..1e596aa567 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -786,8 +786,23 @@ class TestVectorStoreManagementEndpointsExist: - POST /vector_store/info - POST /vector_store/update """ + import importlib + + from litellm.proxy._lazy_features import LAZY_FEATURES from litellm.proxy.proxy_server import app + # Force-register the lazy vector_store_management routes so the + # assertions can find them. + already_registered = any( + getattr(r, "path", None) == "/vector_store/new" for r in app.routes + ) + if not already_registered: + for feat in LAZY_FEATURES: + if feat.name == "vector_store_management": + module = importlib.import_module(feat.module_path) + feat.register_fn(app, module) + break + # Define expected endpoints expected_endpoints = [ ("POST", "/vector_store/new"), From adcab435a4ac07acaff857fcf3b4d1894ab589a0 Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Tue, 28 Apr 2026 13:36:00 -0700 Subject: [PATCH 13/47] swagger: stub-inject unloaded lazy features and warm on dropdown expand --- litellm/proxy/_lazy_features.py | 37 +++++++++++++++++++++++++++++++- litellm/proxy/proxy_server.py | 38 ++++++++++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index 450c9483f3..3a8129987d 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -8,8 +8,9 @@ omits each feature's routes until the feature is warmed. import asyncio import importlib +import sys from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Callable, Tuple +from typing import TYPE_CHECKING, Callable, Dict, Tuple from starlette.types import Receive, Scope, Send @@ -46,6 +47,9 @@ class LazyFeature: # For routes whose path has a leading parameter (e.g. /{server}/authorize) # — startswith can't match those, so the matcher also checks endswith. path_suffixes: Tuple[str, ...] = () + # Keep the stub injected even after load — for mounted ASGI sub-apps + # whose routes don't appear in the parent app's openapi spec. + persistent_swagger_stub: bool = False LAZY_FEATURES: Tuple[LazyFeature, ...] = ( @@ -158,6 +162,7 @@ LAZY_FEATURES: Tuple[LazyFeature, ...] = ( module_path="litellm.proxy._experimental.mcp_server.server", path_prefixes=("/mcp",), register_fn=_mount_app("/mcp", attr_name="app"), + persistent_swagger_stub=True, ), LazyFeature( name="config_overrides", @@ -305,3 +310,33 @@ class LazyFeatureMiddleware: def attach_lazy_features(app: "FastAPI") -> None: app.add_middleware(LazyFeatureMiddleware, fastapi_app=app) + + +def inject_lazy_stubs(schema: Dict) -> Dict: + """Stub openapi entries for unloaded features so Swagger renders sections.""" + paths = schema.setdefault("paths", {}) + for feat in LAZY_FEATURES: + if feat.module_path in sys.modules and not feat.persistent_swagger_stub: + continue + prefix = feat.path_prefixes[0] + if prefix in paths: + continue + paths[prefix] = { + "get": { + "tags": [feat.name], + "summary": feat.name, + "responses": {"200": {"description": "OK"}}, + } + } + return schema + + +def lazy_tag_to_prefix() -> Dict[str, str]: + """feature.name -> first prefix, used by the Swagger warmup JS plugin. + Excludes persistent-stub features (mounted sub-apps) — warming them + triggers a streaming hit and no useful new routes appear.""" + return { + feat.name: feat.path_prefixes[0] + for feat in LAZY_FEATURES + if not feat.persistent_swagger_stub + } diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index ebc3cdfddd..c305644db3 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1033,6 +1033,11 @@ def get_openapi_schema(): openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema) + # Stub unloaded lazy features so they appear as Swagger sections. + from litellm.proxy._lazy_features import inject_lazy_stubs + + openapi_schema = inject_lazy_stubs(openapi_schema) + # Fix Swagger UI execute path error when server_root_path is set if server_root_path: openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}] @@ -1059,6 +1064,11 @@ def custom_openapi(): openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema) + # Stub unloaded lazy features so they appear as Swagger sections. + from litellm.proxy._lazy_features import inject_lazy_stubs + + openapi_schema = inject_lazy_stubs(openapi_schema) + # Fix Swagger UI execute path error when server_root_path is set if server_root_path: openapi_schema["servers"] = [{"url": "/" + server_root_path.strip("/")}] @@ -1486,14 +1496,40 @@ def mount_swagger_ui(): app.mount("/swagger", StaticFiles(directory=swagger_directory), name="swagger") + # On dropdown expand: one-time fetch to the prefix (triggers lazy load), + # then spec re-download so real routes replace the stub. Raw JS (no + # ", - ), - ): - result = await fetch_validated_image_bytes("https://cdn.example/x.svg") - assert result is None - - @pytest.mark.parametrize( - "content_type", - sorted(ALLOWED_IMAGE_CONTENT_TYPES), - ) - @pytest.mark.asyncio - async def test_accepts_each_allowed_image_content_type(self, content_type): - with _patch_async_safe_get( - return_value=_image_response(content_type=content_type), - ): - result = await fetch_validated_image_bytes("https://cdn.example/logo") - assert result == b"image-bytes" + def test_rejects_empty_path(self): + assert resolve_validated_local_image_path("") is None diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 1a77e39aa8..356ab4a4b0 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -472,8 +472,7 @@ def test_get_logo_url_does_not_disclose_local_paths( # ``/get_logo_url`` is unauthenticated. Returning a local filesystem # path verbatim discloses admin-only config to any caller. Only # browser-loadable HTTP(S) URLs should be returned; for local paths - # the dashboard falls back to ``/get_image`` (which has path - # containment). + # the dashboard falls back to ``/get_image``. monkeypatch.setenv("UI_LOGO_PATH", ui_logo_path) response = client_no_auth.get("/get_logo_url") @@ -4034,7 +4033,7 @@ async def test_get_image_root_case_uses_current_dir(monkeypatch): @pytest.mark.asyncio -async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch): +async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch, tmp_path): """ Test that when UI_LOGO_PATH is set to a local file, get_image serves it directly and does not return a stale cached_logo.jpg. @@ -4043,16 +4042,13 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch): so a pre-existing cached_logo.jpg (e.g. from the base Docker image) would always be returned, ignoring the user's custom logo. """ - from unittest.mock import patch - from litellm.proxy.proxy_server import get_image - # Use a path inside the allowlisted ``LITELLM_ASSETS_PATH`` — the - # path-containment guard added for GHSA-3pcp-536p-ghjc rejects any - # local UI_LOGO_PATH outside the allowed asset roots. - monkeypatch.setenv("LITELLM_ASSETS_PATH", "/app") - monkeypatch.setenv("UI_LOGO_PATH", "/app/custom_logo.jpg") + custom_logo = tmp_path / "custom_logo.jpg" + custom_logo.write_bytes(b"\xff\xd8\xff custom logo") + monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo)) monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) + monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False) calls_to_file_response = [] @@ -4061,35 +4057,23 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch): return MagicMock() with ( - patch("litellm.proxy.proxy_server.os.path.exists", return_value=True), - patch("litellm.proxy.proxy_server.os.access", return_value=True), patch( "litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response ), - # The path-containment helper calls ``os.path.realpath`` and - # ``os.path.isfile`` — make them play along for the test path. - patch( - "litellm.proxy.common_utils.static_asset_utils.os.path.realpath", - side_effect=lambda p: p, - ), - patch( - "litellm.proxy.common_utils.static_asset_utils.os.path.isfile", - return_value=True, - ), ): await get_image() assert ( len(calls_to_file_response) == 1 ), "FileResponse should be called exactly once" - assert calls_to_file_response[0] == "/app/custom_logo.jpg", ( + assert calls_to_file_response[0] == str(custom_logo.resolve()), ( f"Expected custom logo path, got {calls_to_file_response[0]}. " "A stale cached_logo.jpg may have been returned instead." ) @pytest.mark.asyncio -async def test_get_image_default_logo_still_uses_cache(monkeypatch): +async def test_get_image_default_logo_still_uses_cache(monkeypatch, tmp_path): """ Test that when UI_LOGO_PATH is NOT set (default logo), the cache optimization still works — cached_logo.jpg is returned if it exists. @@ -4098,9 +4082,11 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch): from litellm.proxy.proxy_server import get_image + cache_path = tmp_path / "cached_logo.jpg" + cache_path.write_bytes(b"\xff\xd8\xff cached logo") monkeypatch.delenv("UI_LOGO_PATH", raising=False) monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) - monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False) + monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path)) calls_to_file_response = [] @@ -4109,8 +4095,6 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch): return MagicMock() with ( - patch("litellm.proxy.proxy_server.os.path.exists", return_value=True), - patch("litellm.proxy.proxy_server.os.access", return_value=True), patch( "litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response ), @@ -4121,13 +4105,13 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch): len(calls_to_file_response) == 1 ), "FileResponse should be called exactly once" served_path = calls_to_file_response[0] - assert served_path.endswith( - "cached_logo.jpg" - ), f"Expected cached_logo.jpg for default logo, got {served_path}" + assert served_path == str(cache_path.resolve()) @pytest.mark.asyncio -async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatch): +async def test_get_image_custom_logo_missing_falls_through_to_default( + monkeypatch, tmp_path +): """ Test that when UI_LOGO_PATH points to a non-existent local file, get_image falls through to the cache/default logo instead of failing. @@ -4136,9 +4120,12 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc from litellm.proxy.proxy_server import get_image - monkeypatch.setenv("UI_LOGO_PATH", "/app/nonexistent_logo.jpg") + cache_path = tmp_path / "cached_logo.jpg" + cache_path.write_bytes(b"\xff\xd8\xff cached logo") + custom_logo_path = tmp_path / "nonexistent_logo.jpg" + monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path)) monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) - monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False) + monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path)) calls_to_file_response = [] @@ -4146,17 +4133,7 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc calls_to_file_response.append(path) return MagicMock() - def exists_side_effect(path): - # The custom logo does NOT exist; cache and default DO exist - if path == "/app/nonexistent_logo.jpg": - return False - return True - with ( - patch( - "litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect - ), - patch("litellm.proxy.proxy_server.os.access", return_value=True), patch( "litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response ), @@ -4167,16 +4144,16 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc len(calls_to_file_response) == 1 ), "FileResponse should be called exactly once" served_path = calls_to_file_response[0] - assert ( - served_path != "/app/nonexistent_logo.jpg" + assert served_path != str( + custom_logo_path ), "Should not attempt to serve a non-existent custom logo" - assert served_path.endswith( - "cached_logo.jpg" - ), f"Expected fallback to cached_logo.jpg, got {served_path}" + assert served_path == str(cache_path.resolve()) @pytest.mark.asyncio -async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch): +async def test_get_image_custom_logo_missing_no_cache_serves_default( + monkeypatch, tmp_path +): """ Test that when UI_LOGO_PATH points to a non-existent file AND there is no cached_logo.jpg, get_image serves the default logo instead of the @@ -4186,9 +4163,10 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch from litellm.proxy.proxy_server import get_image - monkeypatch.setenv("UI_LOGO_PATH", "/app/nonexistent_logo.jpg") + custom_logo_path = tmp_path / "nonexistent_logo.jpg" + monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path)) monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) - monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False) + monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path)) calls_to_file_response = [] @@ -4196,19 +4174,7 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch calls_to_file_response.append(path) return MagicMock() - def exists_side_effect(path): - # Neither the custom logo nor the cache exist - if path == "/app/nonexistent_logo.jpg": - return False - if "cached_logo.jpg" in path: - return False - return True - with ( - patch( - "litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect - ), - patch("litellm.proxy.proxy_server.os.access", return_value=True), patch( "litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response ), @@ -4219,8 +4185,8 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch len(calls_to_file_response) == 1 ), "FileResponse should be called exactly once" served_path = calls_to_file_response[0] - assert ( - served_path != "/app/nonexistent_logo.jpg" + assert served_path != str( + custom_logo_path ), "Should not attempt to serve a non-existent custom logo" assert served_path.endswith( "logo.jpg" From b8a141cefd2b0aba2959a2aae42f7a465575577e Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:34:25 -0700 Subject: [PATCH 42/47] fix(static-assets): stop serving stale logo cache --- litellm/proxy/proxy_server.py | 18 +----------------- tests/test_litellm/proxy/test_proxy_server.py | 19 +++++++++---------- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b65455e567..828a4747bd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12229,7 +12229,7 @@ def get_logo_url(): directly by the browser from a public/internal CDN. Local file paths set via ``UI_LOGO_PATH`` are NOT returned: they are admin- only filesystem details, the dashboard falls back to ``/get_image`` - which serves the file (with path containment) instead. Without + which serves the file only when it is a supported image. Without this filter, the unauthenticated endpoint would disclose internal hostnames or filesystem paths to any caller. """ @@ -12275,9 +12275,6 @@ async def get_image(): if assets_dir != current_dir and not os.path.exists(default_logo): default_logo = default_site_logo - cache_dir = assets_dir if os.access(assets_dir, os.W_OK) else current_dir - cache_path = os.path.join(cache_dir, "cached_logo.jpg") - logo_path = os.getenv("UI_LOGO_PATH", default_logo) verbose_proxy_logger.debug("Reading logo from path: %s", logo_path) @@ -12302,19 +12299,6 @@ async def get_image(): if logo_path.startswith(("http://", "https://")): return RedirectResponse(url=logo_path) - # [OPTIMIZATION] For default logo, check if the cached image exists. - # Validate the cache before serving so stale pre-fix cache files cannot - # keep exposing non-image responses fetched before this hardening. - if os.path.exists(cache_path): - safe_cache = resolve_validated_local_image_path(cache_path) - if safe_cache is not None: - safe_cache_path, media_type = safe_cache - return FileResponse(safe_cache_path, media_type=media_type) - verbose_proxy_logger.warning( - "Ignoring cached logo at %s because it is not a supported image file", - cache_path, - ) - # Default logo (resolved from the bundled asset, not user-controlled). safe_logo = resolve_validated_local_image_path(logo_path) if safe_logo is not None: diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 356ab4a4b0..d482fd1c51 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -4073,10 +4073,10 @@ async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch, tmp_path) @pytest.mark.asyncio -async def test_get_image_default_logo_still_uses_cache(monkeypatch, tmp_path): +async def test_get_image_default_logo_ignores_stale_cache(monkeypatch, tmp_path): """ - Test that when UI_LOGO_PATH is NOT set (default logo), the cache - optimization still works — cached_logo.jpg is returned if it exists. + Test that when UI_LOGO_PATH is NOT set, stale pre-fix cached_logo.jpg + files are ignored and the default logo is served. """ from unittest.mock import patch @@ -4105,7 +4105,8 @@ async def test_get_image_default_logo_still_uses_cache(monkeypatch, tmp_path): len(calls_to_file_response) == 1 ), "FileResponse should be called exactly once" served_path = calls_to_file_response[0] - assert served_path == str(cache_path.resolve()) + assert served_path != str(cache_path.resolve()) + assert served_path.endswith("logo.jpg") @pytest.mark.asyncio @@ -4114,14 +4115,12 @@ async def test_get_image_custom_logo_missing_falls_through_to_default( ): """ Test that when UI_LOGO_PATH points to a non-existent local file, - get_image falls through to the cache/default logo instead of failing. + get_image falls through to the default logo instead of failing. """ from unittest.mock import patch from litellm.proxy.proxy_server import get_image - cache_path = tmp_path / "cached_logo.jpg" - cache_path.write_bytes(b"\xff\xd8\xff cached logo") custom_logo_path = tmp_path / "nonexistent_logo.jpg" monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path)) monkeypatch.delenv("LITELLM_NON_ROOT", raising=False) @@ -4147,7 +4146,7 @@ async def test_get_image_custom_logo_missing_falls_through_to_default( assert served_path != str( custom_logo_path ), "Should not attempt to serve a non-existent custom logo" - assert served_path == str(cache_path.resolve()) + assert served_path.endswith("logo.jpg") @pytest.mark.asyncio @@ -4156,8 +4155,8 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default( ): """ Test that when UI_LOGO_PATH points to a non-existent file AND there is no - cached_logo.jpg, get_image serves the default logo instead of the - non-existent custom path. + cached_logo.jpg, get_image serves the default logo instead of the non-existent + custom path. """ from unittest.mock import patch From d47948ab236d12a1a2f8d35cafa7cf0a6cda2673 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 30 Apr 2026 11:22:47 -0700 Subject: [PATCH 43/47] fix: validate aws region name --- litellm/llms/bedrock/base_aws_llm.py | 30 ++++ .../llms/bedrock/test_base_aws_llm.py | 134 ++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 4e3521b119..dae60948a5 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -1,6 +1,7 @@ import hashlib import json import os +import re import urllib.parse from datetime import datetime from typing import ( @@ -37,6 +38,11 @@ else: AWSPreparedRequest = Any +# Real AWS region names are lowercase letters, digits, and hyphens +# (e.g. "us-east-1", "eu-west-2", "us-gov-west-1", "cn-north-1"). +_VALID_AWS_REGION_PATTERN = re.compile(r"\A[a-z0-9-]+\Z") + + class Boto3CredentialsInfo(BaseModel): credentials: Credentials aws_region_name: str @@ -284,6 +290,9 @@ class BaseAWSLLM: if not region: # Check if region is empty return None + if not _VALID_AWS_REGION_PATTERN.match(region): + return None + return region except Exception: # Catch any unexpected errors and return None @@ -481,6 +490,7 @@ class BaseAWSLLM: str: The AWS region name """ aws_region_name = optional_params.get("aws_region_name", None) + self._validate_aws_region_name(aws_region_name) ### SET REGION NAME ### if aws_region_name is None: # check model arn # @@ -519,8 +529,25 @@ class BaseAWSLLM: except Exception: aws_region_name = "us-west-2" + self._validate_aws_region_name(aws_region_name) return aws_region_name + @staticmethod + def _validate_aws_region_name(aws_region_name: Optional[str]) -> None: + """ + Validate that an AWS region name conforms to the expected format + (lowercase alphanumerics and hyphens). Raises ValueError otherwise. + """ + if aws_region_name is None: + return + if not isinstance(aws_region_name, str) or not _VALID_AWS_REGION_PATTERN.match( + aws_region_name + ): + raise ValueError( + f"Invalid AWS region format: {aws_region_name!r}. " + "Region names must contain only lowercase letters, digits, and hyphens." + ) + def get_aws_region_name_for_non_llm_api_calls( self, aws_region_name: Optional[str] = None, @@ -532,6 +559,7 @@ class BaseAWSLLM: For non-llm api calls eg. Guardrails, Vector Stores we just need to check the dynamic param or env vars. """ + self._validate_aws_region_name(aws_region_name) if aws_region_name is None: # check env # litellm_aws_region_name = get_secret("AWS_REGION_NAME", None) @@ -549,6 +577,8 @@ class BaseAWSLLM: if aws_region_name is None: aws_region_name = "us-west-2" + + self._validate_aws_region_name(aws_region_name) return aws_region_name @staticmethod diff --git a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py index 1c2272757b..55f810380b 100644 --- a/tests/test_litellm/llms/bedrock/test_base_aws_llm.py +++ b/tests/test_litellm/llms/bedrock/test_base_aws_llm.py @@ -180,6 +180,140 @@ def test_get_aws_region_name_boto3_fallback(): mock_boto3_session.assert_not_called() +@pytest.mark.parametrize( + "bad_region", + [ + "us-east-1@example.com/", + "us-east-1@example.com", + "us-east-1/path", + "us-east-1.example.com", + "us-east-1:8080", + "us-east-1#fragment", + "us-east-1?query=1", + "us-east-1\\path", + "US-EAST-1", # uppercase not allowed + "us east 1", # spaces not allowed + "", # empty string not allowed + "us-east-1\n", # trailing newline must not slip past $ + ], +) +def test_get_aws_region_name_rejects_malformed_region(bad_region): + """ + Region names are interpolated into endpoint URL templates, so any value + containing characters that would alter URL parsing must be rejected. + """ + base_aws_llm = BaseAWSLLM() + + with pytest.raises(ValueError, match="Invalid AWS region format"): + base_aws_llm._get_aws_region_name( + optional_params={"aws_region_name": bad_region} + ) + + +@pytest.mark.parametrize( + "valid_region", + [ + "us-east-1", + "eu-west-2", + "ap-southeast-1", + "us-gov-west-1", + "cn-north-1", + "me-south-1", + ], +) +def test_get_aws_region_name_accepts_valid_regions(valid_region): + """Real AWS region formats must continue to work after the format guard.""" + base_aws_llm = BaseAWSLLM() + result = base_aws_llm._get_aws_region_name( + optional_params={"aws_region_name": valid_region} + ) + assert result == valid_region + + +def test_get_aws_region_name_rejects_malformed_region_from_env(): + """ + A malformed AWS_REGION / AWS_REGION_NAME env value must also be rejected + before it can flow into a URL template. + """ + base_aws_llm = BaseAWSLLM() + + with patch("litellm.llms.bedrock.base_aws_llm.get_secret") as mock_get_secret: + + def side_effect(key, default=None): + if key == "AWS_REGION_NAME": + return "us-east-1@example.com/" + return default + + mock_get_secret.side_effect = side_effect + + with pytest.raises(ValueError, match="Invalid AWS region format"): + base_aws_llm._get_aws_region_name(optional_params={}) + + +def test_get_aws_region_name_for_non_llm_api_calls_rejects_malformed_param(): + """ + The non-LLM helper (used by Guardrails, Vector Stores, etc.) must validate + a region passed in directly so it can't flow into a URL template. + """ + base_aws_llm = BaseAWSLLM() + + with pytest.raises(ValueError, match="Invalid AWS region format"): + base_aws_llm.get_aws_region_name_for_non_llm_api_calls( + aws_region_name="us-east-1@example.com/" + ) + + +def test_get_aws_region_name_for_non_llm_api_calls_rejects_malformed_env(): + """ + A malformed AWS_REGION / AWS_REGION_NAME env value must be rejected on the + non-LLM path too — Guardrails and Vector Stores read the same env vars. + """ + base_aws_llm = BaseAWSLLM() + + with patch("litellm.llms.bedrock.base_aws_llm.get_secret") as mock_get_secret: + + def side_effect(key, default=None): + if key == "AWS_REGION_NAME": + return "us-east-1@example.com/" + return default + + mock_get_secret.side_effect = side_effect + + with pytest.raises(ValueError, match="Invalid AWS region format"): + base_aws_llm.get_aws_region_name_for_non_llm_api_calls() + + +def test_get_aws_region_name_for_non_llm_api_calls_accepts_valid_region(): + """The non-LLM helper still returns valid regions unchanged.""" + base_aws_llm = BaseAWSLLM() + assert ( + base_aws_llm.get_aws_region_name_for_non_llm_api_calls( + aws_region_name="us-east-1" + ) + == "us-east-1" + ) + + +def test_get_aws_region_from_model_arn_rejects_malformed_region(): + """ + If the region segment of a model ARN does not match the expected format, + the helper must return None so the caller falls back to env / default. + """ + base_aws_llm = BaseAWSLLM() + + bad_arn = ( + "arn:aws:bedrock:us-east-1@example.com:123456789012" + ":foundation-model/anthropic.claude-3-sonnet" + ) + assert base_aws_llm._get_aws_region_from_model_arn(bad_arn) is None + + good_arn = ( + "arn:aws:bedrock:us-east-1:123456789012" + ":foundation-model/anthropic.claude-3-sonnet" + ) + assert base_aws_llm._get_aws_region_from_model_arn(good_arn) == "us-east-1" + + def test_sign_request_with_env_var_bearer_token(): # Create instance of actual class llm = BaseAWSLLM() From b67a81da47383e707eee239b4675fa61c0258f49 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:46:45 -0700 Subject: [PATCH 44/47] test(proxy): align favicon remote asset expectations --- tests/proxy_unit_tests/test_get_favicon.py | 61 +++++++--------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/tests/proxy_unit_tests/test_get_favicon.py b/tests/proxy_unit_tests/test_get_favicon.py index f17787e740..ddc8b1230a 100644 --- a/tests/proxy_unit_tests/test_get_favicon.py +++ b/tests/proxy_unit_tests/test_get_favicon.py @@ -1,6 +1,5 @@ import os import sys -from unittest import mock sys.path.insert(0, os.path.abspath("../..")) @@ -26,50 +25,30 @@ async def test_get_favicon_default(): @pytest.mark.asyncio -async def test_get_favicon_with_custom_url(): - """Test that get_favicon fetches from a custom URL.""" - os.environ["LITELLM_FAVICON_URL"] = "https://example.com/favicon.ico" +async def test_get_favicon_with_custom_url(monkeypatch): + """Test that get_favicon redirects browser-loaded custom URLs.""" + monkeypatch.setenv("LITELLM_FAVICON_URL", "https://example.com/favicon.ico") - mock_response = mock.Mock() - mock_response.status_code = 200 - mock_response.content = b"\x00\x00\x01\x00" - mock_response.headers = {"content-type": "image/x-icon"} + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as ac: + response = await ac.get("/get_favicon") - try: - with mock.patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" - ) as mock_get: - mock_get.return_value = mock_response - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - ) as ac: - response = await ac.get("/get_favicon") - - assert response.status_code == 200 - assert response.headers["content-type"] == "image/x-icon" - finally: - os.environ.pop("LITELLM_FAVICON_URL", None) + assert response.status_code == 307 + assert response.headers["location"] == "https://example.com/favicon.ico" @pytest.mark.asyncio -async def test_get_favicon_url_error_fallback(): - """Test that get_favicon falls back to default on error.""" - os.environ["LITELLM_FAVICON_URL"] = "https://invalid.com/favicon.ico" +async def test_get_favicon_remote_url_is_not_server_fetched(monkeypatch): + """Test that get_favicon does not validate remote URLs server-side.""" + monkeypatch.setenv("LITELLM_FAVICON_URL", "https://invalid.com/favicon.ico") - try: - with mock.patch( - "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.get" - ) as mock_get: - mock_get.side_effect = httpx.ConnectError("unreachable") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + ) as ac: + response = await ac.get("/get_favicon") - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - base_url="http://testserver", - ) as ac: - response = await ac.get("/get_favicon") - - assert response.status_code in [200, 404] - finally: - os.environ.pop("LITELLM_FAVICON_URL", None) + assert response.status_code == 307 + assert response.headers["location"] == "https://invalid.com/favicon.ico" From dfc080f58043f98222840b12d038dbd8ef6ad593 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 30 Apr 2026 11:44:39 -0700 Subject: [PATCH 45/47] fix: drop milvus dbName and partitionNames from MILVUS_OPTIONAL_PARAMS --- .../milvus/vector_stores/transformation.py | 12 +- litellm/types/router.py | 2 + .../test_milvus_vector_store.py | 133 ++++++++++++++++++ 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/litellm/llms/milvus/vector_stores/transformation.py b/litellm/llms/milvus/vector_stores/transformation.py index af78cd8dbd..867f6d4b1f 100644 --- a/litellm/llms/milvus/vector_stores/transformation.py +++ b/litellm/llms/milvus/vector_stores/transformation.py @@ -25,7 +25,6 @@ else: LiteLLMLoggingObj = Any MILVUS_OPTIONAL_PARAMS = { - "dbName", "annsField", "limit", "filter", @@ -33,7 +32,6 @@ MILVUS_OPTIONAL_PARAMS = { "groupingField", "outputFields", "searchParams", - "partitionNames", "consistencyLevel", } @@ -173,13 +171,21 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig): url = f"{api_base}/v2/vectordb/entities/search" # Build the request body for Azure AI Search with vector search - request_body = { + request_body: Dict[str, Any] = { "collectionName": index_name, "data": [query_vector], "annsField": "book_intro_vector", **vector_store_search_optional_params, } + db_name = litellm_params.get("milvus_db_name") + if db_name: + request_body["dbName"] = db_name + + partition_names = litellm_params.get("milvus_partition_names") + if partition_names: + request_body["partitionNames"] = partition_names + ######################################################### # Update logging object with details of the request ######################################################### diff --git a/litellm/types/router.py b/litellm/types/router.py index 33102f9ec4..2bc8cee351 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -237,6 +237,8 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): # Vector Store Params vector_store_id: Optional[str] = None milvus_text_field: Optional[str] = None + milvus_db_name: Optional[str] = None + milvus_partition_names: Optional[List[str]] = None @model_validator(mode="before") @classmethod diff --git a/tests/vector_store_tests/test_milvus_vector_store.py b/tests/vector_store_tests/test_milvus_vector_store.py index ece0737778..6627f6006d 100644 --- a/tests/vector_store_tests/test_milvus_vector_store.py +++ b/tests/vector_store_tests/test_milvus_vector_store.py @@ -310,6 +310,139 @@ class TestMilvusVectorStore: assert result["attributes"]["book_id"] == expected["book_id"] # type: ignore assert "book_intro_text" not in result["attributes"] # type: ignore # Should be in content, not attributes + def _extract_request_body(self, mock_post): + call_args = mock_post.call_args + request_data_str = call_args.kwargs.get("data") + if request_data_str: + return json.loads(request_data_str) + request_data = call_args.kwargs.get("json") + if ( + request_data is None + and len(call_args.args) > 0 + and isinstance(call_args.args[0], dict) + ): + request_data = call_args.args[0] + return request_data + + def test_user_supplied_db_and_partition_are_dropped(self): + """User-supplied dbName / partitionNames must not be forwarded to Milvus.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE + mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE) + + with patch("litellm.embedding") as mock_embedding: + mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = mock_response + + vector_store_search( + query="what is machine learning?", + vector_store_id="book_2", + custom_llm_provider="milvus", + api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com", + api_key="mock_milvus_api_key", + litellm_embedding_model="text-embedding-3-large", + litellm_embedding_config={ + "api_key": "mock_openai_api_key", + }, + outputFields=["book_intro_text"], + annsField="book_intro_vector", + milvus_text_field="book_intro_text", + dbName="other_tenant_db", + partitionNames=["other_tenant_partition"], + ) + + mock_post.assert_called_once() + request_data = self._extract_request_body(mock_post) + assert request_data is not None + assert "dbName" not in request_data + assert "partitionNames" not in request_data + assert request_data["collectionName"] == "book_2" + assert request_data["annsField"] == "book_intro_vector" + assert request_data["outputFields"] == ["book_intro_text"] + + def test_backend_configured_db_and_partition_are_forwarded(self): + """milvus_db_name / milvus_partition_names from litellm_params must be sent.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE + mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE) + + with patch("litellm.embedding") as mock_embedding: + mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = mock_response + + vector_store_search( + query="what is machine learning?", + vector_store_id="book_2", + custom_llm_provider="milvus", + api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com", + api_key="mock_milvus_api_key", + litellm_embedding_model="text-embedding-3-large", + litellm_embedding_config={ + "api_key": "mock_openai_api_key", + }, + outputFields=["book_intro_text"], + annsField="book_intro_vector", + milvus_text_field="book_intro_text", + milvus_db_name="tenant_a_db", + milvus_partition_names=["tenant_a_partition"], + ) + + mock_post.assert_called_once() + request_data = self._extract_request_body(mock_post) + assert request_data is not None + assert request_data["dbName"] == "tenant_a_db" + assert request_data["partitionNames"] == ["tenant_a_partition"] + + def test_user_params_cannot_override_backend_db_and_partition(self): + """Backend-config dbName/partitionNames must win over user-supplied values.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE + mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE) + + with patch("litellm.embedding") as mock_embedding: + mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post" + ) as mock_post: + mock_post.return_value = mock_response + + vector_store_search( + query="what is machine learning?", + vector_store_id="book_2", + custom_llm_provider="milvus", + api_base="https://in03-test.serverless.aws-eu-central-1.cloud.zilliz.com", + api_key="mock_milvus_api_key", + litellm_embedding_model="text-embedding-3-large", + litellm_embedding_config={ + "api_key": "mock_openai_api_key", + }, + outputFields=["book_intro_text"], + annsField="book_intro_vector", + milvus_text_field="book_intro_text", + milvus_db_name="tenant_a_db", + milvus_partition_names=["tenant_a_partition"], + dbName="other_tenant_db", + partitionNames=["other_tenant_partition"], + ) + + mock_post.assert_called_once() + request_data = self._extract_request_body(mock_post) + assert request_data is not None + assert request_data["dbName"] == "tenant_a_db" + assert request_data["partitionNames"] == ["tenant_a_partition"] + # @pytest.mark.parametrize("sync_mode", [True, False]) # @pytest.mark.asyncio From 51a3e90451f0b23cd2ee650f108a39c31b706b80 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:42:52 -0700 Subject: [PATCH 46/47] fix(mcp): reuse safe URL fetch for OAuth discovery --- .../mcp_server/mcp_server_manager.py | 122 ++++------ .../mcp_server/test_mcp_server_manager.py | 227 ++++++++++++++---- 2 files changed, 218 insertions(+), 131 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 2892821081..f96350500d 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -12,7 +12,6 @@ import hashlib import json import os import re -import socket from typing import Any, Callable, Dict, List, Literal, Optional, Set, Tuple, Union, cast from urllib.parse import urlparse @@ -42,7 +41,7 @@ from litellm.constants import ( MCP_TOOL_LISTING_TIMEOUT, ) from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException -from litellm.litellm_core_utils.url_utils import _is_blocked_ip +from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get from litellm.experimental_mcp_client.client import MCPClient, MCPSigV4Auth from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( @@ -1502,27 +1501,15 @@ class MCPServerManager: return await client.get_prompt(get_prompt_request_params) @staticmethod - def _is_safe_metadata_url(url: str, server_url: str) -> bool: + def _is_same_authority_metadata_url(url: str, server_url: str) -> bool: """ - Whether ``url`` is safe to fetch during OAuth discovery for ``server_url``. + Whether ``url`` shares scheme, host, and port with ``server_url``. - OAuth metadata discovery follows attacker-influenceable URLs from a - WWW-Authenticate header and from the protected-resource-metadata JSON. - Without a guard those become an SSRF primitive: a malicious MCP server - can point the proxy at cloud metadata services, internal admin panels, - or loopback debug endpoints. - - A URL is allowed when: - - it shares (scheme, host, port) with ``server_url`` — well-known - endpoints constructed from the admin's URL, and PRM published at - the resource server itself per RFC 9728 §3.3, OR - - it resolves to one or more public IPs only — covers federated - authorization servers (Azure Entra, Google, Okta, GitHub) hosted - cross-origin from the resource. - - URLs that resolve to private / loopback / link-local / cloud-metadata - addresses, or that don't resolve at all, are rejected. ``http`` and - ``https`` are the only schemes accepted. + Same-authority metadata URLs are produced by our well-known discovery + construction and by resource servers that publish protected-resource + metadata on the resource origin. These must keep working for + administrator-configured internal MCP servers, so they are fetched + directly. Cross-origin URLs are fetched through ``async_safe_get``. """ try: target = urlparse(url) @@ -1535,37 +1522,24 @@ class MCPServerManager: target_port = target.port or (443 if target.scheme == "https" else 80) base_port = base.port or (443 if base.scheme == "https" else 80) - same_authority = ( + return ( base.scheme == target.scheme and (base.hostname or "").lower() == target.hostname.lower() and base_port == target_port ) - if same_authority: - return True - try: - infos = socket.getaddrinfo( - target.hostname, target_port, type=socket.SOCK_STREAM - ) - except socket.gaierror: - return False - - if not infos: - return False - - # Reuse the proxy-wide outbound block list (private / loopback / - # link-local / multicast / reserved / cloud-fabric IPs). Defence - # in depth only — the resolution here and the one httpx performs at - # request time leave a small DNS-rebinding window; an attacker who - # also controls the resource server is already in scope, so the - # primary mitigation is the same-authority pin above. - for info in infos: - sockaddr_host = info[4][0] - if not isinstance(sockaddr_host, str): - return False - if _is_blocked_ip(sockaddr_host): - return False - return True + async def _fetch_oauth_discovery_url(self, url: str, server_url: str) -> Any: + client = get_async_httpx_client( + llm_provider=httpxSpecialProvider.MCP, + params={"timeout": MCP_METADATA_TIMEOUT}, + ) + if self._is_same_authority_metadata_url(url, server_url): + # Same-authority URLs may point at administrator-configured + # internal MCP servers. Do not run them through user URL + # validation, but also do not follow redirects because the + # redirect target would not inherit the same-authority guarantee. + return await client.get(url, follow_redirects=False) + return await async_safe_get(client, url) async def _descovery_metadata( self, @@ -1689,26 +1663,21 @@ class MCPServerManager: if not resource_metadata_url: return [], None - if not self._is_safe_metadata_url(resource_metadata_url, server_url): - verbose_logger.warning( - "MCP OAuth discovery: refusing to fetch resource metadata from %s " - "(rejected by SSRF guard for server %s)", - resource_metadata_url, - server_url, - ) - return [], None - try: - client = get_async_httpx_client( - llm_provider=httpxSpecialProvider.MCP, - params={"timeout": MCP_METADATA_TIMEOUT}, + response = await self._fetch_oauth_discovery_url( + resource_metadata_url, server_url ) - # Redirects bypass the SSRF guard (the new ``Location`` is not - # re-checked against ``server_url``), so refuse to follow them. - # Spec-compliant OAuth metadata endpoints serve the JSON directly. - response = await client.get(resource_metadata_url, follow_redirects=False) response.raise_for_status() data = response.json() + except SSRFError as exc: + verbose_logger.warning( + "MCP OAuth discovery: refusing to fetch resource metadata from %s " + "(rejected by SSRF guard for server %s): %s", + resource_metadata_url, + server_url, + exc, + ) + return [], None except Exception as exc: # pragma: no cover - network issues verbose_logger.debug( "Failed to fetch MCP OAuth metadata from %s: %s", @@ -1802,26 +1771,19 @@ class MCPServerManager: candidate_urls.append(issuer_url.rstrip("/")) for url in candidate_urls: - if not self._is_safe_metadata_url(url, server_url): - verbose_logger.warning( - "MCP OAuth discovery: refusing to fetch authorization-server " - "metadata from %s (rejected by SSRF guard for server %s)", - url, - server_url, - ) - continue try: - client = get_async_httpx_client( - llm_provider=httpxSpecialProvider.MCP, - params={"timeout": MCP_METADATA_TIMEOUT}, - ) - # Disable redirects: a redirect to a private IP would bypass - # the SSRF guard (the ``Location`` target is not re-checked - # against ``server_url``). Spec-compliant OAuth/OIDC - # metadata endpoints serve the JSON directly. - response = await client.get(url, follow_redirects=False) + response = await self._fetch_oauth_discovery_url(url, server_url) response.raise_for_status() data = response.json() + except SSRFError as exc: + verbose_logger.warning( + "MCP OAuth discovery: refusing to fetch authorization-server " + "metadata from %s (rejected by SSRF guard for server %s): %s", + url, + server_url, + exc, + ) + continue except Exception as exc: # pragma: no cover - network issues verbose_logger.debug( "Failed to fetch authorization metadata from %s: %s", diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 28eab1e7a0..6cbdcd2820 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -4,6 +4,7 @@ import logging import os import sys from datetime import datetime +from typing import Any, Dict from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -2971,7 +2972,7 @@ class TestOAuthDiscoverySSRFGuard: """Patch ``socket.getaddrinfo`` for a deterministic SSRF-guard test. ``mapping`` is ``{hostname: [ip-string, ...]}``; unknown hosts raise - ``gaierror`` (treated as "unresolvable" -> blocked). + ``gaierror`` (treated as "unresolvable" -> blocked by async_safe_get). """ import socket as _socket @@ -2984,26 +2985,21 @@ class TestOAuthDiscoverySSRFGuard: ] monkeypatch.setattr( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.socket.getaddrinfo", + "litellm.litellm_core_utils.url_utils.socket.getaddrinfo", fake_getaddrinfo, ) - def test_same_authority_url_is_safe(self): + def test_same_authority_url_is_direct_fetch_eligible(self): # Same scheme + host + port skips DNS entirely — the well-known # endpoint construction in _attempt_well_known_discovery always # produces same-authority URLs against the admin's server_url. - assert MCPServerManager._is_safe_metadata_url( + assert MCPServerManager._is_same_authority_metadata_url( "https://example.com/.well-known/oauth-protected-resource", "https://example.com/mcp", ) - def test_same_host_different_port_blocked_when_resolves_to_private_ip( - self, monkeypatch - ): - # Cross-authority (different port). Falls through to DNS check. - # If the resolved IP is private, the guard rejects. - self._patch_resolves(monkeypatch, {"example.com": ["10.1.2.3"]}) - assert not MCPServerManager._is_safe_metadata_url( + def test_same_host_different_port_uses_safe_fetch_path(self): + assert not MCPServerManager._is_same_authority_metadata_url( "https://example.com:9999/.well-known/oauth-protected-resource", "https://example.com/mcp", ) @@ -3023,48 +3019,133 @@ class TestOAuthDiscoverySSRFGuard: "fc00::1", # IPv6 ULA ], ) - def test_cross_origin_blocked_when_resolves_to_unsafe_ip(self, monkeypatch, ip): + @pytest.mark.asyncio + async def test_cross_origin_blocked_when_resolves_to_unsafe_ip( + self, monkeypatch, ip + ): self._patch_resolves(monkeypatch, {"attacker.example.com": [ip]}) - assert not MCPServerManager._is_safe_metadata_url( - f"https://attacker.example.com/.well-known/oauth-authorization-server", - "https://legit-mcp.example.com/mcp", - ) + manager = MCPServerManager() - def test_cross_origin_allowed_when_resolves_to_public_ip(self, monkeypatch): + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + result = await manager._fetch_single_authorization_server_metadata( + "https://attacker.example.com", + "https://legit-mcp.example.com/mcp", + ) + + assert result is None + mock_client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_cross_origin_allowed_when_resolves_to_public_ip(self, monkeypatch): self._patch_resolves( monkeypatch, {"login.microsoftonline.com": ["20.190.151.7"]} ) - assert MCPServerManager._is_safe_metadata_url( - "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration", - "https://atlassian-mcp.example.com/mcp", + manager = MCPServerManager() + + mock_response = MagicMock() + mock_response.is_redirect = False + mock_response.raise_for_status = MagicMock() + mock_response.json.return_value = { + "authorization_servers": ["https://login.microsoftonline.com/tenant/v2.0"], + "scopes_supported": ["mcp.read"], + } + + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + servers, scopes = await manager._fetch_oauth_metadata_from_resource( + "https://login.microsoftonline.com/tenant/v2.0/.well-known/openid-configuration", + "https://atlassian-mcp.example.com/mcp", + ) + + assert servers == ["https://login.microsoftonline.com/tenant/v2.0"] + assert scopes == ["mcp.read"] + mock_client.get.assert_awaited_once() + assert mock_client.get.await_args.kwargs["follow_redirects"] is False + assert ( + mock_client.get.await_args.kwargs["headers"]["Host"] + == "login.microsoftonline.com" ) - def test_cross_origin_blocked_when_unresolvable(self, monkeypatch): + @pytest.mark.asyncio + async def test_cross_origin_blocked_when_unresolvable(self, monkeypatch): self._patch_resolves(monkeypatch, {}) - assert not MCPServerManager._is_safe_metadata_url( - "https://nope.example.invalid/.well-known/oauth-authorization-server", - "https://legit-mcp.example.com/mcp", - ) + manager = MCPServerManager() - def test_non_http_scheme_is_not_safe(self): - assert not MCPServerManager._is_safe_metadata_url( - "file:///etc/passwd", "https://example.com/mcp" - ) - assert not MCPServerManager._is_safe_metadata_url( - "gopher://example.com/", "https://example.com/mcp" - ) + mock_client = MagicMock() + mock_client.get = AsyncMock() - def test_dual_resolution_blocked_if_any_ip_unsafe(self, monkeypatch): + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + servers, scopes = await manager._fetch_oauth_metadata_from_resource( + "https://nope.example.invalid/.well-known/oauth-authorization-server", + "https://legit-mcp.example.com/mcp", + ) + + assert servers == [] + assert scopes is None + mock_client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_non_http_scheme_is_not_safe(self): + manager = MCPServerManager() + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + servers, scopes = await manager._fetch_oauth_metadata_from_resource( + "file:///etc/passwd", + "https://example.com/mcp", + ) + result = await manager._fetch_single_authorization_server_metadata( + "gopher://example.com/", + "https://example.com/mcp", + ) + + assert servers == [] + assert scopes is None + assert result is None + mock_client.get.assert_not_called() + + @pytest.mark.asyncio + async def test_dual_resolution_blocked_if_any_ip_unsafe(self, monkeypatch): # If the attacker controls a DNS record returning multiple A records, - # one of which is private, the guard must reject — pinning to the - # safe IP would be a TOCTOU window. + # one of which is private, async_safe_get rejects before any network call. self._patch_resolves( monkeypatch, {"dual-stack.example.com": ["8.8.8.8", "127.0.0.1"]} ) - assert not MCPServerManager._is_safe_metadata_url( - "https://dual-stack.example.com/.well-known/oauth-authorization-server", - "https://legit-mcp.example.com/mcp", - ) + manager = MCPServerManager() + + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + servers, scopes = await manager._fetch_oauth_metadata_from_resource( + "https://dual-stack.example.com/.well-known/oauth-authorization-server", + "https://legit-mcp.example.com/mcp", + ) + + assert servers == [] + assert scopes is None + mock_client.get.assert_not_called() @pytest.mark.asyncio async def test_fetch_oauth_metadata_refuses_unsafe_url(self, monkeypatch): @@ -3089,23 +3170,67 @@ class TestOAuthDiscoverySSRFGuard: assert scopes is None mock_client.get.assert_not_called() - def test_empty_getaddrinfo_result_blocks_url(self, monkeypatch): + @pytest.mark.asyncio + async def test_empty_getaddrinfo_result_blocks_url(self, monkeypatch): # POSIX doesn't strictly forbid an empty success-list from getaddrinfo. - # The guard must fail closed rather than fall through to ``return True``. + # async_safe_get must fail closed rather than making a network call. monkeypatch.setattr( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.socket.getaddrinfo", + "litellm.litellm_core_utils.url_utils.socket.getaddrinfo", lambda *a, **k: [], ) - assert not MCPServerManager._is_safe_metadata_url( - "https://no-records.example.com/.well-known/oauth-authorization-server", - "https://legit-mcp.example.com/mcp", - ) + manager = MCPServerManager() + + mock_client = MagicMock() + mock_client.get = AsyncMock() + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + servers, scopes = await manager._fetch_oauth_metadata_from_resource( + "https://no-records.example.com/.well-known/oauth-authorization-server", + "https://legit-mcp.example.com/mcp", + ) + + assert servers == [] + assert scopes is None + mock_client.get.assert_not_called() @pytest.mark.asyncio - async def test_fetch_oauth_metadata_does_not_follow_redirects(self): - # If the validated origin redirects to a loopback or other unsafe - # address, httpx must NOT follow — the new ``Location`` would not - # be re-checked against ``server_url``. + async def test_cross_origin_redirect_is_revalidated(self, monkeypatch): + self._patch_resolves( + monkeypatch, + { + "provider.example.com": ["8.8.8.8"], + "127.0.0.1": ["127.0.0.1"], + }, + ) + manager = MCPServerManager() + + redirect_response = MagicMock() + redirect_response.is_redirect = True + redirect_response.headers = {"location": "http://127.0.0.1/admin"} + + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=redirect_response) + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ): + result = await manager._fetch_single_authorization_server_metadata( + "https://provider.example.com", + "https://legit-mcp.example.com/mcp", + ) + + assert result is None + assert mock_client.get.await_count == 3 + + @pytest.mark.asyncio + async def test_same_authority_fetch_does_not_follow_redirects(self): + # Same-authority URLs may be internal admin-configured MCP servers, so + # they are fetched directly. Redirects are still disabled because a + # Location target would not inherit the same-authority guarantee. manager = MCPServerManager() mock_response = MagicMock() @@ -3134,7 +3259,7 @@ class TestOAuthDiscoverySSRFGuard: assert captured_kwargs.get("follow_redirects") is False @pytest.mark.asyncio - async def test_fetch_single_auth_server_does_not_follow_redirects(self): + async def test_same_authority_auth_server_fetch_does_not_follow_redirects(self): # Same redirect-bypass concern for the authorization-server fetch path. manager = MCPServerManager() From 47b2832d6f573b7bc1fda3026aa273c3f942a9ce Mon Sep 17 00:00:00 2001 From: Michael Riad Zaky Date: Thu, 30 Apr 2026 16:15:46 -0700 Subject: [PATCH 47/47] test: replace subprocess startup-import diff with static source scan --- tests/test_litellm/proxy/test_proxy_server.py | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 7a96f6cbd1..17b03adf0a 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -5513,39 +5513,34 @@ class TestLazyFeaturesNotImportedAtStartup: """ def test_heavy_modules_absent_at_startup(self): - # Force a fresh `proxy_server` import in a subprocess so other tests - # in this run (which may have triggered lazy loads via the TestClient) - # don't pollute the result. - import subprocess + # Static scan of proxy_server.py source — catches any top-level + # `from import` that would defeat lazy loading. + # Importing proxy_server in a subprocess and diffing sys.modules + # would also work, but takes 60-120 s and flakes on slow CI runners. + import re + from pathlib import Path - check = ( - "import sys; " - "from litellm.proxy.proxy_server import app; " # noqa: F401 - "heavy = [" - "'litellm.proxy._experimental.mcp_server.rest_endpoints'," - "'litellm.proxy._experimental.mcp_server.server'," - "'litellm.proxy.management_endpoints.config_override_endpoints'," - "'litellm.proxy.guardrails.guardrail_endpoints'," - "'litellm.proxy.openai_evals_endpoints.endpoints'," - "]; " - "still_present = [m for m in heavy if m in sys.modules]; " - "print('PRESENT_AT_STARTUP:', still_present)" + from litellm.proxy._lazy_features import LAZY_FEATURES + + proxy_server_src = ( + Path(__file__).resolve().parents[3] / "litellm/proxy/proxy_server.py" + ).read_text() + + leaks = [] + for feat in LAZY_FEATURES: + # Anchor at column 0 — indented imports inside function bodies + # are fine (deferred until the function runs). + pattern = ( + rf"^(from\s+{re.escape(feat.module_path)}\s+import|" + rf"import\s+{re.escape(feat.module_path)})" + ) + if re.search(pattern, proxy_server_src, re.MULTILINE): + leaks.append(feat.module_path) + + assert not leaks, ( + "proxy_server.py top-level imports a lazy feature module — these " + f"should be loaded via LazyFeatureMiddleware: {leaks}" ) - result = subprocess.run( - [sys.executable, "-c", check], - capture_output=True, - text=True, - timeout=120, - ) - # Last non-empty line of stdout (skip warnings printed before) - out_lines = [ - line for line in result.stdout.strip().splitlines() if line.strip() - ] - report = next((line for line in out_lines if "PRESENT_AT_STARTUP" in line), "") - assert report, f"no report emitted (stderr: {result.stderr[-500:]})" - assert ( - "PRESENT_AT_STARTUP: []" in report - ), f"expected no heavy modules at startup, got: {report}" class TestLazyFeatureMiddleware: