fix(security): block IPv4-mapped IPv6 SSRF bypass - #374
Conversation
The SSRF guard judged addresses by their literal spelling against the blocklist. An IPv4-mapped IPv6 literal such as ::ffff:127.0.0.1 parses as an IPv6Address, so it matched none of the blocked networks, yet the kernel routes it to the embedded IPv4 address. Normalize IPv4-mapped addresses via ipv4_mapped before the check and block the 6to4 (2002::/16) and NAT64 (64:ff9b::/96) prefixes, which likewise route to an embedded IPv4 destination. Co-authored-by: Claude (deepseek-v4-pro) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
Blocking: the transition-prefix entries reject legitimate public destinations; see the inline note.
I reviewed github/main...HEAD, the WebFetch and DingTalk redirect callers, relevant validator history, backward compatibility, and the test delta; no tests were weakened. I also checked the repository rules in AGENTS.md / CLAUDE.md and the security terminology routed by CONTEXT-MAP.md. The IPv4-mapped normalization itself correctly blocks mapped private addresses while preserving mapped public addresses.
Verification:
uv run pytest tests/test_security_network.py -q- 27 passed.uv run pytest tests/test_security_web_ssrf.py tests/test_security_trust.py tests/test_security_untrusted_context.py tests/test_agent_loop_web_tools.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py -q- 45 passed.- Direct public-transition probes reproduced the blocking behavior described inline.
git diff --check github/main...HEADpassed.
| # 6to4 (RFC 3056) and NAT64 (RFC 6052) route to an embedded IPv4 | ||
| # destination, so an IPv4-only blocklist would miss them. | ||
| ipaddress.ip_network("2002::/16"), | ||
| ipaddress.ip_network("64:ff9b::/96"), |
There was a problem hiding this comment.
Blocking the whole 64:ff9b::/96 and 2002::/16 ranges rejects public IPv4 destinations, breaking legitimate NAT64 and 6to4 fetches instead of only blocking embedded private addresses. For example, direct probes of http://[64:ff9b::5db8:d822]/x (NAT64 for public 93.184.216.34) and http://[2002:5db8:d822::]/x both return False. On IPv6-only networks, DNS64 synthesizes 64:ff9b::/96 addresses for legitimate IPv4 services, so this contradicts the PR's backward-compatibility claim that public fetches are unaffected. Please extract the embedded IPv4 address and apply the existing blocklist to it, as the mapped-address path does, so private targets remain blocked while public ones remain usable.
There was a problem hiding this comment.
Fixed in aaca2b8: 6to4 and NAT64 are no longer blocked wholesale. The guard now extracts the embedded IPv4 (ipv4_mapped / sixtofour / low 32 bits of the NAT64 well-known prefix) and applies the existing blocklist to it. Your probes now pass: http://[64:ff9b::5db8:d822]/x and http://[2002:5db8:d822::]/x are allowed, while http://[64:ff9b::7f00:1]/x and http://[2002:7f00:1::]/x remain blocked. New tests cover both directions (tests/test_security_network.py, 56 security+downstream tests green).
There was a problem hiding this comment.
Verified on aaca2b8: mapped, NAT64, and 6to4 private destinations remain blocked, while the corresponding public destinations are allowed. I also ran the focused and downstream suites with uv run pytest; all 76 tests passed. This resolves my blocker.
Blocking 6to4 and NAT64 prefixes wholesale also rejected legitimate public destinations: DNS64 networks synthesize 64:ff9b::/96 addresses for public IPv4 services. Extract the embedded IPv4 (ipv4_mapped, sixtofour, or the low 32 bits of the NAT64 well-known prefix) and apply the existing blocklist to it, so private embedded targets stay blocked while public ones remain usable. Co-authored-by: Claude (deepseek-v4-pro) <noreply@anthropic.com>
gloryfromca
left a comment
There was a problem hiding this comment.
No blockers; this can merge as far as I am concerned.
I re-reviewed github/main...HEAD and the delta from dc36c92168b3, including the WebFetch and DingTalk redirect callers, relevant validator history, backward compatibility, repository rules in AGENTS.md / CLAUDE.md, and the security terminology routed by CONTEXT-MAP.md. I also checked that tests were not weakened. The prior transition-prefix overblocking is resolved: mapped, 6to4, and NAT64 addresses are judged by their embedded IPv4 destination, preserving public fetches while blocking private targets. The updated tests cover both directions.
Verification:
uv run pytest tests/test_security_network.py tests/test_security_web_ssrf.py tests/test_security_trust.py tests/test_security_untrusted_context.py tests/test_agent_loop_web_tools.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py -q- 76 passed.- Direct probes covered private and public mapped, NAT64, and 6to4 addresses; all six returned the expected verdict.
git diff --check github/main...HEADpassed.
Summary
The SSRF URL guard judged addresses by their literal spelling against a network blocklist, but the kernel routes IPv4-mapped IPv6 literals (
::ffff:a.b.c.d) to the embedded IPv4 address. A URL likehttp://[::ffff:127.0.0.1]/(or the cloud metadata alias::ffff:169.254.169.254) therefore passed validation while connecting to a private target.Changes:
_is_privatenormalizes IPv4-mapped IPv6 viaipv4_mappedbefore the blocklist check, so private embedded targets are blocked and public ones stay allowed.2002::/16) and NAT64 well-known (64:ff9b::/96) prefixes to the blocklist; both route to an embedded IPv4 destination that an IPv4-only list misses.Both SSRF entry points funnel through
_is_private(raven/agent/tools/web.py,raven/channels/adapters/dingtalk/api.pyredirect hops), so one fix covers all paths.Type
Verification
Commands run:
Nine new regression cases cover v4-mapped loopback/private/metadata (blocked), public v4-mapped (allowed), 6to4/NAT64 (blocked), and the redirect-path validator.
Risk
Behaviour change is confined to the SSRF guard: URLs using IPv4-mapped IPv6 literals, 6to4, or NAT64 prefixes were previously validated against the part of the blocklist that could never match them, and are now rejected when they embed a private destination. Legitimate public fetches are unaffected (covered by
test_allows_v4_mapped_publicand the existing public-host tests). Rollback: revert the single commit.Related Issues
N/A