diff --git a/litellm/proxy/common_utils/static_asset_utils.py b/litellm/proxy/common_utils/static_asset_utils.py index 0643572118b..ffead95dcfe 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 ad8c2672754..f14c6da5539 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 da3f963f9de..1a77e39aa80 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()