The webhook tester endpoint POST /api/webhook_out/test decides whether a live
send is allowed by looking at the URL STRING only — never resolving the host to
reject a non-public IP — so it makes the station POST to internal/private HTTPS
services and to arbitrary loopback ports. The governed webhook send path guards
exactly this with a DNS-resolving SSRF check; the tester omits it (a sink-parity
gap: the SSRF control on the real send path is not on the test send path).
routes/dispatch_sends._handle_webhook_out_test (session-gated) gates a live send
on the scheme alone:
url = (body.get("url") or "").strip()
if not dry and not (url.startswith("https://")
or url.startswith("http://127.0.0.1")
or url.startswith("http://localhost")):
return {"ok": False, "error": "...valid webhook URL..."}
return ... run_webhook_out(url, target, msg, dry_run=bool(dry)) ...
run_webhook_out -> routes/rails.send_webhook -> studio_server._post_no_redirect,
which just builds urllib.request.Request(url) and POSTs with no host validation.
So the scheme filter admits every one of these:
ANY https://<host> — https://10.0.0.5, https://192.168.1.10, https://[::1],
https://<cloud-internal>, https://169.254.169.254 — each of which the
governed guard refuses as a non-public IP;
http://127.0.0.1:<port> and http://localhost:<port> — any loopback port (a
local admin UI, a database's HTTP interface, another service on the box).
The station then POSTs a JSON body (the webhook "shape" carrying the caller'smessage) to that internal target. The governed path does not allow this:
primitives/webhook_bus._validate_hook_url is documented "SSRF guard. Parses the
URL, resolves DNS, and refuses anything that resolves to a non-public IP", and
the v0.99 DNS-rebind fix further pins the connection to the validated IP. The
tester reaches none of that — its only runtime checks are response-content
heuristics (a Google sign-in page) and a small blocked-host list, neither of
which is an IP guard.
The endpoint is session-gated and Origin-checked, so it is reached with the
Studio session (a local process on the station host, or a same-origin request);
the security-relevant gap is that a control the product enforces on its real
egress path is simply absent on this one, letting the station be used to probe
and POST to hosts it is otherwise built to refuse.
Reproduction steps:
- Extract the station-v1.2.0 tarball to a clean directory; put workbench/ and
its parent on sys.path; import studio_server, primitives.webhook_bus,
routes.rails.
- Confirm the governed guard refuses private/loopback:
webhook_bus._validate_hook_url("https://10.0.0.5/x"),
("http://127.0.0.1:9200/x"), ("https://169.254.169.254/latest") all raise.
- Confirm the tester's live-send gate admits them: the check
`url.startswith("https://") or url.startswith("http://127.0.0.1") or
url.startswith("http://localhost")` is True for each.
- Stand up a loopback HTTP server on a random port and call
routes.rails.send_webhook("http://127.0.0.1:<port>/internal", "generic",
"probe", dry_run=False); confirm the POST is received by that server.
Expected: the tester applies the same SSRF guard as the governed send path —
resolve the host and refuse a non-public/loopback IP before any POST.
Actual:
governed _validate_hook_url -> REFUSES 10.0.0.5, 192.168.1.10,
127.0.0.1:9200, 169.254.169.254
tester scheme gate -> ADMITS all of them
send_webhook to a loopback -> result=SENT, http_status=200, and the
server loopback server RECEIVES the POST body
{"event":"railcall_test","message":"probe",...}
Root cause: the tester validates the URL scheme string instead of resolving the
destination IP, so it never applies the private/loopback SSRF guard the governed
webhook path (webhook_bus._validate_hook_url) enforces. A https:// prefix is
not evidence that the host is public.
Suggested fix: run the same guard on the tester before firing — call
webhook_bus._validate_hook_url(url) (which resolves DNS and refuses non-public
IPs, and whose v0.99 hardening pins the connection to the validated address) in
_handle_webhook_out_test / send_webhook, and drop the scheme-prefix heuristic in
favor of that resolved-IP check. If a loopback tester is genuinely wanted, gate
it behind an explicit allow_loopback flag the operator sets, not a blanket
http://127.0.0.1 prefix allowance.