workflow_http.py re-validates the SSRF private-IP guard on every REDIRECT hop but never on the INITIAL connection — a domain that resolves public at plan_http() time and private moments later at apply_http_plan() time (DNS rebind, or simple TTL expiry) reaches the private target with zero refusal (v1.5.0)
Reproduction steps:
- workbench/workflow_http.py's private-IP/SSRF guard, _host_is_private()
(line 47), is consulted in exactly two places:
- plan_http() (line 114): the DRY-RUN planning step, which resolves
the host once and refuses if it's private.
- _GuardedRedirect.redirect_request() (line 171-177): re-checks
_host_is_private() on every 3xx a live request follows — this is
workflow_http.py's own earlier fix (#19) for exactly this class of
gap on the redirect leg.
apply_http_plan() (line 184-215) — the function that actually FIRES the
live request — calls opener.open(req, ...) directly (line 215) with NO
_host_is_private() re-check anywhere before that call. It trusts
plan_http()'s plan-time determination verbatim for the INITIAL
connection.
- workflow_engine.py's runtime
kind == "http"node handler (line
1762-1768) calls plan_http() and apply_http_plan() back to back, each
step:
plan = WH.plan_http(req, n.get("policy"), allow_private=n.get("allow_private", False))
result = WH.apply_http_plan(plan, saga, live=live_http, mock_fn=http_mock, compensator=None)
plan_http()'s DNS resolution and the resolution the real HTTP client
performs a moment later inside apply_http_plan() are two INDEPENDENT
lookups. Any attacker who controls (or can race) DNS for an allowlisted
domain — answer PUBLIC on the first query, PRIVATE (127.0.0.1,
169.254.169.254, an internal host) on the next, a standard DNS-rebind
setup, or simply a domain whose TTL expired between the two lookups —
gets a plan_http() that approves the request as safe and an
apply_http_plan() that then connects wherever the SECOND resolution
says, with zero re-validation. This is the exact class of bug
workflow_http.py's own redirect-hop fix (#19) already closed for
REDIRECTS; it was never applied to the initial connection at all.
- Run repro_workflow_http_initial_request_no_refire_ssrf_check_v150.py
against a clean v1.5.0 extraction. It drives the REAL, unmodified
plan_http() and apply_http_plan() with socket.getaddrinfo monkeypatched
to return a PUBLIC address on its first call (what plan_http()'s
_host_is_private sees) and 127.0.0.1 on every call after (what the real
connection resolves to moments later, simulating a rebind). No live
egress happens — only the final socket.connect() is captured and
aborted, so the only thing under test is whether workflow_http.py
itself refuses the private target before attempting to connect:
plan_http() succeeded (domain resolved PUBLIC at plan time):
host: rebindable-domain.example action_class: read_only
apply_http_plan() raised HttpError (network-layer, NOT an SSRF
refusal): network failure on GET
https://rebindable-domain.example/status: URLError(
ConnectionRefusedError('repro: refusing to actually connect --
capture only'))
real connect attempts captured: [('127.0.0.1', 443)]
CONFIRMED
Expected:
The private-IP/SSRF guard should protect every connection workflow_http.py
makes, not just the redirect hops — the initial connection is exactly as
capable of landing on a rebound or since-expired-TTL private address as
any redirect target is, and the file already contains the correct pattern
(re-check at connect time, not just plan time) for redirects specifically.
Actual:
apply_http_plan() attempts a real TCP connection to whatever the host
currently resolves to, with no re-validation against plan_http()'s
determination or a fresh _host_is_private() check — a workflow node
targeting an allowlisted, legitimately-public domain can be redirected
into hitting an internal service, a cloud metadata endpoint, or any other
private target on its very first hop, the same outcome the #19 redirect
fix exists to prevent one hop later.
Suggested fix:
Re-validate _host_is_private() against a fresh resolution immediately
before the actual connect in apply_http_plan(), the same way
_GuardedRedirect already does per-hop — or, more robustly, resolve the
host ONCE (pin the resolved address, as this same release's webhook_bus.py
DNS-rebind fix does for a different subsystem) and connect to that pinned
address rather than letting the stdlib HTTP client re-resolve the hostname
independently at fire time:
def apply_http_plan(plan, saga, *, live, mock_fn=None, compensator=None,
timeout_s=...):
if live and not plan.get("allow_private"):
if _host_is_private(plan["host"]):
raise HttpPolicyError(
f"host {plan['host']!r} resolves to a private/internal "
f"address at connect time — refusing (SSRF guard)")
...
Applying the SAME re-check discipline the redirect handler already has, to
the one connection that currently skips it, closes the gap without a
larger architectural change.