Skip to content

fix(agent-api): close SSRF bypass via IPv4-mapped IPv6 hostnames - #981

Merged
sonichi merged 1 commit into
sonichi:mainfrom
VasiliyRad:fix/agent-api-ssrf-ipv4-mapped-ipv6
May 21, 2026
Merged

sonichi merged 1 commit into
sonichi:mainfrom
VasiliyRad:fix/agent-api-ssrf-ipv4-mapped-ipv6

Conversation

@VasiliyRad

Copy link
Copy Markdown
Contributor

Security: SSRF bypass

_is_safe_callback_url in src/agent-api.py loops getaddrinfo results against a private-IP range list. For IPv4-mapped IPv6 addresses (::ffff:127.0.0.1 and similar), Python's ipaddress cross-family in check is always False:

```python

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).

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 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.

Case Pins
test_ipv4_mapped_ipv6_loopback_is_blocked The core bypass closed — ::ffff:127.0.0.1 rejected.
test_ipv4_mapped_ipv6_rfc1918_is_blocked ::ffff:10.0.0.5 (RFC1918 generalization) rejected.
test_plain_ipv4_loopback_still_blocked No regression on plain 127.0.0.1.
test_plain_ipv6_loopback_still_blocked No regression on plain ::1.
test_public_ipv6_still_passes Public IPv6 (Cloudflare DNS) still allowed — guards against over-zealous fix that rejects all IPv6.
test_public_ipv4_still_passes Public IPv4 (1.1.1.1) still allowed.

Test plan

  • python3 tests/agent-api-ssrf-ipv4-mapped-ipv6.test.py — 6/6 pass
  • python3 -c "import ast; ast.parse(open('src/agent-api.py').read())" — syntax check
  • Manual: register a webhook with https://[::ffff:127.0.0.1]/, trigger a task completion, verify [webhook] BLOCKED appears in logs

🤖 Generated with Claude Code

## 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>
@CLAassistant

CLAassistant commented May 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@sonichi
sonichi merged commit d6aacb1 into sonichi:main May 21, 2026
1 of 2 checks passed
@sonichi

sonichi commented May 21, 2026

Copy link
Copy Markdown
Owner

LGTM. The bug is real: Python's ipaddress cross-family in check returns False, so ::ffff:127.0.0.1 passes the private-IP loop today. Practical exploit is TLS-gated (no public cert covers IPv6 IP literals → urllib.request.urlopen fails cert validation) — but defense-in-depth says the validator must catch it; the docstring explicitly claims to block private-IP-resolving hostnames.

Fix uses IPv6Address.ipv4_mapped to project onto the IPv4 private-range checks. Correctly excludes 6to4 (2002::/16) and Teredo (2001::/32) — those tunnels embed the encapsulating endpoint's IPv4, not the destination's, and projecting them would generate false positives. PR body calls this out explicitly which is a strong signal.

Tests monkey-patch socket.getaddrinfo for deterministic DNS simulation — clean shape, no network dependency.

liususan091219 pushed a commit to liususan091219/sutando-fork that referenced this pull request May 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants