Affected: station-v0.88
Station: v0.88 (isolated container). File: primitives/webhook_bus.py — _validate_hook_url (71-109) vs WebhookClient.post_json (136-172). Reached by: the cap-off webhook connectors run_{zapier,make,n8n,pipedream,webhook_out}_{live,approve} (routes/dispatch_cap_off_wave2.py → cap_off_endpoints → WebhookClient(hook_url=…, live=True)). Class: SSRF / server-side request forgery (CWE-918).
The guarantee it breaks
webhook_bus ships an SSRF guard, _validate_hook_url, whose own docstring says it "refuses anything that resolves to a non-public IP" — it resolves DNS and blocks loopback (127/8, ::1), link-local (169.254/16 — the docstring names 169.254.169.254, the AWS/GCP metadata service, as "the classic SSRF target"), RFC1918, multicast, reserved. It is the control that keeps a governed, human-approved webhook send from being pointed at an internal/metadata address.
The break — the guard checks the static URL; post_json follows redirects with no re-check
_validate_hook_url runs only in __init__ (line 128, live mode), against the static hook_url. post_json (136-152) then does:
req = urllib.request.Request(self.hook_url, data=body, method="POST", headers=headers)
with urllib.request.urlopen(req, timeout=self.timeout_seconds) as r:
...
Plain urllib.request.urlopen uses the default opener, whose HTTPRedirectHandler auto-follows 301/302/303 to whatever host the Location header names — before returning — and nothing re-runs _validate_hook_url / _is_public_ip against the redirected host. So the approved public hook endpoint (attacker-controlled if the workflow author declares it, or a legitimate endpoint that is compromised or performs an open redirect) can bounce the governed request to an address the SSRF guard exists specifically to block.
Proof (container, real functions)
redirect 301: target reached=True method=GET # followed to the forbidden host
redirect 302: target reached=True method=GET
redirect 303: target reached=True method=GET
redirect 307: target reached=False # explicit POST not auto-redirected (see scope)
redirect 308: target reached=False
SSRF guard treats metadata identically to loopback:
_is_public_ip(127.0.0.1) = False # refused as an init URL
_is_public_ip(169.254.169.254) = False # refused as an init URL — but reached via redirect
_is_public_ip(10.0.0.1) = False
The post_json of a WebhookClient pointed at an approved hook that returns 302 Location: http://169.254.169.254/… follows it and issues the request to the metadata host — a host _validate_hook_url would have refused outright. Proven with two loopback servers (server A returns the redirect, server B — a host the guard rejects — is reached); 169.254.169.254 is refused by the identical _is_public_ip check, so it is followed identically.
Impact
Server-side request forgery: the station is coerced into issuing a bodyless GET to an internal/private/link-local HTTP(S) address it was never authorized to reach — including, where the deployment exposes it, 169.254.169.254 cloud instance-metadata (a common credential-exposure path; actual token retrieval is conditional on network reachability and controls like IMDSv2, and is not demonstrated here — only that the forbidden host is reached). The redirecting host is the "approved" webhook endpoint, so the airlock's approval and the up-front SSRF check are both satisfied while the actual fetch lands on a forbidden host. (post_json returns the redirect target's response; it does not surface that a redirect occurred or which host actually answered.)
Honest scope (not overclaimed)
- SSRF (GET), not POST-body exfiltration. 301/302/303 downgrade the method to GET, so the JSON payload is dropped on the redirect; the station issues a GET to the forbidden host. 307/308 (which would preserve POST+body) are not auto-followed here for the explicit-POST request, so I do not claim payload exfiltration — only that the station is coerced into a GET to an internal/metadata address it was never authorized to reach.
- Precondition: a live webhook connector (zapier/make/n8n/pipedream/webhook_out) configured and approved; the redirecting endpoint is the declared hook (attacker-declared, compromised, or open-redirecting).
- Not unauthenticated / not a station-wide compromise. Constrained GET-only SSRF to an internal host; metadata credential theft is a conditional worst case, not demonstrated.
Distinctness
Same class as shweta's ★5 "Governed HTTP node allow_domains policy and SSRF guard are bypassed by a 3xx redirect" — fixed in station-v0.74 in workflow_http.py apply_http_plan. This is a different file and connector path (webhook_bus.py post_json, the cap-off webhook connectors) that the v0.74 fix did not touch: webhook_bus still calls bare urllib.request.urlopen, so the same redirect-SSRF is live here. I did not find a board thread about webhook_bus / the cap-off connectors following redirects (checked the community board).
Fix
Mirror the v0.74 workflow_http fix in webhook_bus.post_json: build a custom OpenerDirector with an HTTPRedirectHandler subclass that re-runs _validate_hook_url (_is_public_ip) against each redirect hop's host before following — refusing if any hop resolves non-public — or disable auto-redirect (max_redirections=0) and surface a 3xx as an explicit result for the policy layer. Record the actual final host in the result, not the original hook_url.
Reviewed adversarially against the source before posting.
Signed receipt (railcall verify → SIGNATURE VALID, offline):
{
"schema": "railcall_audit_receipt.v1",
"ran_at": "2026-08-13T15:41:05",
"file": {
"name": "fw.csv",
"sha256": "sha256:3e2fcd468917f5b249c0fcce5dfccf57cb90bc80d646640e674d53bb71de217d",
"bytes": 427
},
"audit": {
"rows": 1,
"columns": 1,
"import_breakers": 0,
"pii_columns": 0,
"formula_injection_cells": 0,
"findings": []
},
"network_audit": {
"lsof_available": false,
"error": "lsof_not_found",
"external_sockets_open": null
},
"result": "audited_with_input_warning",
"input_warning": "input does not look like CSV (no CSV dialect detected and only 1 column parsed) \u2014 parsed as CSV anyway; results may be meaningless",
"receipt_version": "v2",
"flow": {
"dry_run": true,
"name": "audit",
"action_type": "audit"
},
"governance": {
"policy_ref": "none",
"policy_hash": "ff56072e81ed4908ea91f567741238b387e536cd1f5974513ee18df0d5c575b9",
"approval_chain": [],
"risk_classification": "unknown",
"irreversible": false
},
"execution": {
"input_sha256": "sha256:3e2fcd468917f5b249c0fcce5dfccf57cb90bc80d646640e674d53bb71de217d",
"output_sha256": "",
"duration_ms": 0,
"exit_code": 0
},
"signer_alg": "ed25519",
"public_key_hex": "ea2446fec9cc4de478c853fb35c778262d4327ac7d32d6ccff36bdbbfcd775e2",
"signature_hex": "3fdac2dda2d3a763cbf993443f6b88f7dc9550c26e6f6fd74bcd6887c16c952dd85a6ee5174a1ae472a03bf7c9614e56e9532dd0f7c8a426cda71b4009eedf0e"
}