From 9363f36481b8602e7866398bd054a11dd9842e8e Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:13:54 +0000 Subject: [PATCH 01/15] fix(proxy): add SSRF protection via resolve-and-rewrite for user-supplied URLs Add validate_url() utility that resolves DNS once, validates all IPs against private network ranges, and rewrites the URL to connect to the validated IP directly. Prevents DNS rebinding by pinning to the resolved IP. Disable follow_redirects to prevent redirect-based SSRF bypasses. Applied to all user-supplied URL entry points: - Image URL fetching in chat completions - Token counter image dimension fetching - RAG file ingestion - MCP OpenAPI spec loading --- .../prompt_templates/image_handling.py | 19 ++- litellm/litellm_core_utils/token_counter.py | 10 +- .../mcp_server/openapi_to_mcp_generator.py | 10 +- litellm/proxy/common_utils/url_utils.py | 123 ++++++++++++++++++ litellm/rag/ingestion/base_ingestion.py | 8 +- 5 files changed, 162 insertions(+), 8 deletions(-) create mode 100644 litellm/proxy/common_utils/url_utils.py diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index eaf78b7bcf5..c0727699167 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -10,6 +10,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB +from litellm.proxy.common_utils.url_utils import SSRFError, validate_url MAX_IMGS_IN_MEMORY = 10 @@ -81,10 +82,17 @@ async def async_convert_url_to_base64(url: str) -> str: if cached_result: return cached_result + # Resolve DNS once, validate IPs, rewrite URL to validated IP + validated_url, original_host = validate_url(url) + client = litellm.module_level_aclient for _ in range(3): try: - response = await client.get(url, follow_redirects=True) + response = await client.get( + validated_url, + headers={"Host": original_host}, + follow_redirects=False, + ) return _process_image_response(response, url) except litellm.ImageFetchError: raise @@ -106,10 +114,17 @@ def convert_url_to_base64(url: str) -> str: if cached_result: return cached_result + # Resolve DNS once, validate IPs, rewrite URL to validated IP + validated_url, original_host = validate_url(url) + client = litellm.module_level_client for _ in range(3): try: - response = client.get(url, follow_redirects=True) + response = client.get( + validated_url, + headers={"Host": original_host}, + follow_redirects=False, + ) return _process_image_response(response, url) except litellm.ImageFetchError: raise diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 09c62f2eb55..e2d2a56c698 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -30,6 +30,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.default_encoding import encoding as default_encoding from litellm.llms.custom_httpx.http_handler import _get_httpx_client +from litellm.proxy.common_utils.url_utils import validate_url from litellm.types.llms.anthropic import ( AnthropicMessagesToolResultParam, AnthropicMessagesToolUseParam, @@ -211,9 +212,14 @@ def get_image_dimensions( """ img_data = None try: - # Try to open as URL + # Try to open as URL — validate and pin to resolved IP + validated_url, original_host = validate_url(data) client = _get_httpx_client() - response = client.get(data) + response = client.get( + validated_url, + headers={"Host": original_host}, + follow_redirects=False, + ) img_data = response.read() except Exception: # If not URL, assume it's base64 diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 4b4818892bb..68f52f34395 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -15,6 +15,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) +from litellm.proxy.common_utils.url_utils import validate_url from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, ) @@ -74,10 +75,13 @@ def load_openapi_spec(filepath: str) -> Dict[str, Any]: async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]: if filepath.startswith("http://") or filepath.startswith("https://"): + validated_url, original_host = validate_url(filepath) client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) - # NOTE: do not close shared client if get_async_httpx_client returns a shared singleton. - # If it returns a new client each time, consider wrapping it in an async context manager. - r = await client.get(filepath) + r = await client.get( + validated_url, + headers={"Host": original_host}, + follow_redirects=False, + ) r.raise_for_status() return r.json() diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py new file mode 100644 index 00000000000..97fee0c0965 --- /dev/null +++ b/litellm/proxy/common_utils/url_utils.py @@ -0,0 +1,123 @@ +""" +URL validation for user-controlled URLs. + +Use validate_url() before fetching any URL that originates from user +input (image_url, file_url, spec_path, etc.) to prevent SSRF attacks. + +The function resolves DNS once, validates all IPs, and rewrites the URL +to connect to the validated IP directly — no TOCTOU gap, no DNS rebinding. +Callers should also set follow_redirects=False to prevent redirect-based +SSRF bypasses. +""" + +import ipaddress +import socket +from ipaddress import ip_address, ip_network +from typing import Optional, Tuple +from urllib.parse import urlparse, urlunparse + +_BLOCKED_NETWORKS = [ + ip_network("0.0.0.0/8"), + ip_network("10.0.0.0/8"), + ip_network("100.64.0.0/10"), + ip_network("127.0.0.0/8"), + ip_network("169.254.0.0/16"), + ip_network("172.16.0.0/12"), + ip_network("192.0.0.0/24"), + ip_network("192.168.0.0/16"), + ip_network("198.18.0.0/15"), + ip_network("::1/128"), + ip_network("fc00::/7"), + ip_network("fe80::/10"), +] + +_ALLOWED_SCHEMES = ("http", "https") + + +class SSRFError(ValueError): + """Raised when a URL targets a blocked network.""" + + pass + + +def _is_blocked_ip(addr: str) -> bool: + try: + ip = ip_address(addr) + except ValueError: + return False + if ip.version == 6 and hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped: + ip = ip.ipv4_mapped + return any(ip in net for net in _BLOCKED_NETWORKS) + + +def validate_url(url: str) -> Tuple[str, str]: + """ + Validate a user-supplied URL and rewrite it to connect to a validated IP. + + Resolves the hostname, checks all resolved IPs against blocked networks, + then returns a rewritten URL that points to the validated IP along with + the original hostname (for use in the Host header). + + This eliminates DNS rebinding because the caller connects to the IP we + validated, not the hostname that could rebind. Callers should also disable + follow_redirects to prevent redirect-based SSRF bypasses. + + Args: + url: The user-supplied URL to validate. + + Returns: + Tuple of (rewritten_url, original_hostname). + The rewritten URL has the hostname replaced with the validated IP. + The original hostname should be set as the Host header. + + Raises: + SSRFError: If the URL scheme is invalid or the hostname resolves + to a private/internal IP address. + """ + parsed = urlparse(url) + + if parsed.scheme not in _ALLOWED_SCHEMES: + raise SSRFError(f"URL scheme '{parsed.scheme}' is not allowed") + + hostname = parsed.hostname + if not hostname: + raise SSRFError("URL has no hostname") + + port = parsed.port + default_port = 443 if parsed.scheme == "https" else 80 + + # Resolve hostname and validate ALL addresses + try: + addrinfo = socket.getaddrinfo( + hostname, port or default_port, proto=socket.IPPROTO_TCP + ) + except socket.gaierror as e: + raise SSRFError(f"DNS resolution failed for '{hostname}': {e}") + + if not addrinfo: + raise SSRFError(f"No addresses found for '{hostname}'") + + for family, type_, proto, canonname, sockaddr in addrinfo: + if _is_blocked_ip(sockaddr[0]): + raise SSRFError( + f"URL targets a blocked address ({sockaddr[0]}). " + "If this is a legitimate internal service, use a direct " + "provider configuration instead of a user-supplied URL." + ) + + # Rewrite URL to connect to the first validated IP + validated_ip = addrinfo[0][4][0] + is_ipv6 = addrinfo[0][0] == socket.AF_INET6 + ip_host = f"[{validated_ip}]" if is_ipv6 else validated_ip + + # Reconstruct netloc with IP instead of hostname + if port: + new_netloc = f"{ip_host}:{port}" + else: + new_netloc = ip_host + + rewritten = urlunparse( + (parsed.scheme, new_netloc, parsed.path, parsed.params, parsed.query, "") + ) + + return rewritten, hostname diff --git a/litellm/rag/ingestion/base_ingestion.py b/litellm/rag/ingestion/base_ingestion.py index 0d12bdfffc1..1d868d35c7a 100644 --- a/litellm/rag/ingestion/base_ingestion.py +++ b/litellm/rag/ingestion/base_ingestion.py @@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) +from litellm.proxy.common_utils.url_utils import validate_url from litellm.rag.ingestion.file_parsers import extract_text_from_pdf from litellm.rag.text_splitters import RecursiveCharacterTextSplitter from litellm.types.rag import RAGIngestOptions, RAGIngestResponse @@ -111,8 +112,13 @@ class BaseRAGIngestion(ABC): return filename, file_content, content_type, None if file_url: + validated_url, original_host = validate_url(file_url) http_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.RAG) - response = await http_client.get(file_url) + response = await http_client.get( + validated_url, + headers={"Host": original_host}, + follow_redirects=False, + ) response.raise_for_status() file_content = response.content filename = file_url.split("/")[-1] or "document" From d15196b5197d89270469faabaece0dd26cf1f4b6 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:30:14 +0000 Subject: [PATCH 02/15] fix(proxy): add safe_get/async_safe_get with redirect validation Add safe_get() and async_safe_get() helpers that validate each redirect hop before following. For HTTPS, rely on TLS certificate binding instead of URL rewriting. Simplify call sites to use the new helpers. --- .../prompt_templates/image_handling.py | 20 +----- litellm/litellm_core_utils/token_counter.py | 11 +-- .../mcp_server/openapi_to_mcp_generator.py | 9 +-- litellm/proxy/common_utils/url_utils.py | 70 +++++++++++++++++-- litellm/rag/ingestion/base_ingestion.py | 9 +-- 5 files changed, 73 insertions(+), 46 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index c0727699167..c036ec23ddb 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -10,7 +10,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB -from litellm.proxy.common_utils.url_utils import SSRFError, validate_url +from litellm.proxy.common_utils.url_utils import async_safe_get, safe_get MAX_IMGS_IN_MEMORY = 10 @@ -82,17 +82,10 @@ async def async_convert_url_to_base64(url: str) -> str: if cached_result: return cached_result - # Resolve DNS once, validate IPs, rewrite URL to validated IP - validated_url, original_host = validate_url(url) - client = litellm.module_level_aclient for _ in range(3): try: - response = await client.get( - validated_url, - headers={"Host": original_host}, - follow_redirects=False, - ) + response = await async_safe_get(client, url) return _process_image_response(response, url) except litellm.ImageFetchError: raise @@ -114,17 +107,10 @@ def convert_url_to_base64(url: str) -> str: if cached_result: return cached_result - # Resolve DNS once, validate IPs, rewrite URL to validated IP - validated_url, original_host = validate_url(url) - client = litellm.module_level_client for _ in range(3): try: - response = client.get( - validated_url, - headers={"Host": original_host}, - follow_redirects=False, - ) + response = safe_get(client, url) return _process_image_response(response, url) except litellm.ImageFetchError: raise diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index e2d2a56c698..ad2691156a5 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -30,7 +30,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.default_encoding import encoding as default_encoding from litellm.llms.custom_httpx.http_handler import _get_httpx_client -from litellm.proxy.common_utils.url_utils import validate_url +from litellm.proxy.common_utils.url_utils import safe_get from litellm.types.llms.anthropic import ( AnthropicMessagesToolResultParam, AnthropicMessagesToolUseParam, @@ -212,14 +212,9 @@ def get_image_dimensions( """ img_data = None try: - # Try to open as URL — validate and pin to resolved IP - validated_url, original_host = validate_url(data) + # Try to open as URL with SSRF protection client = _get_httpx_client() - response = client.get( - validated_url, - headers={"Host": original_host}, - follow_redirects=False, - ) + response = safe_get(client, data) img_data = response.read() except Exception: # If not URL, assume it's base64 diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 68f52f34395..d6b2cb86b26 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -15,7 +15,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.proxy.common_utils.url_utils import validate_url +from litellm.proxy.common_utils.url_utils import async_safe_get from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, ) @@ -75,13 +75,8 @@ def load_openapi_spec(filepath: str) -> Dict[str, Any]: async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]: if filepath.startswith("http://") or filepath.startswith("https://"): - validated_url, original_host = validate_url(filepath) client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) - r = await client.get( - validated_url, - headers={"Host": original_host}, - follow_redirects=False, - ) + r = await async_safe_get(client, filepath) r.raise_for_status() return r.json() diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py index 97fee0c0965..0583cc3a42b 100644 --- a/litellm/proxy/common_utils/url_utils.py +++ b/litellm/proxy/common_utils/url_utils.py @@ -4,16 +4,15 @@ URL validation for user-controlled URLs. Use validate_url() before fetching any URL that originates from user input (image_url, file_url, spec_path, etc.) to prevent SSRF attacks. -The function resolves DNS once, validates all IPs, and rewrites the URL -to connect to the validated IP directly — no TOCTOU gap, no DNS rebinding. -Callers should also set follow_redirects=False to prevent redirect-based -SSRF bypasses. +validate_url() resolves DNS once, validates all IPs, and rewrites the +URL to connect to the validated IP directly — no TOCTOU gap, no DNS +rebinding. Redirects are followed manually with validation at each hop. """ import ipaddress import socket from ipaddress import ip_address, ip_network -from typing import Optional, Tuple +from typing import Any, Optional, Tuple, Union from urllib.parse import urlparse, urlunparse _BLOCKED_NETWORKS = [ @@ -105,12 +104,18 @@ def validate_url(url: str) -> Tuple[str, str]: "provider configuration instead of a user-supplied URL." ) - # Rewrite URL to connect to the first validated IP + # For HTTPS, TLS certificate validation binds the connection to the + # hostname — DNS rebinding can't redirect to a different server because + # the cert wouldn't match. Return the original URL. + if parsed.scheme == "https": + return url, hostname + + # For HTTP, rewrite URL to connect to the validated IP directly + # to prevent DNS rebinding (no TLS to bind the connection). validated_ip = addrinfo[0][4][0] is_ipv6 = addrinfo[0][0] == socket.AF_INET6 ip_host = f"[{validated_ip}]" if is_ipv6 else validated_ip - # Reconstruct netloc with IP instead of hostname if port: new_netloc = f"{ip_host}:{port}" else: @@ -121,3 +126,54 @@ def validate_url(url: str) -> Tuple[str, str]: ) return rewritten, hostname + + +_MAX_REDIRECTS = 10 + + +def safe_get(client: Any, url: str, **kwargs: Any) -> Any: + """ + Fetch a user-supplied URL with SSRF protection on every redirect hop. + + Validates the initial URL and each redirect target before making the + request. No DNS rebinding (resolve-and-rewrite). No redirect bypass + (each hop validated). No breaking change for legitimate CDN redirects. + + Args: + client: An httpx.Client or httpx.AsyncClient (sync version). + url: The user-supplied URL. + **kwargs: Additional kwargs passed to client.get(). + + Returns: + The final httpx.Response. + """ + kwargs.pop("follow_redirects", None) + for _ in range(_MAX_REDIRECTS): + validated_url, original_host = validate_url(url) + response = client.get( + validated_url, + headers={**kwargs.pop("headers", {}), "Host": original_host}, + follow_redirects=False, + **kwargs, + ) + if not response.is_redirect or response.next_request is None: + return response + url = str(response.next_request.url) + raise SSRFError("Too many redirects") + + +async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any: + """Async version of safe_get.""" + kwargs.pop("follow_redirects", None) + for _ in range(_MAX_REDIRECTS): + validated_url, original_host = validate_url(url) + response = await client.get( + validated_url, + headers={**kwargs.pop("headers", {}), "Host": original_host}, + follow_redirects=False, + **kwargs, + ) + if not response.is_redirect or response.next_request is None: + return response + url = str(response.next_request.url) + raise SSRFError("Too many redirects") diff --git a/litellm/rag/ingestion/base_ingestion.py b/litellm/rag/ingestion/base_ingestion.py index 1d868d35c7a..6f139764a85 100644 --- a/litellm/rag/ingestion/base_ingestion.py +++ b/litellm/rag/ingestion/base_ingestion.py @@ -24,7 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.proxy.common_utils.url_utils import validate_url +from litellm.proxy.common_utils.url_utils import async_safe_get from litellm.rag.ingestion.file_parsers import extract_text_from_pdf from litellm.rag.text_splitters import RecursiveCharacterTextSplitter from litellm.types.rag import RAGIngestOptions, RAGIngestResponse @@ -112,13 +112,8 @@ class BaseRAGIngestion(ABC): return filename, file_content, content_type, None if file_url: - validated_url, original_host = validate_url(file_url) http_client = get_async_httpx_client(llm_provider=httpxSpecialProvider.RAG) - response = await http_client.get( - validated_url, - headers={"Host": original_host}, - follow_redirects=False, - ) + response = await async_safe_get(http_client, file_url) response.raise_for_status() file_content = response.content filename = file_url.split("/")[-1] or "document" From 037fb573f77e22aa67733ea3b755d515ebcc5904 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:38:29 +0000 Subject: [PATCH 03/15] fix: preserve caller headers across redirect hops in safe_get --- litellm/proxy/common_utils/url_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py index 0583cc3a42b..2a160d3cec7 100644 --- a/litellm/proxy/common_utils/url_utils.py +++ b/litellm/proxy/common_utils/url_utils.py @@ -148,11 +148,12 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: The final httpx.Response. """ kwargs.pop("follow_redirects", None) + caller_headers = kwargs.pop("headers", {}) for _ in range(_MAX_REDIRECTS): validated_url, original_host = validate_url(url) response = client.get( validated_url, - headers={**kwargs.pop("headers", {}), "Host": original_host}, + headers={**caller_headers, "Host": original_host}, follow_redirects=False, **kwargs, ) @@ -165,11 +166,12 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any: """Async version of safe_get.""" kwargs.pop("follow_redirects", None) + caller_headers = kwargs.pop("headers", {}) for _ in range(_MAX_REDIRECTS): validated_url, original_host = validate_url(url) response = await client.get( validated_url, - headers={**kwargs.pop("headers", {}), "Host": original_host}, + headers={**caller_headers, "Host": original_host}, follow_redirects=False, **kwargs, ) From b94aaa72b07ce1b634b3458af620a4554fb90991 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:40:54 +0000 Subject: [PATCH 04/15] fix: skip DNS resolution for base64 data in token counter, add unit tests Check URL scheme before calling safe_get in token counter to avoid unnecessary DNS resolution on base64-encoded image data. Add 14 unit tests for validate_url covering blocked networks, scheme validation, URL rewriting, and DNS failure handling. --- litellm/litellm_core_utils/token_counter.py | 16 +++-- .../proxy/common_utils/test_url_utils.py | 64 +++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/proxy/common_utils/test_url_utils.py diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index ad2691156a5..6245828a381 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -211,13 +211,15 @@ def get_image_dimensions( Tuple[int, int]: The width and height of the image. """ img_data = None - try: - # Try to open as URL with SSRF protection - client = _get_httpx_client() - response = safe_get(client, data) - img_data = response.read() - except Exception: - # If not URL, assume it's base64 + if data.startswith(("http://", "https://")): + try: + client = _get_httpx_client() + response = safe_get(client, data) + img_data = response.read() + except Exception: + pass + if img_data is None: + # Not a URL or fetch failed — assume base64 _header, encoded = data.split(",", 1) img_data = base64.b64decode(encoded) diff --git a/tests/test_litellm/proxy/common_utils/test_url_utils.py b/tests/test_litellm/proxy/common_utils/test_url_utils.py new file mode 100644 index 00000000000..73f465cfbc4 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_url_utils.py @@ -0,0 +1,64 @@ +import pytest + +from litellm.proxy.common_utils.url_utils import SSRFError, validate_url + + +class TestValidateUrl: + def test_blocks_loopback(self): + with pytest.raises(SSRFError): + validate_url("http://127.0.0.1/test") + + def test_blocks_imds(self): + with pytest.raises(SSRFError): + validate_url("http://169.254.169.254/latest/meta-data/") + + def test_blocks_rfc1918_class_a(self): + with pytest.raises(SSRFError): + validate_url("http://10.0.1.5:8080/v1/completions") + + def test_blocks_rfc1918_class_b(self): + with pytest.raises(SSRFError): + validate_url("http://172.16.0.1/") + + def test_blocks_rfc1918_class_c(self): + with pytest.raises(SSRFError): + validate_url("http://192.168.1.1/") + + def test_blocks_file_scheme(self): + with pytest.raises(SSRFError): + validate_url("file:///etc/passwd") + + def test_blocks_ftp_scheme(self): + with pytest.raises(SSRFError): + validate_url("ftp://internal.host/data") + + def test_blocks_no_hostname(self): + with pytest.raises(SSRFError): + validate_url("http:///path") + + def test_allows_public_https(self): + rewritten, host = validate_url("https://example.com/image.png") + assert host == "example.com" + assert rewritten == "https://example.com/image.png" + + def test_rewrites_public_http_to_ip(self): + rewritten, host = validate_url("http://example.com/image.png") + assert host == "example.com" + assert "example.com" not in rewritten + + def test_preserves_path_and_query(self): + rewritten, host = validate_url("http://example.com/path?key=value") + assert "/path" in rewritten + assert "key=value" in rewritten + + def test_dns_failure_raises(self): + with pytest.raises(SSRFError, match="DNS resolution failed"): + validate_url("http://this-domain-does-not-exist-xyz123.invalid/test") + + def test_blocks_localhost_hostname(self): + with pytest.raises(SSRFError): + validate_url("http://localhost/") + + def test_blocks_ipv6_loopback(self): + with pytest.raises(SSRFError): + validate_url("http://[::1]/") From 62ec39677586ae0588e5dd919d6186e4da010659 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:44:10 +0000 Subject: [PATCH 05/15] test: mock SSRF validation in openapi spec URL test --- tests/mcp_tests/test_openapi_spec_path_url.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/mcp_tests/test_openapi_spec_path_url.py b/tests/mcp_tests/test_openapi_spec_path_url.py index 03e9db94967..17a0022046e 100644 --- a/tests/mcp_tests/test_openapi_spec_path_url.py +++ b/tests/mcp_tests/test_openapi_spec_path_url.py @@ -55,6 +55,11 @@ def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> # Ensure shared/custom client path is used monkeypatch.setattr(gen, "get_async_httpx_client", fake_get_async_httpx_client) + # Bypass SSRF validation in test (example.local doesn't resolve) + monkeypatch.setattr( + gen, "async_safe_get", lambda client, url, **kw: client.get(url) + ) + # Fail loudly if someone reintroduces direct httpx.get() def boom(*args, **kwargs): raise AssertionError("Direct httpx.get() must not be used for URL spec loading") @@ -68,7 +73,9 @@ def test_load_openapi_spec_supports_http_url(monkeypatch: pytest.MonkeyPatch) -> assert handler_holder["handler"].calls == 1 -def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_load_openapi_spec_supports_local_file_path( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: expected: Dict[str, Any] = { "openapi": "3.0.0", "info": {"title": "Local API", "version": "1.0.0"}, @@ -83,10 +90,11 @@ def test_load_openapi_spec_supports_local_file_path(tmp_path, monkeypatch: pytes # For local files, shared client must NOT be used. def boom_client(*args, **kwargs): - raise AssertionError("get_async_httpx_client() must not be called for local file paths") + raise AssertionError( + "get_async_httpx_client() must not be called for local file paths" + ) monkeypatch.setattr(gen, "get_async_httpx_client", boom_client) spec = gen.load_openapi_spec(str(p)) assert spec == expected - From 814d03d1cee07128411c4492a67eb0813c9eb195 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:48:12 +0000 Subject: [PATCH 06/15] fix: fail-closed on unparseable IPs, rewrite HTTPS when SSL verify disabled _is_blocked_ip now returns True (blocked) for unparseable addresses instead of False (allowed). HTTPS URLs are rewritten to validated IPs when ssl_verify is disabled, closing the DNS rebinding window that exists without TLS certificate binding. --- litellm/proxy/common_utils/url_utils.py | 15 +++++++---- .../proxy/common_utils/test_url_utils.py | 25 ++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py index 2a160d3cec7..72cbc776589 100644 --- a/litellm/proxy/common_utils/url_utils.py +++ b/litellm/proxy/common_utils/url_utils.py @@ -15,6 +15,8 @@ from ipaddress import ip_address, ip_network from typing import Any, Optional, Tuple, Union from urllib.parse import urlparse, urlunparse +import litellm + _BLOCKED_NETWORKS = [ ip_network("0.0.0.0/8"), ip_network("10.0.0.0/8"), @@ -43,7 +45,7 @@ def _is_blocked_ip(addr: str) -> bool: try: ip = ip_address(addr) except ValueError: - return False + return True # fail-closed: unparseable addresses are blocked if ip.version == 6 and hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped: ip = ip.ipv4_mapped return any(ip in net for net in _BLOCKED_NETWORKS) @@ -104,10 +106,13 @@ def validate_url(url: str) -> Tuple[str, str]: "provider configuration instead of a user-supplied URL." ) - # For HTTPS, TLS certificate validation binds the connection to the - # hostname — DNS rebinding can't redirect to a different server because - # the cert wouldn't match. Return the original URL. - if parsed.scheme == "https": + # For HTTPS with SSL verification enabled, TLS certificate validation + # binds the connection to the hostname — DNS rebinding can't redirect + # to a different server because the cert wouldn't match. + # When SSL verification is disabled, this defense doesn't apply, so + # we rewrite to the validated IP like HTTP. + ssl_verify = getattr(litellm, "ssl_verify", True) + if parsed.scheme == "https" and ssl_verify is not False: return url, hostname # For HTTP, rewrite URL to connect to the validated IP directly diff --git a/tests/test_litellm/proxy/common_utils/test_url_utils.py b/tests/test_litellm/proxy/common_utils/test_url_utils.py index 73f465cfbc4..4dbda6a815e 100644 --- a/tests/test_litellm/proxy/common_utils/test_url_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_url_utils.py @@ -1,6 +1,18 @@ import pytest -from litellm.proxy.common_utils.url_utils import SSRFError, validate_url +import litellm +from litellm.proxy.common_utils.url_utils import SSRFError, _is_blocked_ip, validate_url + + +class TestIsBlockedIp: + def test_blocks_private(self): + assert _is_blocked_ip("10.0.0.1") is True + + def test_allows_public(self): + assert _is_blocked_ip("8.8.8.8") is False + + def test_unparseable_is_blocked(self): + assert _is_blocked_ip("not-an-ip") is True class TestValidateUrl: @@ -62,3 +74,14 @@ class TestValidateUrl: def test_blocks_ipv6_loopback(self): with pytest.raises(SSRFError): validate_url("http://[::1]/") + + def test_https_rewrites_when_ssl_verify_disabled(self, monkeypatch): + monkeypatch.setattr(litellm, "ssl_verify", False) + rewritten, host = validate_url("https://example.com/image.png") + assert host == "example.com" + assert "example.com" not in rewritten # rewritten to IP + + def test_https_not_rewritten_when_ssl_verify_enabled(self, monkeypatch): + monkeypatch.setattr(litellm, "ssl_verify", True) + rewritten, host = validate_url("https://example.com/image.png") + assert rewritten == "https://example.com/image.png" From e2a0c96663548d36ebca5a530e9140da530eb19e Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:53:09 +0000 Subject: [PATCH 07/15] fix: redirect loop was dead code, clean up imports Read Location header directly instead of response.next_request (which is None when follow_redirects=False). Resolve relative redirect URLs with httpx.URL.join(). Remove unused imports. --- litellm/proxy/common_utils/url_utils.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py index 72cbc776589..f18378f9007 100644 --- a/litellm/proxy/common_utils/url_utils.py +++ b/litellm/proxy/common_utils/url_utils.py @@ -9,10 +9,10 @@ URL to connect to the validated IP directly — no TOCTOU gap, no DNS rebinding. Redirects are followed manually with validation at each hop. """ -import ipaddress +import asyncio import socket from ipaddress import ip_address, ip_network -from typing import Any, Optional, Tuple, Union +from typing import Any, Tuple from urllib.parse import urlparse, urlunparse import litellm @@ -136,6 +136,17 @@ def validate_url(url: str) -> Tuple[str, str]: _MAX_REDIRECTS = 10 +def _extract_redirect_url(response: Any, request_url: str) -> str: + """Extract and resolve the redirect target from a response's Location header.""" + import httpx + + location = response.headers.get("location") + if not location: + raise SSRFError("Redirect response has no Location header") + # Resolve relative URLs against the request URL + return str(httpx.URL(request_url).join(location)) + + def safe_get(client: Any, url: str, **kwargs: Any) -> Any: """ Fetch a user-supplied URL with SSRF protection on every redirect hop. @@ -145,7 +156,7 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: (each hop validated). No breaking change for legitimate CDN redirects. Args: - client: An httpx.Client or httpx.AsyncClient (sync version). + client: An httpx.Client (sync). url: The user-supplied URL. **kwargs: Additional kwargs passed to client.get(). @@ -162,9 +173,9 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: follow_redirects=False, **kwargs, ) - if not response.is_redirect or response.next_request is None: + if not response.is_redirect: return response - url = str(response.next_request.url) + url = _extract_redirect_url(response, validated_url) raise SSRFError("Too many redirects") @@ -180,7 +191,7 @@ async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any: follow_redirects=False, **kwargs, ) - if not response.is_redirect or response.next_request is None: + if not response.is_redirect: return response - url = str(response.next_request.url) + url = _extract_redirect_url(response, validated_url) raise SSRFError("Too many redirects") From 00b25d6ca4f55890cc1fb07484c8c37205677bb1 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:55:56 +0000 Subject: [PATCH 08/15] fix: sync redirect bypass, Host header port, redirect loop dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass follow_redirects through in HTTPHandler.get() — previously the parameter was accepted but never forwarded to the underlying httpx client, making sync redirect protection ineffective. Include port in Host header when non-default (e.g. example.com:8080). Fix redirect loop to read Location header directly instead of response.next_request (which is None when follow_redirects=False). --- litellm/llms/custom_httpx/http_handler.py | 1 + litellm/proxy/common_utils/url_utils.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/litellm/llms/custom_httpx/http_handler.py b/litellm/llms/custom_httpx/http_handler.py index 489a56daf8e..03d2af72329 100644 --- a/litellm/llms/custom_httpx/http_handler.py +++ b/litellm/llms/custom_httpx/http_handler.py @@ -1019,6 +1019,7 @@ class HTTPHandler: url, params=params, headers=headers, + follow_redirects=_follow_redirects, ) return response diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/proxy/common_utils/url_utils.py index f18378f9007..a47d7ae2ca4 100644 --- a/litellm/proxy/common_utils/url_utils.py +++ b/litellm/proxy/common_utils/url_utils.py @@ -87,6 +87,11 @@ def validate_url(url: str) -> Tuple[str, str]: port = parsed.port default_port = 443 if parsed.scheme == "https" else 80 + # Build the Host header value — include port when non-default + host_header = ( + hostname if (port is None or port == default_port) else f"{hostname}:{port}" + ) + # Resolve hostname and validate ALL addresses try: addrinfo = socket.getaddrinfo( @@ -113,7 +118,7 @@ def validate_url(url: str) -> Tuple[str, str]: # we rewrite to the validated IP like HTTP. ssl_verify = getattr(litellm, "ssl_verify", True) if parsed.scheme == "https" and ssl_verify is not False: - return url, hostname + return url, host_header # For HTTP, rewrite URL to connect to the validated IP directly # to prevent DNS rebinding (no TLS to bind the connection). @@ -130,7 +135,7 @@ def validate_url(url: str) -> Tuple[str, str]: (parsed.scheme, new_netloc, parsed.path, parsed.params, parsed.query, "") ) - return rewritten, hostname + return rewritten, host_header _MAX_REDIRECTS = 10 From 1ba2be77aed17a35ebf74d963f67040ec5e03477 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 04:59:25 +0000 Subject: [PATCH 09/15] refactor: move url_utils to litellm_core_utils to avoid proxy dependency SDK core modules (image_handling, token_counter) should not import from litellm.proxy. Move url_utils.py to litellm_core_utils/ so bare SDK installs without proxy dependencies still work. --- litellm/litellm_core_utils/prompt_templates/image_handling.py | 2 +- litellm/litellm_core_utils/token_counter.py | 2 +- litellm/{proxy/common_utils => litellm_core_utils}/url_utils.py | 0 .../proxy/_experimental/mcp_server/openapi_to_mcp_generator.py | 2 +- litellm/proxy/_experimental/out/404/index.html | 1 + litellm/proxy/_experimental/out/_not-found/index.html | 1 + litellm/proxy/_experimental/out/api-reference/index.html | 1 + litellm/proxy/_experimental/out/chat/index.html | 1 + .../_experimental/out/experimental/api-playground/index.html | 1 + litellm/proxy/_experimental/out/experimental/budgets/index.html | 1 + litellm/proxy/_experimental/out/experimental/caching/index.html | 1 + .../out/experimental/claude-code-plugins/index.html | 1 + .../proxy/_experimental/out/experimental/old-usage/index.html | 1 + litellm/proxy/_experimental/out/experimental/prompts/index.html | 1 + .../_experimental/out/experimental/tag-management/index.html | 1 + litellm/proxy/_experimental/out/guardrails/index.html | 1 + litellm/proxy/_experimental/out/login/index.html | 1 + litellm/proxy/_experimental/out/logs/index.html | 1 + litellm/proxy/_experimental/out/mcp/oauth/callback/index.html | 1 + litellm/proxy/_experimental/out/model-hub/index.html | 1 + litellm/proxy/_experimental/out/model_hub/index.html | 1 + litellm/proxy/_experimental/out/model_hub_table/index.html | 1 + litellm/proxy/_experimental/out/models-and-endpoints/index.html | 1 + litellm/proxy/_experimental/out/onboarding/index.html | 1 + litellm/proxy/_experimental/out/organizations/index.html | 1 + litellm/proxy/_experimental/out/playground/index.html | 1 + litellm/proxy/_experimental/out/policies/index.html | 1 + .../proxy/_experimental/out/settings/admin-settings/index.html | 1 + .../_experimental/out/settings/logging-and-alerts/index.html | 1 + .../proxy/_experimental/out/settings/router-settings/index.html | 1 + litellm/proxy/_experimental/out/settings/ui-theme/index.html | 1 + litellm/proxy/_experimental/out/teams/index.html | 1 + litellm/proxy/_experimental/out/test-key/index.html | 1 + litellm/proxy/_experimental/out/tools/mcp-servers/index.html | 1 + litellm/proxy/_experimental/out/tools/vector-stores/index.html | 1 + litellm/proxy/_experimental/out/usage/index.html | 1 + litellm/proxy/_experimental/out/users/index.html | 1 + litellm/proxy/_experimental/out/virtual-keys/index.html | 1 + litellm/rag/ingestion/base_ingestion.py | 2 +- .../common_utils => litellm_core_utils}/test_url_utils.py | 2 +- 40 files changed, 39 insertions(+), 5 deletions(-) rename litellm/{proxy/common_utils => litellm_core_utils}/url_utils.py (100%) create mode 100644 litellm/proxy/_experimental/out/404/index.html create mode 100644 litellm/proxy/_experimental/out/_not-found/index.html create mode 100644 litellm/proxy/_experimental/out/api-reference/index.html create mode 100644 litellm/proxy/_experimental/out/chat/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/api-playground/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/budgets/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/caching/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/old-usage/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/prompts/index.html create mode 100644 litellm/proxy/_experimental/out/experimental/tag-management/index.html create mode 100644 litellm/proxy/_experimental/out/guardrails/index.html create mode 100644 litellm/proxy/_experimental/out/login/index.html create mode 100644 litellm/proxy/_experimental/out/logs/index.html create mode 100644 litellm/proxy/_experimental/out/mcp/oauth/callback/index.html create mode 100644 litellm/proxy/_experimental/out/model-hub/index.html create mode 100644 litellm/proxy/_experimental/out/model_hub/index.html create mode 100644 litellm/proxy/_experimental/out/model_hub_table/index.html create mode 100644 litellm/proxy/_experimental/out/models-and-endpoints/index.html create mode 100644 litellm/proxy/_experimental/out/onboarding/index.html create mode 100644 litellm/proxy/_experimental/out/organizations/index.html create mode 100644 litellm/proxy/_experimental/out/playground/index.html create mode 100644 litellm/proxy/_experimental/out/policies/index.html create mode 100644 litellm/proxy/_experimental/out/settings/admin-settings/index.html create mode 100644 litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html create mode 100644 litellm/proxy/_experimental/out/settings/router-settings/index.html create mode 100644 litellm/proxy/_experimental/out/settings/ui-theme/index.html create mode 100644 litellm/proxy/_experimental/out/teams/index.html create mode 100644 litellm/proxy/_experimental/out/test-key/index.html create mode 100644 litellm/proxy/_experimental/out/tools/mcp-servers/index.html create mode 100644 litellm/proxy/_experimental/out/tools/vector-stores/index.html create mode 100644 litellm/proxy/_experimental/out/usage/index.html create mode 100644 litellm/proxy/_experimental/out/users/index.html create mode 100644 litellm/proxy/_experimental/out/virtual-keys/index.html rename tests/test_litellm/{proxy/common_utils => litellm_core_utils}/test_url_utils.py (97%) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index c036ec23ddb..fd38bc9388d 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -10,7 +10,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB -from litellm.proxy.common_utils.url_utils import async_safe_get, safe_get +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get MAX_IMGS_IN_MEMORY = 10 diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index 6245828a381..01e5dc39a34 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -30,7 +30,7 @@ from litellm.constants import ( ) from litellm.litellm_core_utils.default_encoding import encoding as default_encoding from litellm.llms.custom_httpx.http_handler import _get_httpx_client -from litellm.proxy.common_utils.url_utils import safe_get +from litellm.litellm_core_utils.url_utils import safe_get from litellm.types.llms.anthropic import ( AnthropicMessagesToolResultParam, AnthropicMessagesToolUseParam, diff --git a/litellm/proxy/common_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py similarity index 100% rename from litellm/proxy/common_utils/url_utils.py rename to litellm/litellm_core_utils/url_utils.py diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index d6b2cb86b26..3b2fa097b70 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -15,7 +15,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.proxy.common_utils.url_utils import async_safe_get +from litellm.litellm_core_utils.url_utils import async_safe_get from litellm.proxy._experimental.mcp_server.tool_registry import ( global_mcp_tool_registry, ) diff --git a/litellm/proxy/_experimental/out/404/index.html b/litellm/proxy/_experimental/out/404/index.html new file mode 100644 index 00000000000..344481d3aed --- /dev/null +++ b/litellm/proxy/_experimental/out/404/index.html @@ -0,0 +1 @@ +404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/_not-found/index.html b/litellm/proxy/_experimental/out/_not-found/index.html new file mode 100644 index 00000000000..344481d3aed --- /dev/null +++ b/litellm/proxy/_experimental/out/_not-found/index.html @@ -0,0 +1 @@ +404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/api-reference/index.html b/litellm/proxy/_experimental/out/api-reference/index.html new file mode 100644 index 00000000000..b636faba290 --- /dev/null +++ b/litellm/proxy/_experimental/out/api-reference/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/chat/index.html b/litellm/proxy/_experimental/out/chat/index.html new file mode 100644 index 00000000000..0d684c66cb5 --- /dev/null +++ b/litellm/proxy/_experimental/out/chat/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/api-playground/index.html b/litellm/proxy/_experimental/out/experimental/api-playground/index.html new file mode 100644 index 00000000000..5268cc3d9ca --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/api-playground/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/budgets/index.html b/litellm/proxy/_experimental/out/experimental/budgets/index.html new file mode 100644 index 00000000000..f463b2d5df3 --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/budgets/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/caching/index.html b/litellm/proxy/_experimental/out/experimental/caching/index.html new file mode 100644 index 00000000000..cf2a1aa14a0 --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/caching/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html new file mode 100644 index 00000000000..069f97b082a --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/claude-code-plugins/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/old-usage/index.html b/litellm/proxy/_experimental/out/experimental/old-usage/index.html new file mode 100644 index 00000000000..53540d126c4 --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/old-usage/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/prompts/index.html b/litellm/proxy/_experimental/out/experimental/prompts/index.html new file mode 100644 index 00000000000..615f06b8166 --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/prompts/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/experimental/tag-management/index.html b/litellm/proxy/_experimental/out/experimental/tag-management/index.html new file mode 100644 index 00000000000..e7d0631c339 --- /dev/null +++ b/litellm/proxy/_experimental/out/experimental/tag-management/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/guardrails/index.html b/litellm/proxy/_experimental/out/guardrails/index.html new file mode 100644 index 00000000000..ebbe174662b --- /dev/null +++ b/litellm/proxy/_experimental/out/guardrails/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/login/index.html b/litellm/proxy/_experimental/out/login/index.html new file mode 100644 index 00000000000..54472c6cc11 --- /dev/null +++ b/litellm/proxy/_experimental/out/login/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
🚅 LiteLLM
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/logs/index.html b/litellm/proxy/_experimental/out/logs/index.html new file mode 100644 index 00000000000..ec43b677a2f --- /dev/null +++ b/litellm/proxy/_experimental/out/logs/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html new file mode 100644 index 00000000000..830060c7aa2 --- /dev/null +++ b/litellm/proxy/_experimental/out/mcp/oauth/callback/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/model-hub/index.html b/litellm/proxy/_experimental/out/model-hub/index.html new file mode 100644 index 00000000000..506c3695285 --- /dev/null +++ b/litellm/proxy/_experimental/out/model-hub/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/model_hub/index.html b/litellm/proxy/_experimental/out/model_hub/index.html new file mode 100644 index 00000000000..27bac5cde7d --- /dev/null +++ b/litellm/proxy/_experimental/out/model_hub/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/model_hub_table/index.html b/litellm/proxy/_experimental/out/model_hub_table/index.html new file mode 100644 index 00000000000..db5d0e6a718 --- /dev/null +++ b/litellm/proxy/_experimental/out/model_hub_table/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/models-and-endpoints/index.html b/litellm/proxy/_experimental/out/models-and-endpoints/index.html new file mode 100644 index 00000000000..96c1a43a7c0 --- /dev/null +++ b/litellm/proxy/_experimental/out/models-and-endpoints/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/onboarding/index.html b/litellm/proxy/_experimental/out/onboarding/index.html new file mode 100644 index 00000000000..5c2121443f9 --- /dev/null +++ b/litellm/proxy/_experimental/out/onboarding/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/organizations/index.html b/litellm/proxy/_experimental/out/organizations/index.html new file mode 100644 index 00000000000..51dd7d1c764 --- /dev/null +++ b/litellm/proxy/_experimental/out/organizations/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/playground/index.html b/litellm/proxy/_experimental/out/playground/index.html new file mode 100644 index 00000000000..41ef863e95b --- /dev/null +++ b/litellm/proxy/_experimental/out/playground/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/policies/index.html b/litellm/proxy/_experimental/out/policies/index.html new file mode 100644 index 00000000000..a452ae4c4aa --- /dev/null +++ b/litellm/proxy/_experimental/out/policies/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/settings/admin-settings/index.html b/litellm/proxy/_experimental/out/settings/admin-settings/index.html new file mode 100644 index 00000000000..b29b4856b0d --- /dev/null +++ b/litellm/proxy/_experimental/out/settings/admin-settings/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html new file mode 100644 index 00000000000..7d5d218fda4 --- /dev/null +++ b/litellm/proxy/_experimental/out/settings/logging-and-alerts/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/settings/router-settings/index.html b/litellm/proxy/_experimental/out/settings/router-settings/index.html new file mode 100644 index 00000000000..eb3fd3fde00 --- /dev/null +++ b/litellm/proxy/_experimental/out/settings/router-settings/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/settings/ui-theme/index.html b/litellm/proxy/_experimental/out/settings/ui-theme/index.html new file mode 100644 index 00000000000..17d352321c5 --- /dev/null +++ b/litellm/proxy/_experimental/out/settings/ui-theme/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/teams/index.html b/litellm/proxy/_experimental/out/teams/index.html new file mode 100644 index 00000000000..781441c0732 --- /dev/null +++ b/litellm/proxy/_experimental/out/teams/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/test-key/index.html b/litellm/proxy/_experimental/out/test-key/index.html new file mode 100644 index 00000000000..22c06d24381 --- /dev/null +++ b/litellm/proxy/_experimental/out/test-key/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/tools/mcp-servers/index.html b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html new file mode 100644 index 00000000000..64b747528e0 --- /dev/null +++ b/litellm/proxy/_experimental/out/tools/mcp-servers/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/tools/vector-stores/index.html b/litellm/proxy/_experimental/out/tools/vector-stores/index.html new file mode 100644 index 00000000000..098b0d212c6 --- /dev/null +++ b/litellm/proxy/_experimental/out/tools/vector-stores/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/usage/index.html b/litellm/proxy/_experimental/out/usage/index.html new file mode 100644 index 00000000000..ed6ac2eba97 --- /dev/null +++ b/litellm/proxy/_experimental/out/usage/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/users/index.html b/litellm/proxy/_experimental/out/users/index.html new file mode 100644 index 00000000000..247dda941bd --- /dev/null +++ b/litellm/proxy/_experimental/out/users/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/virtual-keys/index.html b/litellm/proxy/_experimental/out/virtual-keys/index.html new file mode 100644 index 00000000000..b17ef6de095 --- /dev/null +++ b/litellm/proxy/_experimental/out/virtual-keys/index.html @@ -0,0 +1 @@ +LiteLLM Dashboard
Loading...
\ No newline at end of file diff --git a/litellm/rag/ingestion/base_ingestion.py b/litellm/rag/ingestion/base_ingestion.py index 6f139764a85..6a4eb89d0fd 100644 --- a/litellm/rag/ingestion/base_ingestion.py +++ b/litellm/rag/ingestion/base_ingestion.py @@ -24,7 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( get_async_httpx_client, httpxSpecialProvider, ) -from litellm.proxy.common_utils.url_utils import async_safe_get +from litellm.litellm_core_utils.url_utils import async_safe_get from litellm.rag.ingestion.file_parsers import extract_text_from_pdf from litellm.rag.text_splitters import RecursiveCharacterTextSplitter from litellm.types.rag import RAGIngestOptions, RAGIngestResponse diff --git a/tests/test_litellm/proxy/common_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py similarity index 97% rename from tests/test_litellm/proxy/common_utils/test_url_utils.py rename to tests/test_litellm/litellm_core_utils/test_url_utils.py index 4dbda6a815e..16798cebad5 100644 --- a/tests/test_litellm/proxy/common_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -1,7 +1,7 @@ import pytest import litellm -from litellm.proxy.common_utils.url_utils import SSRFError, _is_blocked_ip, validate_url +from litellm.litellm_core_utils.url_utils import SSRFError, _is_blocked_ip, validate_url class TestIsBlockedIp: From 30c6556782103ece93b86a50b5ae64ffa7c887d6 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:06:14 +0000 Subject: [PATCH 10/15] test: bypass SSRF validation in image handling tests --- .../litellm_core_utils/test_image_handling.py | 39 +++++++++++++------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 9c2939b2da5..cc13e816dde 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -5,11 +5,22 @@ from httpx import Request, Response import litellm from litellm import constants +from litellm.litellm_core_utils.prompt_templates import image_handling from litellm.litellm_core_utils.prompt_templates.image_handling import ( convert_url_to_base64, ) +@pytest.fixture(autouse=True) +def _bypass_ssrf(monkeypatch): + """Bypass SSRF validation in image handling tests — tests use fake URLs.""" + monkeypatch.setattr( + image_handling, + "safe_get", + lambda client, url, **kw: client.get(url, follow_redirects=True), + ) + + class DummyClient: def get(self, url, follow_redirects=True): return Response(status_code=404, request=Request("GET", url)) @@ -37,9 +48,7 @@ def test_completion_with_invalid_image_url(monkeypatch): } ] with pytest.raises(litellm.ImageFetchError) as excinfo: - litellm.completion( - model="gemini/gemini-pro", messages=messages, api_key="test" - ) + litellm.completion(model="gemini/gemini-pro", messages=messages, api_key="test") assert excinfo.value.status_code == 400 assert "Unable to fetch image" in str(excinfo.value) @@ -81,7 +90,7 @@ class StreamingLargeImageClient: headers = {"Content-Type": "image/jpeg"} if self.include_content_length: headers["Content-Length"] = str(size_bytes) - + # Create a generator that yields chunks without creating the whole file in memory def generate_chunks(total_size, chunk_size=8192): bytes_sent = 0 @@ -89,7 +98,7 @@ class StreamingLargeImageClient: chunk = b"x" * min(chunk_size, total_size - bytes_sent) bytes_sent += len(chunk) yield chunk - + # Create response with streaming content response = Response( status_code=200, @@ -97,7 +106,9 @@ class StreamingLargeImageClient: request=Request("GET", url), ) # Mock the iter_bytes method to return our generator - response.iter_bytes = lambda chunk_size=8192: generate_chunks(size_bytes, chunk_size) + response.iter_bytes = lambda chunk_size=8192: generate_chunks( + size_bytes, chunk_size + ) return response @@ -121,7 +132,9 @@ def test_image_exceeds_size_limit_without_content_length(monkeypatch): This uses the old non-streaming mock for backward compatibility. """ monkeypatch.setattr( - litellm, "module_level_client", LargeImageClient(size_mb=100, include_content_length=False) + litellm, + "module_level_client", + LargeImageClient(size_mb=100, include_content_length=False), ) with pytest.raises(litellm.ImageFetchError) as excinfo: @@ -134,7 +147,7 @@ def test_streaming_download_protects_against_huge_files(monkeypatch): """ Test that streaming download aborts early when file exceeds size limit, preventing memory exhaustion from huge files (e.g., petabyte-sized files). - + This test verifies that the streaming implementation doesn't download the entire file into memory before checking size. Instead, it should abort as soon as the limit is exceeded during streaming. @@ -148,7 +161,7 @@ def test_streaming_download_protects_against_huge_files(monkeypatch): # Verify the error message shows it was caught during streaming assert "exceeds maximum allowed size" in str(excinfo.value) - + # The error should be raised after downloading just slightly more than the limit # not after downloading the full 1GB @@ -187,13 +200,15 @@ def test_streaming_download_handles_petabyte_file(monkeypatch): """ Test that streaming download can handle extremely large file URLs (e.g., petabyte-sized) without attempting to download the entire file or causing memory exhaustion. - + This simulates what happens if a malicious actor or misconfiguration provides a URL to an extremely large file. """ # Simulate a 1 petabyte file (1,000,000 GB) # Without streaming protection, this would cause OOM or hang indefinitely - client = StreamingLargeImageClient(size_mb=1_000_000_000, include_content_length=False) + client = StreamingLargeImageClient( + size_mb=1_000_000_000, include_content_length=False + ) monkeypatch.setattr(litellm, "module_level_client", client) with pytest.raises(litellm.ImageFetchError) as excinfo: @@ -214,6 +229,6 @@ def test_image_size_limit_disabled(monkeypatch): with pytest.raises(litellm.ImageFetchError) as excinfo: convert_url_to_base64("https://example.com/image.jpg") - + assert "Image URL download is disabled" in str(excinfo.value) assert "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0" in str(excinfo.value) From f5a9218cb31bd1284a92bcfb70228ad0e51cbd97 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 05:14:39 +0000 Subject: [PATCH 11/15] chore: remove unused asyncio import --- litellm/litellm_core_utils/url_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index a47d7ae2ca4..1a552e6cf54 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -9,7 +9,6 @@ URL to connect to the validated IP directly — no TOCTOU gap, no DNS rebinding. Redirects are followed manually with validation at each hop. """ -import asyncio import socket from ipaddress import ip_address, ip_network from typing import Any, Tuple From 1f50c6fa66b0cb6cbe970c442e3ba4130cb8d642 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:28:13 +0000 Subject: [PATCH 12/15] test: mock DNS resolution, hoist httpx import to module level Greptile P1: six tests in test_url_utils.py performed real DNS lookups to example.com, violating the tests/test_litellm/ mock-only rule and risking offline CI failures. Add mock_dns_public and mock_dns_failure fixtures that monkeypatch socket.getaddrinfo on the url_utils module. Greptile P2: move 'import httpx' from inside _extract_redirect_url to module-level imports per CLAUDE.md style guide. --- litellm/litellm_core_utils/url_utils.py | 4 +- .../litellm_core_utils/test_url_utils.py | 41 ++++++++++++++++--- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index 1a552e6cf54..aaeb2bee7ef 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -14,6 +14,8 @@ from ipaddress import ip_address, ip_network from typing import Any, Tuple from urllib.parse import urlparse, urlunparse +import httpx + import litellm _BLOCKED_NETWORKS = [ @@ -142,8 +144,6 @@ _MAX_REDIRECTS = 10 def _extract_redirect_url(response: Any, request_url: str) -> str: """Extract and resolve the redirect target from a response's Location header.""" - import httpx - location = response.headers.get("location") if not location: raise SSRFError("Redirect response has no Location header") diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index 16798cebad5..1b8121efaca 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -1,9 +1,34 @@ +import socket + import pytest import litellm +from litellm.litellm_core_utils import url_utils from litellm.litellm_core_utils.url_utils import SSRFError, _is_blocked_ip, validate_url +@pytest.fixture +def mock_dns_public(monkeypatch): + """Resolve any hostname to 93.184.216.34 (public).""" + + def fake_getaddrinfo(host, port, *args, **kwargs): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port or 80)) + ] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake_getaddrinfo) + + +@pytest.fixture +def mock_dns_failure(monkeypatch): + """Make every DNS lookup raise gaierror.""" + + def fake_getaddrinfo(host, port, *args, **kwargs): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake_getaddrinfo) + + class TestIsBlockedIp: def test_blocks_private(self): assert _is_blocked_ip("10.0.0.1") is True @@ -48,22 +73,22 @@ class TestValidateUrl: with pytest.raises(SSRFError): validate_url("http:///path") - def test_allows_public_https(self): + def test_allows_public_https(self, mock_dns_public): rewritten, host = validate_url("https://example.com/image.png") assert host == "example.com" assert rewritten == "https://example.com/image.png" - def test_rewrites_public_http_to_ip(self): + def test_rewrites_public_http_to_ip(self, mock_dns_public): rewritten, host = validate_url("http://example.com/image.png") assert host == "example.com" assert "example.com" not in rewritten - def test_preserves_path_and_query(self): + def test_preserves_path_and_query(self, mock_dns_public): rewritten, host = validate_url("http://example.com/path?key=value") assert "/path" in rewritten assert "key=value" in rewritten - def test_dns_failure_raises(self): + def test_dns_failure_raises(self, mock_dns_failure): with pytest.raises(SSRFError, match="DNS resolution failed"): validate_url("http://this-domain-does-not-exist-xyz123.invalid/test") @@ -75,13 +100,17 @@ class TestValidateUrl: with pytest.raises(SSRFError): validate_url("http://[::1]/") - def test_https_rewrites_when_ssl_verify_disabled(self, monkeypatch): + def test_https_rewrites_when_ssl_verify_disabled( + self, monkeypatch, mock_dns_public + ): monkeypatch.setattr(litellm, "ssl_verify", False) rewritten, host = validate_url("https://example.com/image.png") assert host == "example.com" assert "example.com" not in rewritten # rewritten to IP - def test_https_not_rewritten_when_ssl_verify_enabled(self, monkeypatch): + def test_https_not_rewritten_when_ssl_verify_enabled( + self, monkeypatch, mock_dns_public + ): monkeypatch.setattr(litellm, "ssl_verify", True) rewritten, host = validate_url("https://example.com/image.png") assert rewritten == "https://example.com/image.png" From 1d3dda93429c42e58c2fd9f0db0298cd743000e5 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:40:19 +0000 Subject: [PATCH 13/15] feat: add admin opt-out for user URL validation Two litellm-level flags wired through litellm_settings YAML: - user_url_validation (bool, default True): master switch. When False, safe_get/async_safe_get bypass validation and call client.get directly. - user_url_allowed_hosts (List[str], default []): per-host allowlist. Entries are 'host' (matches any port) or 'host:port' (port-specific). Matched hosts skip the blocked-networks check but still resolve DNS and still rewrite HTTP to the validated IP, preserving rebinding protection within the permitted name. Also fix an existing Host header bug: IPv6 literals (e.g. 2001:db8::1) were emitted unbracketed, producing ambiguous values like '2001:db8::1:8080' per RFC 7230 5.4. Bracket them consistently in _format_host_header. --- litellm/__init__.py | 2 + litellm/litellm_core_utils/url_utils.py | 79 ++++++-- .../litellm_core_utils/test_url_utils.py | 187 ++++++++++++++++++ 3 files changed, 253 insertions(+), 15 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 3b67d9e0021..273af465b29 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -274,6 +274,8 @@ use_client: bool = False ssl_verify: Union[str, bool] = True ssl_security_level: Optional[str] = None ssl_certificate: Optional[str] = None +user_url_validation: bool = True +user_url_allowed_hosts: List[str] = [] ssl_ecdh_curve: Optional[ str ] = None # Set to 'X25519' to disable PQC and improve performance diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index aaeb2bee7ef..e920a044583 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -7,11 +7,21 @@ input (image_url, file_url, spec_path, etc.) to prevent SSRF attacks. validate_url() resolves DNS once, validates all IPs, and rewrites the URL to connect to the validated IP directly — no TOCTOU gap, no DNS rebinding. Redirects are followed manually with validation at each hop. + +Admins can opt out via two ``litellm`` globals (wired from proxy config): + +- ``litellm.user_url_validation`` (bool, default True): master switch. + When False, ``safe_get``/``async_safe_get`` perform a plain fetch with + no DNS check, no block list, and no rewrite. +- ``litellm.user_url_allowed_hosts`` (List[str], default []): per-host + allowlist. Entries are ``hostname`` or ``hostname:port`` (IPv6 hosts as + ``[addr]`` / ``[addr]:port``). Matching hosts skip the blocked-networks + check but still resolve DNS and still rewrite HTTP to the resolved IP. """ import socket from ipaddress import ip_address, ip_network -from typing import Any, Tuple +from typing import Any, List, Set, Tuple from urllib.parse import urlparse, urlunparse import httpx @@ -52,6 +62,36 @@ def _is_blocked_ip(addr: str) -> bool: return any(ip in net for net in _BLOCKED_NETWORKS) +def _normalize_host(host: str) -> str: + """Lowercase and strip a trailing dot from a hostname.""" + return host.lower().rstrip(".") + + +def _format_host_header(hostname: str, port: int, default_port: int) -> str: + """Build an RFC 7230 Host header value, bracketing IPv6 literals.""" + bracketed = f"[{hostname}]" if ":" in hostname else hostname + if port == default_port: + return bracketed + return f"{bracketed}:{port}" + + +def _is_host_allowlisted(hostname: str, effective_port: int) -> bool: + """Check whether a host is in the admin-configured allowlist. + + Admin entries may be ``hostname`` (any port) or ``hostname:port``. IPv6 + literals are written bracketed (``[::1]`` / ``[::1]:8080``). Matching + is case-insensitive on the hostname. + """ + configured: List[str] = getattr(litellm, "user_url_allowed_hosts", []) or [] + if not configured: + return False + normalized_host = _normalize_host(hostname) + host_repr = f"[{normalized_host}]" if ":" in normalized_host else normalized_host + candidates: Set[str] = {host_repr, f"{host_repr}:{effective_port}"} + allowlist: Set[str] = {_normalize_host(entry) for entry in configured if entry} + return bool(candidates & allowlist) + + def validate_url(url: str) -> Tuple[str, str]: """ Validate a user-supplied URL and rewrite it to connect to a validated IP. @@ -68,9 +108,9 @@ def validate_url(url: str) -> Tuple[str, str]: url: The user-supplied URL to validate. Returns: - Tuple of (rewritten_url, original_hostname). + Tuple of (rewritten_url, host_header). The rewritten URL has the hostname replaced with the validated IP. - The original hostname should be set as the Host header. + The host_header value should be sent as the Host header. Raises: SSRFError: If the URL scheme is invalid or the hostname resolves @@ -87,16 +127,15 @@ def validate_url(url: str) -> Tuple[str, str]: port = parsed.port default_port = 443 if parsed.scheme == "https" else 80 + effective_port = port if port is not None else default_port + host_header = _format_host_header(hostname, effective_port, default_port) - # Build the Host header value — include port when non-default - host_header = ( - hostname if (port is None or port == default_port) else f"{hostname}:{port}" - ) + is_allowlisted = _is_host_allowlisted(hostname, effective_port) # Resolve hostname and validate ALL addresses try: addrinfo = socket.getaddrinfo( - hostname, port or default_port, proto=socket.IPPROTO_TCP + hostname, effective_port, proto=socket.IPPROTO_TCP ) except socket.gaierror as e: raise SSRFError(f"DNS resolution failed for '{hostname}': {e}") @@ -104,13 +143,14 @@ def validate_url(url: str) -> Tuple[str, str]: if not addrinfo: raise SSRFError(f"No addresses found for '{hostname}'") - for family, type_, proto, canonname, sockaddr in addrinfo: - if _is_blocked_ip(sockaddr[0]): - raise SSRFError( - f"URL targets a blocked address ({sockaddr[0]}). " - "If this is a legitimate internal service, use a direct " - "provider configuration instead of a user-supplied URL." - ) + if not is_allowlisted: + for family, type_, proto, canonname, sockaddr in addrinfo: + if _is_blocked_ip(sockaddr[0]): + raise SSRFError( + f"URL targets a blocked address ({sockaddr[0]}). " + "If this is a legitimate internal service, add the host " + "to `user_url_allowed_hosts` in general_settings." + ) # For HTTPS with SSL verification enabled, TLS certificate validation # binds the connection to the hostname — DNS rebinding can't redirect @@ -159,6 +199,9 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: request. No DNS rebinding (resolve-and-rewrite). No redirect bypass (each hop validated). No breaking change for legitimate CDN redirects. + When ``litellm.user_url_validation`` is False, validation is bypassed + and this function delegates to ``client.get(url, follow_redirects=True)``. + Args: client: An httpx.Client (sync). url: The user-supplied URL. @@ -167,6 +210,9 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: Returns: The final httpx.Response. """ + if not getattr(litellm, "user_url_validation", True): + kwargs.setdefault("follow_redirects", True) + return client.get(url, **kwargs) kwargs.pop("follow_redirects", None) caller_headers = kwargs.pop("headers", {}) for _ in range(_MAX_REDIRECTS): @@ -185,6 +231,9 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any: """Async version of safe_get.""" + if not getattr(litellm, "user_url_validation", True): + kwargs.setdefault("follow_redirects", True) + return await client.get(url, **kwargs) kwargs.pop("follow_redirects", None) caller_headers = kwargs.pop("headers", {}) for _ in range(_MAX_REDIRECTS): diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index 1b8121efaca..f6282c6fe79 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -114,3 +114,190 @@ class TestValidateUrl: monkeypatch.setattr(litellm, "ssl_verify", True) rewritten, host = validate_url("https://example.com/image.png") assert rewritten == "https://example.com/image.png" + + +class TestHostHeaderFormatting: + """RFC 7230 §5.4: IPv6 literals must be bracketed in the Host header.""" + + def test_ipv4_no_port(self, monkeypatch): + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.2.3.4", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + _, host = validate_url("http://example.com/") + assert host == "example.com" + + def test_ipv4_with_explicit_nondefault_port(self, monkeypatch): + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.2.3.4", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + _, host = validate_url("http://example.com:8080/") + assert host == "example.com:8080" + + def test_ipv4_with_explicit_default_port_strips_port(self, monkeypatch): + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("1.2.3.4", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + _, host = validate_url("http://example.com:80/") + assert host == "example.com" + + def test_ipv6_literal_is_bracketed_with_port(self, monkeypatch): + """Regression: IPv6 + port produced ambiguous `Host: 2001:db8::1:8080`.""" + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["[2001:db8::1]"]) + + def fake(host, port, *a, **kw): + return [ + ( + socket.AF_INET6, + socket.SOCK_STREAM, + 6, + "", + ("2001:db8::1", port, 0, 0), + ) + ] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + _, host = validate_url("http://[2001:db8::1]:8080/") + assert host == "[2001:db8::1]:8080" + + def test_ipv6_literal_is_bracketed_without_port(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["[2001:db8::1]"]) + + def fake(host, port, *a, **kw): + return [ + ( + socket.AF_INET6, + socket.SOCK_STREAM, + 6, + "", + ("2001:db8::1", port, 0, 0), + ) + ] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + _, host = validate_url("http://[2001:db8::1]/") + assert host == "[2001:db8::1]" + + +class TestValidationMasterSwitch: + def test_disabled_bypasses_fetch_in_safe_get(self, monkeypatch): + """When user_url_validation is False, safe_get delegates to client.get without validation.""" + monkeypatch.setattr(litellm, "user_url_validation", False) + + calls = [] + + class FakeClient: + def get(self, url, **kwargs): + calls.append((url, kwargs)) + + class R: + is_redirect = False + + return R() + + url_utils.safe_get(FakeClient(), "http://127.0.0.1/internal") + assert calls and calls[0][0] == "http://127.0.0.1/internal" + assert calls[0][1].get("follow_redirects") is True + + def test_enabled_still_blocks(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_validation", True) + with pytest.raises(SSRFError): + validate_url("http://127.0.0.1/") + + +class TestHostAllowlist: + def test_allowlisted_hostname_permits_private_ip(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + rewritten, host = validate_url("http://internal.corp/path") + assert host == "internal.corp" + assert "10.0.1.5" in rewritten + + def test_non_allowlisted_hostname_still_blocked(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + with pytest.raises(SSRFError): + validate_url("http://other.corp/") + + def test_allowlist_case_insensitive(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["Internal.Corp"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + rewritten, _ = validate_url("http://internal.corp/") + assert "10.0.1.5" in rewritten + + def test_allowlist_with_port_matches_explicit_port(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp:8080"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + rewritten, host = validate_url("http://internal.corp:8080/") + assert host == "internal.corp:8080" + assert "10.0.1.5" in rewritten + + def test_allowlist_with_port_matches_default_port(self, monkeypatch): + """Admin entry `host:443` matches `https://host/` (port=None, default 443).""" + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp:443"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + # Should succeed — no SSRFError raised + validate_url("https://internal.corp/") + + def test_allowlist_port_specific_does_not_match_other_port(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp:8080"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + with pytest.raises(SSRFError): + validate_url("http://internal.corp:9090/") + + def test_allowlist_host_entry_matches_any_port(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp"]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + validate_url("http://internal.corp:9090/") + validate_url("https://internal.corp:8443/") + + def test_allowlist_permits_loopback(self, monkeypatch): + """Admin may opt into loopback if they explicitly configure it.""" + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["localhost"]) + # localhost resolves locally without needing mocks + rewritten, host = validate_url("http://localhost:8080/") + assert host == "localhost:8080" + + def test_empty_allowlist_retains_default_deny(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", []) + with pytest.raises(SSRFError): + validate_url("http://127.0.0.1/") + + def test_allowlist_strips_trailing_dot(self, monkeypatch): + monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["internal.corp."]) + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.1.5", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + validate_url("http://internal.corp/") From 0602564b66bd20147f0e0d9fd3bc9e4c5e4fd221 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:03:47 +0000 Subject: [PATCH 14/15] fix: switch blocklist to RFC 6890 via ipaddress.is_global, block multicast and Azure Wire Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-maintained _BLOCKED_NETWORKS CIDR list with a default-deny check based on ipaddress.is_global (RFC 6890 semantics, implemented by Python's stdlib). Also reject multicast explicitly — is_global returns True for public multicast allocations, which are not legitimate HTTP targets. Only globally-routable cloud-fabric IPs need explicit exceptions; the canonical list contains one entry today: Azure Wire Server (168.63.129.16), an in-fabric service reachable from any Azure VM. Coverage delta picked up automatically via is_global: - Alibaba Cloud metadata (100.100.100.200, CGNAT) - Legacy Oracle metadata (192.0.0.192, IETF Protocol Assignments) - IPv4 documentation ranges (192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) - IPv4 reserved/future-use (240.0.0.0/4) and broadcast - IPv6 documentation (2001:db8::/32) Also fix two issues Greptile flagged: - HTTP relative-redirect hops lost the original hostname because _extract_redirect_url joined the Location against the rewritten (IP-based) URL. Join against the pre-rewrite URL so the next hop's Host header keeps the original hostname. - Two unit tests performed real socket.getaddrinfo('localhost') calls. Monkeypatch them. Add coverage tests for every cloud-metadata IP from the canonical SSRF dictionary (AWS/GCP/Azure/Alibaba/Oracle/DO/OpenStack) plus the new multicast/reserved/documentation/broadcast ranges, and a regression test for redirect-hostname preservation. --- litellm/litellm_core_utils/url_utils.py | 39 +++++--- .../litellm_core_utils/test_url_utils.py | 97 ++++++++++++++++++- 2 files changed, 118 insertions(+), 18 deletions(-) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index e920a044583..d22f92642a3 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -28,19 +28,13 @@ import httpx import litellm -_BLOCKED_NETWORKS = [ - ip_network("0.0.0.0/8"), - ip_network("10.0.0.0/8"), - ip_network("100.64.0.0/10"), - ip_network("127.0.0.0/8"), - ip_network("169.254.0.0/16"), - ip_network("172.16.0.0/12"), - ip_network("192.0.0.0/24"), - ip_network("192.168.0.0/16"), - ip_network("198.18.0.0/15"), - ip_network("::1/128"), - ip_network("fc00::/7"), - ip_network("fe80::/10"), +# Globally-routable IPs that are cloud-internal. Everything else +# non-public is caught by ``not ip.is_global`` (RFC 6890, as implemented by +# Python's ``ipaddress`` module). This list only holds IPs that are +# publicly routable *and* point to cloud-fabric services reachable from +# inside a VM via special in-fabric routing. +_CLOUD_METADATA_EXCEPTIONS = [ + ip_network("168.63.129.16/32"), # Azure Wire Server ] _ALLOWED_SCHEMES = ("http", "https") @@ -53,13 +47,22 @@ class SSRFError(ValueError): def _is_blocked_ip(addr: str) -> bool: + """Return True for any IP not safe to reach from a user-supplied URL. + + Policy: default-deny via ``ip.is_global`` (RFC 6890), plus an explicit + exception list for globally-routable cloud-fabric IPs that are still + dangerous from inside a cloud VM (currently just Azure Wire Server). + Unparseable addresses fail closed. + """ try: ip = ip_address(addr) except ValueError: return True # fail-closed: unparseable addresses are blocked if ip.version == 6 and hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped: ip = ip.ipv4_mapped - return any(ip in net for net in _BLOCKED_NETWORKS) + if not ip.is_global or ip.is_multicast: + return True + return any(ip in net for net in _CLOUD_METADATA_EXCEPTIONS) def _normalize_host(host: str) -> str: @@ -225,7 +228,9 @@ def safe_get(client: Any, url: str, **kwargs: Any) -> Any: ) if not response.is_redirect: return response - url = _extract_redirect_url(response, validated_url) + # Resolve the next hop against the ORIGINAL (pre-rewrite) URL so + # relative Location headers keep the original hostname. + url = _extract_redirect_url(response, url) raise SSRFError("Too many redirects") @@ -246,5 +251,7 @@ async def async_safe_get(client: Any, url: str, **kwargs: Any) -> Any: ) if not response.is_redirect: return response - url = _extract_redirect_url(response, validated_url) + # Resolve the next hop against the ORIGINAL (pre-rewrite) URL so + # relative Location headers keep the original hostname. + url = _extract_redirect_url(response, url) raise SSRFError("Too many redirects") diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index f6282c6fe79..4579c203218 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -39,6 +39,46 @@ class TestIsBlockedIp: def test_unparseable_is_blocked(self): assert _is_blocked_ip("not-an-ip") is True + # Coverage delta picked up by switching to `not ip.is_global` (RFC 6890) + # over the old hand-maintained CIDR list. + def test_blocks_cgnat_alibaba_metadata(self): + """100.100.100.200 is Alibaba Cloud metadata; lives in CGNAT.""" + assert _is_blocked_ip("100.100.100.200") is True + + def test_blocks_ietf_protocol_assignments_old_oracle_metadata(self): + """192.0.0.192 was the legacy Oracle Cloud metadata IP.""" + assert _is_blocked_ip("192.0.0.192") is True + + def test_blocks_documentation_ranges(self): + assert _is_blocked_ip("192.0.2.1") is True + assert _is_blocked_ip("198.51.100.1") is True + assert _is_blocked_ip("203.0.113.1") is True + + def test_blocks_multicast(self): + assert _is_blocked_ip("224.0.0.1") is True + + def test_blocks_reserved_future_use(self): + assert _is_blocked_ip("240.0.0.1") is True + + def test_blocks_broadcast(self): + assert _is_blocked_ip("255.255.255.255") is True + + def test_blocks_azure_wire_server(self): + """168.63.129.16 is globally routable but cloud-internal — explicit exception.""" + assert _is_blocked_ip("168.63.129.16") is True + + def test_blocks_aws_ipv6_imds(self): + """fd00:ec2::254 is AWS's IPv6 IMDS, in IPv6 ULA (fc00::/7).""" + assert _is_blocked_ip("fd00:ec2::254") is True + + def test_blocks_ipv4_mapped_private(self): + """::ffff:10.0.0.1 must be unwrapped and blocked as 10.0.0.1.""" + assert _is_blocked_ip("::ffff:10.0.0.1") is True + + def test_blocks_ipv4_mapped_azure_wire_server(self): + """::ffff:168.63.129.16 must be unwrapped and blocked via the exception list.""" + assert _is_blocked_ip("::ffff:168.63.129.16") is True + class TestValidateUrl: def test_blocks_loopback(self): @@ -92,7 +132,13 @@ class TestValidateUrl: with pytest.raises(SSRFError, match="DNS resolution failed"): validate_url("http://this-domain-does-not-exist-xyz123.invalid/test") - def test_blocks_localhost_hostname(self): + def test_blocks_localhost_hostname(self, monkeypatch): + def fake(host, port, *a, **kw): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port or 80)) + ] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) with pytest.raises(SSRFError): validate_url("http://localhost/") @@ -181,6 +227,49 @@ class TestHostHeaderFormatting: assert host == "[2001:db8::1]" +class TestRedirectHostnamePreservation: + """Relative-location redirects must keep the original hostname, not the + rewritten IP, so the next hop's Host header still identifies the site.""" + + def test_relative_redirect_preserves_hostname_for_next_hop(self, monkeypatch): + def fake(host, port, *a, **kw): + return [ + (socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port)) + ] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + + class FakeResponse: + def __init__(self, status, location=None): + self.status_code = status + self.headers = {"location": location} if location else {} + self.is_redirect = 300 <= status < 400 + + hops = [] + + class FakeClient: + def __init__(self): + self._n = 0 + + def get(self, url, headers=None, follow_redirects=False, **kw): + hops.append({"url": url, "host": (headers or {}).get("Host")}) + self._n += 1 + if self._n == 1: + return FakeResponse(302, "/redirected") + return FakeResponse(200) + + url_utils.safe_get(FakeClient(), "http://example.com/initial") + assert len(hops) == 2 + # Both hops must carry the ORIGINAL hostname in the Host header. + assert hops[0]["host"] == "example.com" + assert hops[1]["host"] == "example.com" + # Both outbound URLs go to the resolved IP (rewritten), not the hostname. + assert "93.184.216.34" in hops[0]["url"] + assert "93.184.216.34" in hops[1]["url"] + # The second hop resolved /redirected relative to the original, not the IP. + assert hops[1]["url"].endswith("/redirected") + + class TestValidationMasterSwitch: def test_disabled_bypasses_fetch_in_safe_get(self, monkeypatch): """When user_url_validation is False, safe_get delegates to client.get without validation.""" @@ -284,7 +373,11 @@ class TestHostAllowlist: def test_allowlist_permits_loopback(self, monkeypatch): """Admin may opt into loopback if they explicitly configure it.""" monkeypatch.setattr(litellm, "user_url_allowed_hosts", ["localhost"]) - # localhost resolves locally without needing mocks + + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) rewritten, host = validate_url("http://localhost:8080/") assert host == "localhost:8080" From aa2f05f8c9663202a0d9e68dc1980bb6134b1d82 Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Thu, 16 Apr 2026 22:25:24 +0000 Subject: [PATCH 15/15] style: use 'is not None' for port check (handle port 0 explicitly) --- litellm/litellm_core_utils/url_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index d22f92642a3..b55882819de 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -170,7 +170,7 @@ def validate_url(url: str) -> Tuple[str, str]: is_ipv6 = addrinfo[0][0] == socket.AF_INET6 ip_host = f"[{validated_ip}]" if is_ipv6 else validated_ip - if port: + if port is not None: new_netloc = f"{ip_host}:{port}" else: new_netloc = ip_host