Skip to content

HuggingFace media proxy: host allowlist can be bypassed via HTTP redirects #9103

Open

Description

@PG1204

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, so HTTPS://… 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. S3ProxyServlet in file-service already disables redirects (followRedirects(HttpClient.Redirect.NEVER)).
  • Add hf.co to the allowlist. Standard Hub file URLs (https://huggingface.co/<repo>/resolve/main/<file>) currently redirect to us.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.co already 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, and HTTPS:// and hf.co are 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:

  1. Map an allowlisted name to localhost for the server process, e.g. 127.0.0.1 huggingface.co in /etc/hosts (or -Djdk.net.hosts.file on JDK 18+). This stands in for an allowlisted host that returns a redirect the attacker controls.
  2. Start a server on 127.0.0.1:A that answers every request with 302 Location: http://127.0.0.1:B/latest/meta-data, and an "internal" server on 127.0.0.1:B that returns a secret string.
  3. 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

Relevant log output

Activity

  1. added theissue type on Oct 23, 2026
  2. PG1204 commented on Oct 23, 2026

    @PG1204
    ContributorAuthor

    /take

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions