Skip to content

fix(security): classify NAT64/IPv6-tunnelled IPv4 targets as non-public - #254

Merged
Zongwei9888 merged 1 commit into
HKUDS:mainfrom
raymondginger2018-sudo:pr/nat64-embedded-ipv4-classification
Oct 12, 2026
Merged

Zongwei9888 merged 1 commit into
HKUDS:mainfrom
raymondginger2018-sudo:pr/nat64-embedded-ipv4-classification

Conversation

@raymondginger2018-sudo

Copy link
Copy Markdown
Contributor

Summary

is_public_ip() in core/network/safe_http.py unwraps ipv4_mapped IPv6 addresses before classifying them, but not the other IPv6 forms that carry an IPv4 target in their low 32 bits:

  • 64:ff9b::/96 — NAT64 well-known prefix (RFC 6052)
  • 64:ff9b:1::/48 — NAT64 local-use prefix (RFC 8215; never a public destination)
  • ::/96 — the deprecated IPv4-compatible form (RFC 4291)

So a request to http://[64:ff9b::127.0.0.1]/ or http://[::169.254.169.254]/ passes .is_global — the embedded loopback / link-local metadata address is never examined — and defeats the SSRF guard that SafeHttpClient and PublicResolver both rely on. This is the CVE-2026-85740 bypass class: tunnel an internal IPv4 target through a NAT64 well-known prefix so the address classifier sees a "global" IPv6 address instead of the IPv4 target.

Changes

  • core/network/safe_http.py — add three pre-compiled networks (_NAT64_WELL_KNOWN, _NAT64_LOCAL_USE, _IPV4_COMPATIBLE). In is_public_ip(), 64:ff9b:1::/48 is rejected outright (RFC 8215 local-use is never a public destination); for the other two prefixes the embedded IPv4 address (low 32 bits) is classified instead of the IPv6 address itself. The existing ipv4_mapped branch is untouched, and the address actually used for connecting is unchanged — only the public/non-public classification changes.
  • tests/test_safe_http.py — three new non-public URL cases (http://[64:ff9b::127.0.0.1]/, http://[64:ff9b::169.254.169.254]/, http://[::127.0.0.1]/), six new test_public_ip_classification cases including the public control ("64:ff9b::808:808", True) (a DNS64 answer for public 8.8.8.8 must stay usable), and a resolver-level case that monkeypatches getaddrinfo to return 64:ff9b::169.254.169.254 and asserts PublicResolver rejects it with OSError ... non-public.

Evidence (before/after, this machine)

  • Negative control — fix reverted, tests kept: 5 cases fail (the NAT64/IPv4-compatible tunnel cases plus the resolver-level case, which did not raise). Fix restored: tests/test_safe_http.py 36 passed.
  • Consumer regression: tests/test_web_tools.py (uses SafeHttpClient through core/harness/tools/web.py) 11 passed.
  • Public-path control preserved: 64:ff9b::808:808 still classifies as public, so DNS64-resolved public targets keep working.
  • uvx ruff@0.15.21 check and uvx ruff@0.15.21 format --check both clean (the pinned revision from .pre-commit-config.yaml).

Only is_public_ip() classification changes; no new dependencies (stdlib ipaddress only) and no change to how requests are dispatched.

is_public_ip() unwrapped ipv4_mapped but not the other IPv6 forms that
carry an IPv4 target in their low 32 bits:

- 64:ff9b::/96 (NAT64 well-known prefix, RFC 6052)
- 64:ff9b:1::/48 (local-use, RFC 8215 - never a public destination)
- ::/96 (deprecated IPv4-compatible form, RFC 4291)

so '64:ff9b::127.0.0.1' and '::169.254.169.254' passed .is_global and
defeated the SSRF guard that SafeHttpClient/PublicResolver rely on
(CVE-2026-85740 bypass class). Classify the embedded IPv4 address
instead; the address used for connecting is unchanged.

Tests: 6 non-public classification cases, 1 NAT64 public-control case
(DNS64 answer for a public target stays usable), and a resolver-level
case asserting getaddrinfo returning a NAT64 tunnel to metadata is
rejected. All 5 tunnel cases fail without this change.
@raymondginger2018-sudo

Copy link
Copy Markdown
Contributor Author

CI note: Windows lifecycle locks and platform isolation fails in the environment, not in this diff

The Windows job exits 1 in the Install package and test tools step, before any test runs (no test-results/ produced):

  • scripts/ci/requirements.lock downgrades filelock to 3.32.6, packaging to 26.2, platformdirs to 4.11.8.
  • The current windows-2022 runner image (20261004.326.1) pre-installs pipx 1.17.11, which requires filelock>=4.0.9, packaging>=26.3, platformdirs>=4.12.2.
  • pip reports those conflicts and the step exits 1 (Process completed with exit code 1 immediately after the three pipx 1.17.11 has requirement ... lines). Pytest never executes.

This is independent of the change in this PR (address classification only, no dependency metadata). For reference, the same job was green on main on 2026-10-05T03:42Z, i.e. before the current runner-image/PyPI resolver state.

All other checks pass on this head: test (3.12) / test (3.13) / test (3.14) / package / browser / Desktop quality gates / lint-and-format / Secret history scan. I could not re-run the failed job with my credentials — once the lock/runner-image conflict is resolved, a re-run should go green.

@raymondginger2018-sudo

Copy link
Copy Markdown
Contributor Author

@Zongwei9888 — small follow-up on this PR.

The one red check (Windows lifecycle locks and platform isolation) fails in the environment, not in this diff — details in my earlier comment (#issuecomment-6043611534): scripts/ci/requirements.lock pins filelock 3.32.6, while the current windows-2022 runner image ships pipx 1.17.11 (requires filelock>=4.0.9), so the install step exits 1 before any test runs. All three pytest matrix legs (3.12 / 3.13 / 3.14) are green on this head.

As a fork contributor I do not have permission to re-run workflows in this repo — would you mind hitting "Re-run failed jobs" on this run? https://github.com/HKUDS/DeepCode/actions/runs/37660317969

If the lock/runner-image conflict is still present it will fail identically, in which case it needs a lock update on main; happy to prepare a minimal fix PR for that if it would help.

Zongwei9888 added a commit that referenced this pull request Oct 12, 2026
Since the 2026-10-04 windows-2022 image, the runner's Python carries a
preinstalled pipx 1.17.11 that needs filelock>=4.0.9, packaging>=26.3 and
platformdirs>=4.12.2. Installing the CI lock downgrades all three, so
`pip check` failed in "Install package and test tools" before any test ran,
on main and on every pull request.

The job now creates a venv first and puts it on PATH; the install, pip check
and test steps are unchanged. Reproduced locally with pipx 1.17.11 in a
Python 3.12 (same three conflicts), and the fresh environment passes pip
check and the Windows job's test files.

Diagnosed by the authors of #254 and #257.
@Zongwei9888
Zongwei9888 merged commit 1f6b8ae into HKUDS:main Oct 12, 2026
11 of 12 checks passed
Zongwei9888 added a commit that referenced this pull request Oct 12, 2026
…gets as non-public

`is_public_ip()` unwrapped only IPv4-mapped addresses, so
`64:ff9b::127.0.0.1`, `::169.254.169.254` and a DNS64 answer such as
`64:ff9b::a9fe:a9fe` passed as public. Addresses in the NAT64 well-known
prefix and in the deprecated IPv4-compatible form are now classified by
their embedded IPv4 address, and the RFC 8215 local-use prefix is never
public. DNS64 answers for public IPv4 addresses still pass. Repair: drop an
advisory ID that has no public record from a comment.
Contributed by raymondginger2018-sudo.
@Zongwei9888

Copy link
Copy Markdown
Collaborator

Merged in c29a42a. Thanks for this, and for tracking down the Windows CI failure.

What I checked on main before merging: is_public_ip() returned True for 64:ff9b::127.0.0.1, 64:ff9b::100.64.0.1, ::127.0.0.1 and ::169.254.169.254, and PublicResolver.resolve() accepted a getaddrinfo answer of 64:ff9b::a9fe:a9fe, so the resolver path behaved as you describe. With your change the embedded IPv4 is classified, ::/::1 stay non-public, and DNS64 answers for public IPv4 (64:ff9b::808:808) keep working.

One repair on top of your commit (c561d00): I removed the CVE-2026-85740 reference from the comment in is_public_ip(), because I couldn't find a public record for that ID. If there is one, please share a link and we'll add it back. The RFC 6052 / 8215 / 4291 references stay.

A note for the record: the three new validate_public_url cases already raised on main, because is_domain_allowed() rejects IPv6 literals (core/network/hostnames.py:25-33), so they work as regression pins rather than reproductions.

Your CI diagnosis was right: the windows-2022 image's preinstalled pipx 1.17.11 conflicted with the versions pinned in scripts/ci/requirements.lock. That is fixed on main in f6ab0e7, and the Windows job is green again.

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