Reproduction steps:
- A hook_url (customer/aggregator-configured, or set via the credential/config
write path) points at a host under the attacker's DNS control, e.g.
http://rebind.attacker.test/hook.
- The attacker's authoritative DNS answers with a PUBLIC IP when RailCall's guard
validates, and a moment later (short TTL / rebind) answers with 127.0.0.1 /
169.254.169.254 / an RFC1918 address when urllib actually connects.
- Fire the webhook (any capoff/aggregator send).
- Run repro_webhook_dns_rebind_ssrf_v099.py against a clean v0.99 extraction.
It patches socket.getaddrinfo to return a public IP during validation and a
loopback IP during connect, stands up a local listener, and shows the governed
POST land on it:
_validate_hook_url passed (guard approved the public IP)
payload delivered to the LOCAL (internal) listener: {"customer_pii":"SSN 123-45-6789",...}
Expected:
The SSRF guard's stated guarantee (webhook_bus module docstring: "a compromised
vault or a mistyped URL cannot make RailCall POST to an internal endpoint") must
hold at CONNECT time — the payload must never reach loopback / link-local /
RFC1918, regardless of DNS timing.
Actual:
_validate_hook_url (webhook_bus.py:71) resolves the host, verifies EVERY returned
IP is public, and the redirect-SSRF fix (#541f67) re-runs the same guard on each
redirect hop — but nothing PINS the connection to the validated IP. post_json
hands the HOSTNAME to urllib (line 169-173), and urllib re-resolves it via
socket.getaddrinfo at connect time. So a host that resolves public at validation
and internal at connect (DNS rebinding, or simply a record whose TTL expires
between the two resolutions) bypasses BOTH the initial check and the redirect
re-check: the governed POST — carrying the workflow payload, which routinely
contains PII/PHI — is delivered to the internal address. The repro shows the
payload arriving at a 127.0.0.1 listener after the guard approved a public IP.
This is a SIBLING of the fixed redirect-SSRF: same guarantee, different bypass
(time-of-check/time-of-use on DNS instead of a Location header).
Suggested fix:
Resolve the host ONCE, verify every returned address is public, then CONNECT to
that literal validated IP (not the hostname) while preserving the original Host
header / TLS SNI, so urllib cannot re-resolve to a different address between check
and use. Do the same pin on every redirect hop (validate the hop's host, resolve
once, connect to the pinned IP). A resolve-then-connect-by-hostname guard is
always DNS-rebind-bypassable; only IP pinning closes it.