Repository navigation
Conversation
load_image fetches arbitrary URLs via httpx.get with no validation. Cloud metadata (169.254.169.254), internal services, and port scanning are all reachable. Fix: resolve hostname and block private/loopback/ link-local IPs before the request.
ErenAta16
left a comment
There was a problem hiding this comment.
The redirect-based SSRF case called out in the PR description itself ("follow_redirects=True allows an external server to redirect to internal addresses") isn't actually closed by this diff. _validate_image_url(image) runs once against the URL the caller passed in, then httpx.get(image, timeout=timeout, follow_redirects=True) is free to follow any number of redirects with no further checks, since the validation function is never invoked again once the request starts.
Reproduced this end to end rather than just reading the code. Set up a fake DNS resolver so a normal-looking hostname (evil-cdn.example.com) resolves to a public IP for validation purposes, and rerouted the actual HTTP transport to two local servers: one standing in for the attacker's public-looking entry point (redirects to an "internal" host), one standing in for an internal-only service that returns something sensitive.
PR's SSRF validation: PASSED (url looked like a normal public host)
final URL after redirect: http://127.0.0.1:8766/latest/meta-data/
response body fetched from the internal service: iam-role-credentials: super-secret-aws-key
The initial URL passes _validate_image_url cleanly (it resolves to a public address), and the redirect target's response body is exactly what ends up in PIL.Image.open(BytesIO(...)). Since arbitrary attacker-controlled infrastructure can 30x wherever it wants, this doesn't require finding a target with a literally-private hostname, any public server the attacker controls works as the entry point.
Fix that closes this (same shape as a redirect-revalidation fix I reviewed recently in a different project for the identical bug class): re-validate against the final URL after the request completes, before touching the body:
response = httpx.get(image, timeout=timeout, follow_redirects=True)
if str(response.url) != image:
_validate_image_url(str(response.url))
image = PIL.Image.open(BytesIO(response.content))This doesn't need to inspect every intermediate hop individually, response.url after follow_redirects=True is the fully-resolved final destination, so one check there closes the gap the initial-URL-only check misses. Worth a regression test with a mocked redirect to a private/loopback target, similar to the existing hostname tests but exercising the Location header path specifically.
CI recapDashboard: View test results in Grafana |
|
Quick note on the test failures: the failing tests ( The actual change is a one-line import fix that resolves the |
Signed-off-by: John Kearney <johndanielkearney@gmail.com>
|
Thank you for your contribution 🤗! CI Security Gate — automatic approval blockedThis PR was not automatically approved for CI because the security gate failed. Possible reasons:
See the workflow run for the exact violations. A maintainer can review and manually approve CI if a finding is a false positive. |
This comment was marked as spam.
This comment was marked as spam.
ErenAta16
left a comment
There was a problem hiding this comment.
Re-checked at c62c57d4 (2 commits) — the redirect gap the PR description names is still there, so it isn't closed yet.
_validate_image_url(image)
image = PIL.Image.open(BytesIO(httpx.get(image, timeout=timeout, follow_redirects=True).content))Ran _validate_image_url verbatim from this branch against a local redirect chain. Attacker domain resolves to a public IP (getaddrinfo stubbed for that host only); the redirector and the "internal" service are real sockets on loopback:
call : load_image('http://images.attacker.example/cat.png')
_validate_image_url -> passed (93.184.216.34 is public)
httpx.get(follow_redirects=True):
history : ['http://.../cat.png']
final URL : http://127.0.0.1:36903/latest/meta-data/
body : b'INTERNAL-SECRET-METADATA' -> BytesIO -> PIL.Image.open
was _validate_image_url run on the final URL? no
(control) if it had been: "URL hostname '127.0.0.1' resolves to a private/loopback/link-local address"
The control line is the point — the existing check catches the destination fine, it just never runs on it. Validation happens once, against the string the caller passed, before the request.
Re-validating after the request closes it:
response = httpx.get(image, timeout=timeout, follow_redirects=True)
if str(response.url) != image:
_validate_image_url(str(response.url))
image = PIL.Image.open(BytesIO(response.content))response.url is the final hop only, so a chain that passes through an internal host and bounces back out isn't covered by that alone — httpx's event_hooks or follow_redirects=False with a manual loop would check every hop. The single-hop case above is the one that's trivially reachable today.
Environment: _validate_image_url copied verbatim from c62c57d4, httpx 0.28, Python 3.10.12.
|
Following up on this one (same author, different account). The SSRF in |
|
Hmn, maintainer here, sorry for the delay. The problem here is kind of mismatch of expectations; the pipelines and image loading helpers are generally intended as an "on-ramp" to Transformers, a simple way to get started with models. If you're serving models to untrusted users, you probably should be handling validation yourself and not expecting Transformers to do it for you! We don't want to overload our core code with validation like this. In particular, this blocks lots of legitimate addresses that people might use in testing, so it adds inconvenience to many users (with no workaround or way to disable it except to manually download images). I appreciate the PR but I think we probably don't want to do this, sorry! |
|
Also I think redirects get through this anyway; the check tests the URL but then lets it through to |
|
Thanks for engaging, and for the delay apology — none needed. The redirect point is correct and a fair critique of the patch: validating the URL string while passing Two small suggestions before I close this on my end:
Either way, thanks for looking at it properly. |
|
Maybe! cc @stevhliu if you think that would be worth documenting somewhere |
|
sure! maybe a quick addition in https://huggingface.co/docs/transformers/main/en/pipeline_webserver would be nice |
|
Thanks both. Here's a draft for the pipeline_webserver page — happy to send it as a PR against Validating untrusted inputPipeline helpers and import ipaddress, socket
from urllib.parse import urlparse
BLOCKED = [ipaddress.ip_network(n) for n in (
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "::1/128", "fe80::/10",
)]
def assert_public(url):
host = urlparse(url).hostname
addr = ipaddress.ip_address(socket.gethostbyname(host))
if any(addr in net for net in BLOCKED):
raise ValueError(f"refusing to fetch private address: {host}")
Say the word and I'll open the PR, or feel free to lift the text directly if that's easier. |
Summary
load_imageinimage_utils.pyfetches arbitrary URLs viahttpx.get(url, follow_redirects=True)with no URL validation. This function is called from 8+ image pipelines (classification, segmentation, feature extraction, depth estimation, keypoint matching, visual QA, object detection, zero-shot classification).Severity: HIGH
Class: Server-Side Request Forgery (SSRF)
Root cause
image_utils.py:486:No validation of the URL's destination IP.
follow_redirects=Trueenables redirect-based SSRF bypass.Impact
An attacker who controls the image URL passed to any pipeline can:
http://169.254.169.254/latest/meta-data/iam/security-credentials/(AWS/Azure/GCP instance credentials)http://localhost:PORT/reveals running services via different error messageshttp://10.0.0.1:8080/reaches internal APIsfollow_redirects=Trueallows an external server to redirect to internal addressesEven though non-image responses fail at
PIL.Image.open, the HTTP request is still made — sufficient for SSRF impact (metadata extraction, port enumeration, internal service interaction).Fix
Add
_validate_image_url()that resolves the hostname viasocket.getaddrinfo()and blocks private, loopback, and link-local IP ranges before thehttpx.getcall.Verification
ruff check: cleansrc/transformers/image_utils.pyDedup
Searched issues for "SSRF", "load_image security", "server side request forgery". Zero matches. Novel.