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