Repository navigation
fix(agent-api): close SSRF bypass via IPv4-mapped IPv6 hostnames - #981
Conversation
## The bug
`_is_safe_callback_url` in `src/agent-api.py` is the SSRF defense for
webhook callbacks. It loops `getaddrinfo` results against a list of
private-IP networks. For IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1`
and similar), Python's cross-family `in` check is always False:
>>> import ipaddress
>>> ipaddress.ip_address('::ffff:127.0.0.1') in ipaddress.ip_network('127.0.0.0/8')
False # cross-family
So a webhook URL of `https://[::ffff:127.0.0.1]/exfil` passes the
validator even though it resolves to localhost. Same gap for any
RFC1918 target (`::ffff:10.0.0.5` -> 10.0.0.5, etc.).
Practical exploit is **TLS-gated** — no public cert covers IPv6 IP
literals, so `urllib.request.urlopen` on this URL fails certificate
validation. But defense-in-depth says the validator MUST catch it —
the function's docstring explicitly claims to block "Hostnames that
resolve to private IPs" and the SSRF allowlist is the canonical guard
for callback URLs.
## Fix
After the existing per-address loop, if the address is an
`IPv6Address` with `ipv4_mapped` set, project the mapped IPv4 onto
the IPv4 private-range checks. Catches the bypass with no change to
the existing IPv4 / pure-IPv6 paths.
Note on 6to4 / Teredo: 6to4 (`2002::/16`) and Teredo (`2001::/32`)
are public-routable IPv6 tunneling schemes whose embedded IPv4 is
the *encapsulating endpoint*, not the destination — projecting
those onto private-IPv4 checks would generate false positives. Only
v4-mapped (`::ffff:0:0/96`) needs the projection.
## Tests
`tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py` (6 cases). Monkey-
patches `socket.getaddrinfo` so each case deterministically simulates
a DNS response — no network dependency.
- `test_ipv4_mapped_ipv6_loopback_is_blocked` — the core bypass
- `test_ipv4_mapped_ipv6_rfc1918_is_blocked` — RFC1918 generalization
- `test_plain_ipv4_loopback_still_blocked` — no regression on plain v4
- `test_plain_ipv6_loopback_still_blocked` — no regression on plain v6
- `test_public_ipv6_still_passes` — defends against over-zealous fix
- `test_public_ipv4_still_passes` — sanity
All 6 pass locally.
Co-Authored-By: Vasiliy's Sutando <noreply@anthropic.com>
|
LGTM. The bug is real: Python's Fix uses Tests monkey-patch |
…ichi#981) ## The bug `_is_safe_callback_url` in `src/agent-api.py` is the SSRF defense for webhook callbacks. It loops `getaddrinfo` results against a list of private-IP networks. For IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1` and similar), Python's cross-family `in` check is always False: >>> import ipaddress >>> ipaddress.ip_address('::ffff:127.0.0.1') in ipaddress.ip_network('127.0.0.0/8') False # cross-family So a webhook URL of `https://[::ffff:127.0.0.1]/exfil` passes the validator even though it resolves to localhost. Same gap for any RFC1918 target (`::ffff:10.0.0.5` -> 10.0.0.5, etc.). Practical exploit is **TLS-gated** — no public cert covers IPv6 IP literals, so `urllib.request.urlopen` on this URL fails certificate validation. But defense-in-depth says the validator MUST catch it — the function's docstring explicitly claims to block "Hostnames that resolve to private IPs" and the SSRF allowlist is the canonical guard for callback URLs. ## Fix After the existing per-address loop, if the address is an `IPv6Address` with `ipv4_mapped` set, project the mapped IPv4 onto the IPv4 private-range checks. Catches the bypass with no change to the existing IPv4 / pure-IPv6 paths. Note on 6to4 / Teredo: 6to4 (`2002::/16`) and Teredo (`2001::/32`) are public-routable IPv6 tunneling schemes whose embedded IPv4 is the *encapsulating endpoint*, not the destination — projecting those onto private-IPv4 checks would generate false positives. Only v4-mapped (`::ffff:0:0/96`) needs the projection. ## Tests `tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py` (6 cases). Monkey- patches `socket.getaddrinfo` so each case deterministically simulates a DNS response — no network dependency. - `test_ipv4_mapped_ipv6_loopback_is_blocked` — the core bypass - `test_ipv4_mapped_ipv6_rfc1918_is_blocked` — RFC1918 generalization - `test_plain_ipv4_loopback_still_blocked` — no regression on plain v4 - `test_plain_ipv6_loopback_still_blocked` — no regression on plain v6 - `test_public_ipv6_still_passes` — defends against over-zealous fix - `test_public_ipv4_still_passes` — sanity All 6 pass locally. Co-authored-by: Vasiliy's Sutando <noreply@anthropic.com>
Security: SSRF bypass
_is_safe_callback_urlinsrc/agent-api.pyloopsgetaddrinforesults against a private-IP range list. For IPv4-mapped IPv6 addresses (::ffff:127.0.0.1and similar), Python'sipaddresscross-familyincheck is always False:```python
So a webhook URL of
https://[::ffff:127.0.0.1]/exfilpasses the validator even though it resolves to localhost. Same gap for any RFC1918 target (::ffff:10.0.0.5→ 10.0.0.5).Practical exploit is TLS-gated — no public cert covers IPv6 IP literals, so
urllib.request.urlopenon this URL fails certificate validation. But defense-in-depth says the validator MUST catch it — the docstring explicitly claims to block "Hostnames that resolve to private IPs" and the SSRF allowlist is the canonical guard for callback URLs.Fix
After the existing per-address loop, if the address is an
IPv6Addresswithipv4_mappedset, project the mapped IPv4 onto the IPv4 private-range checks. Catches the bypass with no change to the existing IPv4 / pure-IPv6 paths.Note on 6to4 / Teredo: 6to4 (
2002::/16) and Teredo (2001::/32) are public-routable IPv6 tunneling schemes whose embedded IPv4 is the encapsulating endpoint, not the destination — projecting those onto private-IPv4 checks would generate false positives. Only v4-mapped (::ffff:0:0/96) needs the projection.Tests
tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py(6 cases). Monkey-patchessocket.getaddrinfoso each case deterministically simulates a DNS response — no network dependency.test_ipv4_mapped_ipv6_loopback_is_blocked::ffff:127.0.0.1rejected.test_ipv4_mapped_ipv6_rfc1918_is_blocked::ffff:10.0.0.5(RFC1918 generalization) rejected.test_plain_ipv4_loopback_still_blocked127.0.0.1.test_plain_ipv6_loopback_still_blocked::1.test_public_ipv6_still_passestest_public_ipv4_still_passesTest plan
python3 tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py— 6/6 passpython3 -c "import ast; ast.parse(open('src/agent-api.py').read())"— syntax checkhttps://[::ffff:127.0.0.1]/, trigger a task completion, verify[webhook] BLOCKEDappears in logs🤖 Generated with Claude Code