Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

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

Merged
rickchen007 merged 1 commit into
mainfrom
fix/agent-api-ssrf-ipv4-mapped-ipv6
May 22, 2026
Merged

rickchen007 merged 1 commit into
mainfrom
fix/agent-api-ssrf-ipv4-mapped-ipv6

Conversation

@VasiliyRad

Copy link
Copy Markdown

Security: SSRF bypass

Real bug found while reviewing src/agent-api.py:_is_safe_callback_url. The function 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.

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

`_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 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_mapped is only non-None for the ::ffff:0:0/96 prefix — precisely the IPv4-mapped form that needs this treatment
  • The isinstance(net, IPv4Network) guard ensures the projected IPv4 isn't compared against fc00::/7 or other IPv6 networks in private_ranges
  • The 6to4/Teredo exclusion note is correct: ipv4_mapped returns 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 rickchen007 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved (admin) — security fix, reviewed in full. Logic verified, tests deterministic.

@rickchen007
rickchen007 merged commit c55b026 into main May 22, 2026
1 check passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants