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.
This commit is contained in:
user 2026-04-16 21:40:19 +00:00
parent 1f50c6fa66
commit 1d3dda9342
No known key found for this signature in database
3 changed files with 253 additions and 15 deletions

View file

@ -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

View file

@ -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):

View file

@ -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/")