Skip to content

fix(security): block IPv4-mapped IPv6 SSRF bypass - #374

Open
LivXue wants to merge 2 commits into
mainfrom
fix/ssrf_v4_mapped_ipv6
Open

fix(security): block IPv4-mapped IPv6 SSRF bypass#374
LivXue wants to merge 2 commits into
mainfrom
fix/ssrf_v4_mapped_ipv6

Conversation

@LivXue

@LivXue LivXue commented Aug 29, 2026

Copy link
Copy Markdown
Member

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 like http://[::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_private normalizes IPv4-mapped IPv6 via ipv4_mapped before the blocklist check, so private embedded targets are blocked and public ones stay allowed.
  • Added the 6to4 (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.py redirect hops), so one fix covers all paths.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

  • Relevant tests pass locally
  • Relevant lint / type checks pass locally
  • User-facing docs or screenshots are updated when needed

Commands run:

.venv/bin/python -m pytest tests/test_security_network.py tests/test_security_web_ssrf.py tests/test_security_trust.py tests/test_security_untrusted_context.py -q   # 47 passed
.venv/bin/python -m pytest tests/test_agent_loop_web_tools.py tests/test_search_tools.py tests/test_file_search_traversal_guard.py -q                       # 25 passed
.venv/bin/ruff check raven/security/network.py tests/test_security_network.py                                                                             # all checks passed

Nine new regression cases cover v4-mapped loopback/private/metadata (blocked), public v4-mapped (allowed), 6to4/NAT64 (blocked), and the redirect-path validator.

Risk

  • Security impact considered
  • Backward compatibility considered
  • Rollback path is clear for risky changes

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_public and the existing public-host tests). Rollback: revert the single commit.

Related Issues

N/A

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 gloryfromca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Comment thread raven/security/network.py Outdated
# 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"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 gloryfromca left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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.

2 participants