it concerns workbench/
workflow_http.py, confirmed present and unchanged in a clean station-v0.71
tarball. This module's own docstring for plan_http() describes it as
running "the SSRF guard" and enforcing a required allow_domains policy --
both real, correctly-implemented checks against the REQUEST's declared
URL. Neither is ever re-applied to where a redirect response actually
sends the request at execution time.
Reproduction steps (fully local, no external network or DNS needed):
- Extract a clean station-v0.71 tarball, sys.path.insert(0, "workbench").
- import workflow_http as WH
- Start two local http.server instances on 127.0.0.1, different ports:
Server A immediately responds to any GET with
"302 Location: http://localhost:<port_B>/". Server B responds 200 and
records that it was hit.
- plan = WH.plan_http({"method": "GET", "url": "http://127.0.0.1:<port_A>/"},
{"allow_domains": ["127.0.0.1"], "allow_methods": ["GET"]},
allow_private=True) -- the ONE declared/approved host, correctly
passing policy.
- WH.apply_http_plan(plan, fake_saga, live=True) -- the real live-fire
path (fake_saga.step just calls the function directly).
- Check whether server B -- host "localhost", never present anywhere in
allow_domains=["127.0.0.1"] -- was reached.
Expected: a redirect to a host outside the declared allow_domains policy
(or one that would fail the SSRF/private-address guard) should be refused,
not silently followed.
Actual:
plan_http() approved host: 127.0.0.1 -- passes allow_domains ['127.0.0.1']
apply_http_plan() result: {'action': 'http_request', 'method': 'GET',
'url': 'http://127.0.0.1:<port_A>/', 'status': 200, 'mode': 'live',
'external_api_touched': True, 'note': 'will GET http://127.0.0.1:<port_A>/
(0 bytes) -- governed egress, read-only'}
Server B (host='localhost', NOT in allow_domains=['127.0.0.1']) was reached: True
The live request transparently followed the redirect to a completely
different, never-declared host. Worse, the returned result/receipt is
actively misleading: it reports the ORIGINAL url and a "read-only" note
describing the approved request, while status: 200 is actually the
redirect TARGET's response -- there is no field anywhere indicating a
redirect occurred or which host actually answered.
Root cause: workbench/workflow_http.py, apply_http_plan() (~line
172-176):
req = urllib.request.Request(url, data=body, method=method, headers=headers)
with urllib.request.urlopen(req, timeout=timeout_s) as r:
raw = r.read(1_048_576)
return {"status": r.status, "headers": dict(r.getheaders()), "body": ...}
Plain urllib.request.urlopen() uses Python's default opener chain, which
includes HTTPRedirectHandler -- it follows 301/302/303/307/308
responses automatically, to whatever host the Location header names,
before returning. allow_domains (checked in plan_http(), ~line 106-110)
and the SSRF/private-address guard _host_is_private() (~line 111-115)
are both only ever evaluated against the STATIC url in the original
request at STAGING time; nothing in apply_http_plan() -- or anywhere else
in this file -- re-validates the host actually reached after following
any redirect chain. A workflow author (or simply a compromised/malicious
response from an otherwise-approved host) can redirect a governed,
policy-checked request to ANY other destination, including a private or
metadata address the SSRF guard exists specifically to block, and the
station will fetch from (or, for a mutating method, POST data to) it with
no record that a redirect happened at all.
Suggested fix: build a custom urllib.request.OpenerDirector with aHTTPRedirectHandler subclass that re-runs _domain_allowed() (and the
SSRF guard, when private targets aren't explicitly allowed) against each
redirect's target host before following it -- refusing the request outright
if any hop in the chain fails policy -- or disable automatic redirect
following entirely (max_redirections = 0) and surface a 3xx as a plain
result the caller/policy layer can decide on explicitly. Either way, the
returned result should record the actual final host reached, not silently
report the original request's url as if that's where the response came
from.