Repository navigation
HuggingFace media proxy: host allowlist can be bypassed via HTTP redirects #9103
Description
What happened?
GET /api/huggingface/media-proxy?url=… (HuggingFaceModelResource.proxyRemoteMedia, amber/src/main/scala/org/apache/texera/web/resource/HuggingFaceModelResource.scala L104) fetches a remote media URL on the server and returns the body to the caller. To prevent SSRF it only accepts URLs whose host is in ALLOWED_MEDIA_HOST_SUFFIXES (huggingface.co, fal.media, replicate.delivery, replicate.com, and their subdomains).
That check only runs on the URL as submitted. The fetch itself is Unirest.get(trimmedUrl) (L128-132), and Unirest 3.x follows redirects by default (Config.followRedirects is true). Nothing in amber turns this off. So if an allowlisted host answers with a redirect, the proxy follows it anywhere, including http://169.254.169.254/... or an internal service, and returns that response body to the caller. This is the same bypass that #6967 reported for the operator's Python-side fetch (fixed in #7013). The backend proxy was not covered.
The same check has two smaller gaps:
- The allowlist is purely lexical. The resolved IP is never checked, so an allowlisted name that resolves to a private or loopback address is fetched.
http://is accepted, so anyone on the network path can redirect a plaintext request to an allowlisted host. Separately, the scheme check is case-sensitive, soHTTPS://…is rejected. That is harmless, but inconsistent with the case-insensitive host check.
Any logged-in user can call the endpoint directly with any URL. The result panel also calls it for media URLs found in result cells.
Exploiting this needs a redirect from an allowlisted domain: an open redirect on one of them, a takeover of a dangling subdomain (the suffix match admits every subdomain), or an on-path attacker for http://. I have not found an open redirect on the four domains today. The problem is that the allowlist currently protects nothing once one exists.
Proposed fix
- Fetch with redirects disabled and follow at most a few hops (e.g. 5) manually, re-running the scheme and host checks on every hop. Unirest 3.x has no per-request redirect setting, so this needs a dedicated
UnirestInstance(Unirest.spawnInstance()) rather than changing the global config the other HF endpoints use.S3ProxyServletin file-service already disables redirects (followRedirects(HttpClient.Redirect.NEVER)). - Add
hf.coto the allowlist. Standard Hub file URLs (https://huggingface.co/<repo>/resolve/main/<file>) currently redirect tous.aws.cdn.hf.co, so re-checking each hop without it would break the most common Hugging Face media URL. - Accept
https://only, compared case-insensitively.http://huggingface.coalready redirects to https, so no Hugging Face URL is lost. - Reject hosts that resolve to private, loopback, link-local or other non-public addresses, as fix(workflow-operator): re-validate redirect hops in HF remote-URL fetches to close an SSRF bypass #7013 does on the Python side.
- Pin the behavior in
HuggingFaceModelResourceSpec: a redirect to a non-allowlisted host is refused, a redirect within the allowlist is followed, too many hops are refused,http://is rejected, andHTTPS://andhf.coare accepted.
Related: the proxy returns the upstream status code to the caller (L154-156), which lets it be used to probe what is reachable. That can be handled in the same change or separately.
How to reproduce?
Reproduced locally against Unirest 3.14.5 (the version in amber/build.sbt), with the endpoint's checks copied verbatim:
- Map an allowlisted name to localhost for the server process, e.g.
127.0.0.1 huggingface.coin/etc/hosts(or-Djdk.net.hosts.fileon JDK 18+). This stands in for an allowlisted host that returns a redirect the attacker controls. - Start a server on
127.0.0.1:Athat answers every request with302 Location: http://127.0.0.1:B/latest/meta-data, and an "internal" server on127.0.0.1:Bthat returns a secret string. - Request
/api/huggingface/media-proxy?url=http://huggingface.co:A/img.png.
| Request | Result |
|---|---|
http://127.0.0.1:B/... directly |
403, blocked as expected |
allowlisted host → 302 → 127.0.0.1:B |
200, internal body returned to the caller |
http://cdn-lfs.huggingface.co:B/... resolving to 127.0.0.1, no redirect |
200, internal body returned |
the redirect case with followRedirects(false) |
the upstream 302 is passed back as the response status |
Version/Branch
1.4.0-incubating-SNAPSHOT (main)
Commit Hash (Optional)
No response
What browsers are you seeing the problem on?
No response
Activity
PG1204 commentedon Oct 23, 2026
/take