fix: prevent redirect-based SSRF in web-fetch and image-load call sites (#24491)
validate_url() in retrieval/web/utils.py only validates the initial URL. The HTTP clients used downstream (sync requests, sync requests via the parent WebBaseLoader._scrape, aiohttp via load_url_image) followed 3xx redirects by default and did not re-validate the redirect target against the private-IP / metadata-IP block list. An authenticated user could submit a public URL that 302-redirected to an internal address (RFC1918, 127.0.0.1, 169.254.169.254, etc.) and the redirected response was returned to them, enabling SSRF reads of internal services and cloud metadata. Three call sites needed allow_redirects=False to match the policy already enforced on the async _fetch() path: - SafeWebBaseLoader: override requests_kwargs in __init__ so that the inherited synchronous _scrape() path passes allow_redirects=False to self.session.get() (the parent WebBaseLoader uses requests' default allow_redirects=True). - get_content_from_url (retrieval/utils.py): pass allow_redirects=False on the streamed requests.get(...) call. - load_url_image (routers/images.py, image-edits endpoint): pass allow_redirects=False on the aiohttp session.get(...) call. Reports consolidated under GHSA-rh5x-h6pp-cjj6: - GHSA-rh5x-h6pp-cjj6 (tenbbughunters / Tenable) - sync _scrape - GHSA-5vxg-6gmv-m2qr (YLChen-007) - load_url_image - GHSA-hf76-c83f-63w2 (tempcollab) - aiohttp _fetch (already fixed) - GHSA-h55f-h5fh-mvm4 (sneaXOR) - get_content_from_url
This commit is contained in:
@@ -180,8 +180,12 @@ def get_content_from_url(request, url: str) -> str:
|
||||
validate_url(url)
|
||||
|
||||
# Streamed GET to check Content-Type without downloading the body.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() above is
|
||||
# called on the originally-submitted URL only; following 3xx redirects without
|
||||
# re-validation would let an attacker reach private IPs (RFC1918, loopback,
|
||||
# cloud-metadata 169.254.169.254) via a public host that redirects internally.
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=30)
|
||||
response = requests.get(url, stream=True, timeout=30, allow_redirects=False)
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
except Exception:
|
||||
|
||||
@@ -485,6 +485,17 @@ class SafeWebBaseLoader(WebBaseLoader):
|
||||
"""
|
||||
super().__init__(*args, **kwargs)
|
||||
self.trust_env = trust_env
|
||||
# Prevent redirect-based SSRF on the synchronous _scrape() path.
|
||||
# validate_url() is called once on the originally-submitted URL, but the
|
||||
# parent WebBaseLoader's _scrape() invokes self.session.get(url, **self.requests_kwargs)
|
||||
# which by default follows redirects. Without the override below, an attacker
|
||||
# can submit a public URL that 302-redirects to an internal address (RFC1918,
|
||||
# 127.0.0.1, 169.254.169.254, etc.) and the redirected target is fetched without
|
||||
# re-validation. Matches the policy enforced on the async _fetch() path below.
|
||||
self.requests_kwargs = {
|
||||
**(self.requests_kwargs or {}),
|
||||
'allow_redirects': False,
|
||||
}
|
||||
|
||||
async def _fetch(self, url: str, retries: int = 3, cooldown: int = 2, backoff: float = 1.5) -> str:
|
||||
async with aiohttp.ClientSession(trust_env=self.trust_env) as session:
|
||||
|
||||
@@ -807,10 +807,14 @@ async def image_edits(
|
||||
return data
|
||||
|
||||
if data.startswith('http://') or data.startswith('https://'):
|
||||
# Validate URL to prevent SSRF attacks against local/private networks
|
||||
# Validate URL to prevent SSRF attacks against local/private networks.
|
||||
# allow_redirects=False prevents redirect-based SSRF: validate_url() is
|
||||
# called only on the originally-submitted URL; following 3xx redirects
|
||||
# without re-validation would let an attacker reach private IPs via a
|
||||
# public host that redirects internally (e.g. cloud-metadata exfil).
|
||||
validate_url(data)
|
||||
session = await get_session()
|
||||
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL) as r:
|
||||
async with session.get(data, ssl=AIOHTTP_CLIENT_SESSION_SSL, allow_redirects=False) as r:
|
||||
r.raise_for_status()
|
||||
|
||||
image_data = base64.b64encode(await r.read()).decode('utf-8')
|
||||
|
||||
Reference in New Issue
Block a user