This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git


The following commit(s) were added to refs/heads/main by this push:
     new b98faf4d1c fix(workflow-operator): re-validate redirect hops in HF 
remote-URL fetches to close an SSRF bypass (#7013)
b98faf4d1c is described below

commit b98faf4d1c8ff4a60d8282fe906167568f178992
Author: Prateek Ganigi <[email protected]>
AuthorDate: Thu Jul 30 12:11:53 2026 -0700

    fix(workflow-operator): re-validate redirect hops in HF remote-URL fetches 
to close an SSRF bypass (#7013)
    
    ### What changes were proposed in this PR?
    
    Closes an SSRF bypass in the HuggingFace inference operator's
    `_fetch_remote_url` (generated Python in `PythonCodegenBase.scala`).
    
    The helper hardens remote fetches (https-only, rejects
    private/loopback/link-local/ reserved addresses incl. the
    169.254.169.254 metadata endpoint, size cap) but validated only the
    *original* URL, then called `requests.get(...)`, which follows redirects
    by default. Redirects were never re-checked, so a 302 to
    `http://169.254.169.254/...` or an internal host was fetched without
    re-running the scheme/address checks. Every remote fetch in the operator
    routes through this helper, user-provided image/audio URLs and
    provider-returned media URLs, so a malicious input or hostile provider
    response could
    reach internal services and exfiltrate the content via the result
    column.
    
    The fix follows redirects manually so every hop gets the same scrutiny
    as the original:
    
    - Extracted the scheme + address checks into a `_validate_remote_url`
    helper so it can run per hop.
    - `_fetch_remote_url` sets `allow_redirects=False` and walks the chain
    in a bounded loop (`MAX_REDIRECT_HOPS = 5`), validating before each
    request; relative `Location` values are resolved via `urljoin` and
    re-validated.
    - Fails closed on a missing `Location` or an over-long chain;
    intermediate responses are closed. Size cap and `raise_for_status`
    unchanged.
    - Hardened the address check to an allowlist stance: it now also
    requires a globally-routable address (`not ip.is_global`) alongside the
    existing predicates. This additionally blocks the CGNAT/shared range
    (100.64.0.0/10) the predicate list missed and stays correct across
    Python versions, while keeping the explicit predicates (e.g. multicast,
    which CPython reports as global).
    
    Net effect: a redirect can no longer downgrade the scheme or point the
    worker at a non-public address; legitimate https→https (and relative)
    redirects still work.
    
    ### Any related issues, documentation, discussions?
    
    Closes #6967
    
    ### How was this PR tested?
    
    Added a generated-code test to HuggingFaceInferenceOpDescSpec pinning
    the fix:
    `allow_redirects=False`, validation running before each request,
    interception of all redirect statuses (301/302/303/307/308),
    relative-Location resolution, the hop cap, the fail-closed errors, and
    the globally-routable-only address check.
    
    sbt "WorkflowOperator/testOnly
    org.apache.texera.amber.operator.huggingFace.*"
    
    Full HF package passes (123 tests), including
    PythonCodeRawInvalidTextSpec, which py_compiles the generated Python of
    all 117 operators. scalafmt clean.
    
    Beyond the source-level assertions, the redirect logic was manually
    verified by executing the generated operator Python against simulated
    redirect scenarios (no network): http-downgrade, private/RFC1918,
    169.254.169.254 metadata, CGNAT, multicast, IPv6 loopback/ULA,
    IPv4-mapped, userinfo-trick, and mixed public+private hosts are each
    blocked and never fetched, while legitimate https→https and
    relative-Location redirects still succeed and the hop cap is enforced.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored with Claude Fable 5 in compliance with ASF.
---
 .../huggingFace/codegen/PythonCodegenBase.scala    | 60 ++++++++++++++++++----
 .../HuggingFaceInferenceOpDescSpec.scala           | 35 +++++++++++++
 2 files changed, 85 insertions(+), 10 deletions(-)

diff --git 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/PythonCodegenBase.scala
 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/PythonCodegenBase.scala
index 7cd305bfb6..bac14f0a8a 100644
--- 
a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/PythonCodegenBase.scala
+++ 
b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/huggingFace/codegen/PythonCodegenBase.scala
@@ -128,6 +128,10 @@ object PythonCodegenBase {
        |    # Hard cap on bytes pulled from an external 
(user/response-provided) URL.
        |    MAX_REMOTE_FETCH_BYTES = 25 * 1024 * 1024
        |
+       |    # Redirect-chain bound for _fetch_remote_url. Redirects are 
followed
+       |    # manually so every hop is re-validated (https-only, public 
address).
+       |    MAX_REDIRECT_HOPS = 5
+       |
        |    def open(self):
        |        # User-provided strings reach the operator via base64-encoded
        |        # decode expressions so they cannot break Python syntax or
@@ -704,13 +708,16 @@ object PythonCodegenBase {
        |    # branches of _call_provider).
        |    # 
──────────────────────────────────────────────────────────────────
        |
-       |    def _fetch_remote_url(self, url):
-       |        '''Fetch an external URL with SSRF hardening. Returns 
(content_type, data).
-       |        Enforces https-only, rejects 
private/loopback/link-local/reserved
-       |        addresses (covers the 169.254.169.254 cloud-metadata 
endpoint), and
-       |        caps the response at MAX_REMOTE_FETCH_BYTES. The address check 
runs
-       |        before the request, so it mitigates but does not fully prevent 
DNS
-       |        rebinding (requests re-resolves on connect).
+       |    def _validate_remote_url(self, url):
+       |        '''Validate one URL before it is fetched: https-only, has a 
host,
+       |        and every address the host resolves to is a globally-routable
+       |        public address (rejects private/loopback/link-local/reserved/
+       |        multicast/CGNAT addresses, covering the 169.254.169.254
+       |        cloud-metadata endpoint). Called on the original
+       |        URL and again on every redirect hop, so a redirect can neither
+       |        downgrade the scheme nor point at an internal address. The
+       |        address check runs before the request, so it mitigates but does
+       |        not fully prevent DNS rebinding (requests re-resolves on 
connect).
        |        '''
        |        import ipaddress
        |        import socket
@@ -727,10 +734,43 @@ object PythonCodegenBase {
        |            raise ValueError(f"Could not resolve host '{host}': {e}")
        |        for info in addrinfos:
        |            ip = ipaddress.ip_address(info[4][0])
-       |            if (ip.is_private or ip.is_loopback or ip.is_link_local
-       |                    or ip.is_reserved or ip.is_multicast or 
ip.is_unspecified):
+       |            # Allowlist stance: require a globally-routable address. 
The
+       |            # explicit predicates stay because is_global misses some 
ranges
+       |            # (CPython reports multicast as global) — 
belt-and-suspenders,
+       |            # and it also covers the CGNAT/shared range (100.64.0.0/10)
+       |            # that the predicate list alone lets through.
+       |            if (not ip.is_global or ip.is_private or ip.is_loopback
+       |                    or ip.is_link_local or ip.is_reserved or 
ip.is_multicast
+       |                    or ip.is_unspecified):
        |                raise ValueError(f"Refusing to fetch from non-public 
address {ip}.")
-       |        resp = requests.get(url, timeout=120, stream=True)
+       |
+       |    def _fetch_remote_url(self, url):
+       |        '''Fetch an external URL with SSRF hardening. Returns 
(content_type, data).
+       |        Redirects are never followed automatically: each hop is 
re-validated
+       |        by _validate_remote_url and the chain is bounded by
+       |        MAX_REDIRECT_HOPS, so a redirect cannot escape the https-only /
+       |        public-address checks. The body is capped at 
MAX_REMOTE_FETCH_BYTES.
+       |        '''
+       |        from urllib.parse import urljoin as _urljoin
+       |        current_url = url
+       |        resp = None
+       |        for _hop in range(self.MAX_REDIRECT_HOPS + 1):
+       |            self._validate_remote_url(current_url)
+       |            resp = requests.get(current_url, timeout=120, stream=True, 
allow_redirects=False)
+       |            if resp.status_code in (301, 302, 303, 307, 308):
+       |                location = resp.headers.get("Location", "")
+       |                resp.close()
+       |                if not location:
+       |                    raise ValueError("Redirect response has no 
Location header.")
+       |                # Location may be relative; resolve it against the 
current
+       |                # URL. The result is re-validated at the top of the 
loop.
+       |                current_url = _urljoin(current_url, location)
+       |            else:
+       |                break
+       |        else:
+       |            raise ValueError(
+       |                f"Too many redirects (more than 
{self.MAX_REDIRECT_HOPS}) while fetching remote URL."
+       |            )
        |        resp.raise_for_status()
        |        content_type = resp.headers.get("Content-Type", "")
        |        total = 0
diff --git 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
index 83f6239903..3db7576470 100644
--- 
a/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
+++ 
b/common/workflow-operator/src/test/scala/org/apache/texera/amber/operator/huggingFace/HuggingFaceInferenceOpDescSpec.scala
@@ -304,6 +304,41 @@ class HuggingFaceInferenceOpDescSpec extends AnyFlatSpec 
with Matchers {
     code should not include "open(audio_input"
   }
 
+  it should "re-validate every redirect hop in _fetch_remote_url instead of 
following blindly" in {
+    // requests follows redirects by default, which would skip the scheme/IP
+    // checks on the redirect target: a 302 to http://169.254.169.254/... or an
+    // internal host would be fetched. The helper must disable automatic
+    // redirects and re-run _validate_remote_url on each hop.
+    val code = makeDesc(task = "image-to-image", inputImageColumn = 
"img").generatePythonCode()
+    // Automatic redirect-following is off, and no redirect-following variant
+    // of the fetch remains anywhere in the helper.
+    code should include(
+      "resp = requests.get(current_url, timeout=120, stream=True, 
allow_redirects=False)"
+    )
+    code should not include "resp = requests.get(url, timeout=120, 
stream=True)"
+    // The per-hop validator exists and runs BEFORE the request inside the 
loop.
+    code should include("def _validate_remote_url(self, url):")
+    val validateCall = code.indexOf("self._validate_remote_url(current_url)")
+    val fetchCall = code.indexOf("resp = requests.get(current_url")
+    validateCall should be > 0
+    fetchCall should be > validateCall
+    // Every redirect status is intercepted; relative Location values are
+    // resolved against the current URL before re-validation.
+    code should include("if resp.status_code in (301, 302, 303, 307, 308):")
+    code should include("current_url = _urljoin(current_url, location)")
+    // Degenerate redirects fail closed: missing Location and unbounded chains.
+    code should include("Redirect response has no Location header.")
+    code should include("MAX_REDIRECT_HOPS = 5")
+    code should include("Too many redirects")
+    // The validator keeps the full pre-existing checks (https-only + public
+    // address) so each hop gets the same scrutiny as the original URL, and
+    // takes an allowlist stance (globally-routable only) that also blocks the
+    // CGNAT/shared range the predicate list alone misses.
+    code should include("""if parsed.scheme != "https":""")
+    code should include("not ip.is_global")
+    code should include("ip.is_multicast")
+  }
+
   it should "treat pandas NA sentinels (NaN, pd.NA, NaT) as missing in 
_read_binary_value" in {
     // isinstance(value, float) only catches float('nan'); pd.NA / NaT are not
     // floats and previously fell through to be str()-ified into bytes. The

Reply via email to