-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
fix(proxy): add URL validation for user-supplied URLs #25906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
yuneng-berri
merged 15 commits into
BerriAI:litellm_yj_apr17
from
stuxf:fix/ssrf-url-validation
Apr 17, 2026
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
9363f36
fix(proxy): add SSRF protection via resolve-and-rewrite for user-suppβ¦
stuxf d15196b
fix(proxy): add safe_get/async_safe_get with redirect validation
stuxf 037fb57
fix: preserve caller headers across redirect hops in safe_get
stuxf b94aaa7
fix: skip DNS resolution for base64 data in token counter, add unit tβ¦
stuxf 62ec396
test: mock SSRF validation in openapi spec URL test
stuxf 814d03d
fix: fail-closed on unparseable IPs, rewrite HTTPS when SSL verify diβ¦
stuxf e2a0c96
fix: redirect loop was dead code, clean up imports
stuxf 00b25d6
fix: sync redirect bypass, Host header port, redirect loop dead code
stuxf 1ba2be7
refactor: move url_utils to litellm_core_utils to avoid proxy dependency
stuxf 30c6556
test: bypass SSRF validation in image handling tests
stuxf f5a9218
chore: remove unused asyncio import
stuxf 1f50c6f
test: mock DNS resolution, hoist httpx import to module level
stuxf 1d3dda9
feat: add admin opt-out for user URL validation
stuxf 0602564
fix: switch blocklist to RFC 6890 via ipaddress.is_global, block multβ¦
stuxf aa2f05f
style: use 'is not None' for port check (handle port 0 explicitly)
stuxf File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,257 @@ | ||
| """ | ||
| 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. | ||
|
|
||
| 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, List, Set, Tuple | ||
| from urllib.parse import urlparse, urlunparse | ||
|
|
||
| import httpx | ||
|
|
||
| import litellm | ||
|
|
||
| # 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") | ||
|
|
||
|
|
||
| class SSRFError(ValueError): | ||
| """Raised when a URL targets a blocked network.""" | ||
|
|
||
| pass | ||
|
|
||
|
|
||
| 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 | ||
| 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: | ||
| """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. | ||
|
|
||
| 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, host_header). | ||
| The rewritten URL has the hostname replaced with the validated IP. | ||
| The host_header value should be sent 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 | ||
| effective_port = port if port is not None else default_port | ||
| host_header = _format_host_header(hostname, effective_port, default_port) | ||
|
|
||
| is_allowlisted = _is_host_allowlisted(hostname, effective_port) | ||
|
|
||
| # Resolve hostname and validate ALL addresses | ||
| try: | ||
| addrinfo = socket.getaddrinfo( | ||
| hostname, effective_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}'") | ||
|
|
||
| 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 | ||
| # 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, host_header | ||
|
|
||
| # 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 | ||
|
|
||
| if port is not None: | ||
| 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, host_header | ||
|
|
||
|
|
||
| _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.""" | ||
| 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. | ||
|
|
||
| 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. | ||
|
|
||
| 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. | ||
| **kwargs: Additional kwargs passed to client.get(). | ||
|
|
||
| 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): | ||
| validated_url, original_host = validate_url(url) | ||
| response = client.get( | ||
| validated_url, | ||
| headers={**caller_headers, "Host": original_host}, | ||
| follow_redirects=False, | ||
| **kwargs, | ||
| ) | ||
| if not response.is_redirect: | ||
| return response | ||
| # 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") | ||
|
stuxf marked this conversation as resolved.
|
||
|
|
||
|
|
||
| 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): | ||
| validated_url, original_host = validate_url(url) | ||
| response = await client.get( | ||
| validated_url, | ||
| headers={**caller_headers, "Host": original_host}, | ||
| follow_redirects=False, | ||
| **kwargs, | ||
| ) | ||
| if not response.is_redirect: | ||
| return response | ||
| # 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") | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
set
user_url_validationoruser_url_allowed_hostsingeneral_settingsis not taking any effect