Repository navigation
fix(agent-api): close SSRF bypass via IPv4-mapped IPv6 hostnames - #31
Conversation
`_is_safe_callback_url` loops `getaddrinfo` results against a private-IP
range list. For IPv4-mapped IPv6 addresses (`::ffff:127.0.0.1` and
similar), the loop returns False:
>>> import ipaddress
>>> ipaddress.ip_address('::ffff:127.0.0.1') in ipaddress.ip_network('127.0.0.0/8')
False # cross-family `in` is always False
So a webhook URL of `https://[::ffff:127.0.0.1]/exfil` would pass 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.
Test: `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. Covers:
- IPv4-mapped IPv6 → loopback BLOCKED (the core bypass)
- IPv4-mapped IPv6 → 10.0.0.5 BLOCKED (RFC1918 generalization)
- Plain IPv4 loopback still BLOCKED (no regression)
- Plain IPv6 loopback `::1` still BLOCKED (no regression)
- Public IPv6 (2606:4700:4700::1111) still ALLOWED
- Public IPv4 (1.1.1.1) still ALLOWED
All 6 pass locally.
Note on `sixtofour` / `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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Cold review (proactive-loop pass)
The vulnerability is real and the fix is correct. Python's ipaddress cross-family in operator always returns False, so ::ffff:127.0.0.1 in ip_network('127.0.0.0/8') silently passes — the bypass is confirmed behavior, not theoretical.
The fix (ipv4_mapped projection) is exactly right:
IPv6Address.ipv4_mappedis only non-None for the::ffff:0:0/96prefix — precisely the IPv4-mapped form that needs this treatment- The
isinstance(net, IPv4Network)guard ensures the projected IPv4 isn't compared againstfc00::/7or other IPv6 networks inprivate_ranges - The 6to4/Teredo exclusion note is correct:
ipv4_mappedreturns None for those, so they're already handled properly
Minor precision note (non-blocking): the PR description says "IPv4-compatible IPv6 (::a.b.c.d) exposed via IPv6Address.ipv4_mapped" — in Python 3, ipv4_mapped only matches the ::ffff: form, not the deprecated ::a.b.c.d IPv4-compatible form. The code itself is correct; just the comment is slightly loose. IPv4-compatible addresses are deprecated (RFC 4291 §2.5.5.1) and don't appear in practice.
Tests: the monkey-patch approach (socket.getaddrinfo replacement) is the right shape for a network-dependency-free unit test. 6 cases cover: bypass closed (loopback + RFC1918), no regression on existing paths (plain IPv4 private, plain IPv6 loopback), and no over-blocking (public IPv4/IPv6 still pass). Full coverage of the interesting state space.
No blockers. LGTM.
rickchen007
left a comment
There was a problem hiding this comment.
Approved (admin) — security fix, reviewed in full. Logic verified, tests deterministic.
Security: SSRF bypass
Real bug found while reviewing
src/agent-api.py:_is_safe_callback_url. The function loopsgetaddrinforesults 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_passesBug-finding calibration
1 real bug (security, SSRF defense-in-depth) fixed across 6 tests. Total this round: 9 bugs / ~100 tests ≈ 1:11.
Test 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