mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(guardrails): strip credential headers on cross-origin redirects in async_safe_request
This commit is contained in:
parent
d9292e129b
commit
9c410fffb4
2 changed files with 86 additions and 2 deletions
|
|
@ -21,7 +21,7 @@ Admins can opt out via two ``litellm`` globals (wired from proxy config):
|
|||
|
||||
import socket
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import Any, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
|
|
@ -365,6 +365,34 @@ def _extract_redirect_url(response: Any, request_url: str) -> str:
|
|||
return str(httpx.URL(request_url).join(location))
|
||||
|
||||
|
||||
_SENSITIVE_REDIRECT_HEADERS = frozenset({"authorization", "proxy-authorization", "cookie"})
|
||||
|
||||
|
||||
def _same_origin(first_url: str, second_url: str) -> bool:
|
||||
"""Return True when both URLs share scheme, host, and effective port."""
|
||||
first = urlparse(first_url)
|
||||
second = urlparse(second_url)
|
||||
first_port = first.port if first.port is not None else _default_port_for_scheme(first.scheme)
|
||||
second_port = second.port if second.port is not None else _default_port_for_scheme(second.scheme)
|
||||
return (
|
||||
first.scheme == second.scheme
|
||||
and _normalize_host(first.hostname or "") == _normalize_host(second.hostname or "")
|
||||
and first_port == second_port
|
||||
)
|
||||
|
||||
|
||||
def _headers_for_next_hop(headers: Dict[str, str], current_url: str, next_url: str) -> Dict[str, str]:
|
||||
"""Drop credential-bearing headers when a redirect crosses origins.
|
||||
|
||||
Mirrors httpx's own redirect handling, which strips ``Authorization`` and
|
||||
similar headers on cross-origin hops so a redirect can't exfiltrate the
|
||||
caller's credentials to a different host.
|
||||
"""
|
||||
if _same_origin(current_url, next_url):
|
||||
return headers
|
||||
return {k: v for k, v in headers.items() if k.lower() not in _SENSITIVE_REDIRECT_HEADERS}
|
||||
|
||||
|
||||
def safe_get(client: Any, url: str, **kwargs: Any) -> Any:
|
||||
"""
|
||||
Fetch a user-supplied URL with SSRF protection on every redirect hop.
|
||||
|
|
@ -456,5 +484,7 @@ async def async_safe_request(client: Any, method: str, url: str, **kwargs: Any)
|
|||
)
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
url = _extract_redirect_url(response, url)
|
||||
next_url = _extract_redirect_url(response, url)
|
||||
caller_headers = _headers_for_next_hop(caller_headers, url, next_url)
|
||||
url = next_url
|
||||
raise SSRFError("Too many redirects")
|
||||
|
|
|
|||
|
|
@ -628,6 +628,60 @@ class TestAsyncSafeRequest:
|
|||
with pytest.raises(SSRFError):
|
||||
await async_safe_request(FakeClient(), "POST", "http://example.com/start")
|
||||
|
||||
async def test_sensitive_headers_stripped_on_cross_origin_redirect(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)
|
||||
monkeypatch.setattr(litellm, "user_url_validation", True)
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeClient:
|
||||
async def request(self, method, url, headers=None, follow_redirects=False, **kw):
|
||||
calls.append(dict(headers or {}))
|
||||
if len(calls) == 1:
|
||||
return _FakeResponse(302, "https://other.example.org/next")
|
||||
return _FakeResponse(200)
|
||||
|
||||
await async_safe_request(
|
||||
FakeClient(),
|
||||
"GET",
|
||||
"https://trusted.example.com/start",
|
||||
headers={"Authorization": "Bearer secret", "Cookie": "sid=1", "X-Trace": "keep"},
|
||||
)
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["Authorization"] == "Bearer secret"
|
||||
assert calls[0]["Cookie"] == "sid=1"
|
||||
assert "Authorization" not in calls[1]
|
||||
assert "Cookie" not in calls[1]
|
||||
assert calls[1]["X-Trace"] == "keep"
|
||||
|
||||
async def test_sensitive_headers_kept_on_same_origin_redirect(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)
|
||||
monkeypatch.setattr(litellm, "user_url_validation", True)
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeClient:
|
||||
async def request(self, method, url, headers=None, follow_redirects=False, **kw):
|
||||
calls.append(dict(headers or {}))
|
||||
if len(calls) == 1:
|
||||
return _FakeResponse(302, "https://trusted.example.com/next")
|
||||
return _FakeResponse(200)
|
||||
|
||||
await async_safe_request(
|
||||
FakeClient(),
|
||||
"GET",
|
||||
"https://trusted.example.com/start",
|
||||
headers={"Authorization": "Bearer secret"},
|
||||
)
|
||||
assert len(calls) == 2
|
||||
assert calls[1]["Authorization"] == "Bearer secret"
|
||||
|
||||
async def test_too_many_redirects_raises(self, monkeypatch):
|
||||
def fake(host, port, *a, **kw):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue