Skip to content

proposal: net: IP.IsPrivate is widely misused as a security primitive — extend semantics and improve documentation #79925

Description

@JLLeitschuh

Summary

net.IP.IsPrivate (and the matching method on netip.Addr) is repeatedly misused across the Go ecosystem as the basis for SSRF deny-lists, yielding a steady stream of high-severity CVEs. A Go networking maintainer has publicly stated that the method "was a mistake" because of this misuse, but the documentation contains no warning to that effect, and the method itself does not cover several address forms — most notably IPv6 transition mechanisms that embed RFC 1918 / loopback IPv4 addresses — that any reasonable interpretation of "private" should include.

This issue proposes:

  1. Primary: Extend IsPrivate to return true for addresses that represent connections to RFC 1918 / RFC 4193 destinations via well-defined address-family translation, specifically: 6to4 (RFC 3056), Teredo (RFC 4380), NAT64 well-known prefix (RFC 6052 + RFC 8215), IPv4-mapped / IPv4-compatible IPv6 (RFC 4291), and ISATAP-style embedded IPv4 (RFC 5214) — in each case decoding the embedded IPv4 and applying the existing RFC 1918 check. This is a transitivity argument: if the address routes to a private IPv4 destination through a documented translation gateway, it is operationally private.

  2. Secondary (acknowledged contestable): Consider adding RFC 6598 Carrier-Grade NAT (100.64.0.0/10) to IsPrivate's scope. CGNAT space is widely used as private allocation by ISPs and mesh-VPN products (Tailscale, Netbird), but Python's stdlib explicitly chose not to include CGNAT in is_private on the basis that CGNAT is globally routable from the carrier's perspective. I'm noting this as a possible extension but would not block the rest of the proposal on it; the IPv6-transition extensions in item (1) are the more clearly correct ask.

  3. Fallback / minimum bar: Add explicit security-misuse warnings to the doc comments of IsPrivate, IsGlobalUnicast, IsLoopback, IsLinkLocalUnicast, IsMulticast, and IsUnspecified — both in net.IP and netip.Addr — pointing out that no single classifier is suitable as an SSRF deny-list and recommending purpose-built libraries (e.g., IANA Special Purpose Registry-driven).

The fallback alone would substantially reduce ecosystem harm. The combination of (1) + (2) + (3) is what "safe defaults" requires.


Evidence: this is a pervasive recurring vulnerability class

1. The Go maintainer assessment

From #76067 (comment), Damien Neil (@neild), Nov 2025:

From what I've seen, IsPrivate was a mistake. Just about every use of it that I've seen seems to misuse it. For example, declaring that "if net.IP.IsPrivate, then the host at address is trusted", which is generally not a safe assumption.

This is a public, on-the-record acknowledgement by a Go networking maintainer that the documented API is being widely misused as a security control. Yet the current documentation contains no warning of any kind:

func (ip IP) IsPrivate() bool

    IsPrivate reports whether ip is a private address, according to RFC 1918 
    (IPv4 addresses) and RFC 4193 (IPv6 addresses).

Compare to Python's documentation of pickle.loads, which the language team likewise considers dangerous but at least loudly warns about ("Only unpickle data you trust"). Go's silence on a documented misuse is below the bar that comparable language ecosystems hold themselves to.

2. The Go standard library itself shipped a CVE because of this design

CVE-2024-24790 / GO-2024-2887net/netip:

The various Is methods (IsPrivate, IsLoopback, etc) did not work as expected for IPv4-mapped IPv6 addresses, returning false for addresses which would return true in their traditional IPv4 forms.

Six methods were affected by the stdlib bug: Addr.IsPrivate, Addr.IsGlobalUnicast, Addr.IsLoopback, Addr.IsMulticast, Addr.IsLinkLocalMulticast, Addr.IsInterfaceLocalMulticast. The Go team got the address-form normalization wrong in their own implementation.

The CVSS 9.8 score is a theoretical maximum applied to a generic-impact stdlib bug; I'm not aware of public exploitation reports of CVE-2024-24790 in specific downstream projects, and the supply-chain advisory propagation (RHSA, USN, etc.) does not by itself demonstrate codepath exploitation. What this CVE does establish is the narrower-but-more-important point: the address-form normalization at the IPv4-embedded-in-IPv6 boundary is hard enough that even Go's own maintainers got it wrong. Expecting every downstream developer to navigate it reliably is unreasonable.

3. Downstream Go-ecosystem CVEs caused by the documented misuse pattern

Each of the following is a public advisory or maintainer-acknowledged vulnerability where the code review confirmed that the project's SSRF deny-list was built directly from Go's Is* classifier methods, and where the bypass was achieved using addresses outside the method's narrow scope:

Advisory Project Year What they wrote What got through
GHSA-2r5c-gw76-rh3w go-gitea/gitea 2026 ip.IsGlobalUnicast() && !ip.IsPrivate() (source) CGNAT, Azure WireServer, NAT64, Teredo, 6to4, non-RFC1918 172.32/11
GHSA-56c3-vfp2-5qqj / CVE-2026-44430 modelcontextprotocol/registry 2026 IsLoopback / IsPrivate / IsLinkLocalUnicast / IsMulticast / IsUnspecified + a manual CGNAT range 6to4 (2002::/16), NAT64 (64:ff9b::/96, 64:ff9b:1::/48), deprecated site-local (fec0::/10)
GHSA-86m8-88fq-xfxp / CVE-2026-45741 gotenberg/gotenberg 2026 IsPublicIP calling IsLoopback / IsPrivate / IsLinkLocalUnicast with Unmap() (which normalizes only ::ffff:) 6to4, NAT64, site-local — Unmap() doesn't normalize them
Issue #3074 (GHSA-8cp7-rp8r-mg77) sipeed/picoclaw 2026 IsLoopback / IsLinkLocalUnicast / IsLinkLocalMulticast / IsMulticast / IsUnspecified + manual byte-checks for 6to4 and Teredo ISATAP (:5efe:-embedded IPv4 in arbitrary <prefix>:5efe:<ipv4> form)

Gotenberg and picoclaw are particularly relevant: both projects were trying to build a comprehensive deny-list, both reached for Go's Is* methods as the foundation, and both shipped CVEs because the surface area of "address forms that should be private" is larger than the stdlib's Is* family covers. picoclaw even hand-rolled 6to4 and Teredo handling — and still missed ISATAP. This is the pattern: when developers try harder, they still get it wrong, because every gap requires per-RFC bespoke handling.

Three additional advisories are adjacent but worth noting for different reasons:

  • GHSA-vwq2-jx9q-9h9f / CVE-2025-64522 (soft-serve) — vulnerable code performed no validation. Not stdlib misuse; just absent protection. Listed to show the bug class is widespread in Go webhook implementations.

  • GHSA-mpf7-p9x7-96r3 (mailpit) — vulnerable code performed no validation either, but the advisory's remediation guidance is itself the broken pattern: the documented fix instructs maintainers to add IsLoopback() || IsPrivate() || IsLinkLocalUnicast(). Security advisories are currently teaching the misuse, because no better stdlib option exists.

  • CVE-2024-24790 (Go stdlib netip) — covered above.

4. Cross-ecosystem state of the art

Python (3.13+) has done what this issue is asking Go to do. From the ipaddress docs, is_private returns True if the address is not globally reachable per the IANA IPv4 and IPv6 Special Purpose Registries, with explicit handling for IPv4-mapped IPv6 (address.is_private == address.ipv4_mapped.is_private) and explicit True returns for 6to4 (2002::/16) and NAT64 (64:ff9b::/96, 64:ff9b:1::/48 per RFC 8215) added in 3.13. Python's maintainers shipped this without compatibility disasters; it is existence proof that a language stdlib can carry the comprehensive semantics this issue proposes.

Worth noting: Python made the opposite call on CGNAT (is_private returns False for 100.64.0.0/10 — explicit carve-out). The defensible reason is that CGNAT is globally routable from the carrier's perspective, and conflating it with RFC 1918 muddles the routing-architecture meaning. For the purposes of this issue, I would defer to Python's call on CGNAT and drop that specific ask if it eases acceptance — the IPv6-transition extensions are independently necessary and don't depend on CGNAT.

Other language stdlibs (Java's InetAddress.isSiteLocalAddress, Rust's Ipv4Addr::is_private, Ruby's IPAddr#private?, PHP's FILTER_FLAG_NO_PRIV_RANGE) have the same gap as Go. I'm not arguing Go is uniquely worse than other stdlibs; I'm arguing that Python has shown the gap is fixable, and that the downstream CVE pattern documented above is appearing across the ecosystems whose stdlibs left the gap unfilled.

Beyond stdlibs, the comprehensive treatment is implemented in:

  • CVE-2026-48736 — Symfony (PHP application framework). IpUtils::PRIVATE_SUBNETS did not include 6to4, Teredo, NAT64, or ::/96 until the fix; the fix's commit message cites Chromium and Mozilla's Private Network Access policy as authority.
  • CC-Tweaked's AddressPredicate — third-party Java library, comprehensive coverage of all transition prefixes.
  • Chromium / Mozilla Private Network Access — browser-side policy. Covers the IPv6 transition forms in scope.
  • code.dny.dev/ssrf — Go third-party library, IANA-registry-driven, auto-synced. Exists precisely because the stdlib gap forces every Go project that wants comprehensive coverage to either reinvent this or import a third-party dependency.

The set of applications that need this comprehensive coverage is much larger than the set that have it. That asymmetry is what the documentation gap propagates.

4. Prior Go-issue history confirms the gap is known

The institutional knowledge that this API is misused exists inside the Go team. The institutional response — document the trap, fix the gap, or both — has not happened.


Proposed change: extend IsPrivate semantics

Definition

A more useful definition of "private" — one that matches how the API is actually used and is defensible from an addressing-architecture standpoint — is:

An address is private if a packet sent to it is, by virtue of the address itself or a documented address-family translation it encodes, destined for a network that is not part of the global routing system.

This expands the current RFC 1918 / RFC 4193 set by adding IPv4-in-IPv6 translation handling:

  1. RFC 4291 §2.5.5 — IPv4-mapped IPv6, ::ffff:0:0/96. Decode last 32 bits as IPv4 and apply existing IPv4 check. (Partially fixed in CVE-2024-24790 for netip; this codifies the rule.)

  2. RFC 4291 §2.5.5 — IPv4-compatible IPv6, ::/96. Same treatment.

  3. RFC 3056 — 6to4, 2002::/16. Bits 16–47 encode the IPv4 destination. Decode and apply existing IPv4 check.

  4. RFC 6052 — IPv4-Embedded IPv6 (NAT64 well-known prefix), 64:ff9b::/96. Last 32 bits encode IPv4 destination. Decode and apply existing IPv4 check.

  5. RFC 8215 — NAT64 local-use, 64:ff9b:1::/48. Same treatment.

  6. RFC 4380 — Teredo, 2001::/32. Bits 96–127 encode the IPv4 destination XOR'd with 0xFFFFFFFF. Decode (XOR-invert) and apply existing IPv4 check.

  7. RFC 5214 — ISATAP, <prefix>:5efe:<ipv4> (:5efe: in the interface ID). Decode and apply existing IPv4 check.

All seven items follow a single rule: if the IPv6 form encodes an IPv4 destination via a documented translation prefix, decode it and recurse on the embedded IPv4. This is the same rule Python implemented in 3.13.

(RFC 6598 CGNAT is not in this list — see the secondary ask above for why.)

Why this is consistent with IsPrivate's stated purpose

The current doc says "private address, according to RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses)." The proposed extension does not contradict this — it says that an IPv6 address whose decoded IPv4 destination is in RFC 1918 is itself representing a private destination. That's the natural transitive extension. Calling IsPrivate(64:ff9b::a:0:1) and getting false when the address represents a connection to 10.0.0.1 is the behavior that is currently producing CVEs.

Backward-compatibility analysis

This is a behavior change, not an API change. Callers that previously got false for these addresses will now get true. The risk profile:

  • Code that uses IsPrivate as a security gate (deny-list): Will now correctly classify the previously-bypassable addresses as private and refuse them. This is the intended behavior change.

  • Code that uses IsPrivate to make routing or policy decisions about whether an address is in the strict RFC 1918 sense: Would change behavior. The mitigation is: such code should not be using IsPrivate — it should be checking the specific CIDR ranges it cares about. Anecdotally these uses are rare; @seankhliao's own analysis in #76067 ("based on some loose searches [...] it doesn't appear to be used or defined that often") suggests the call-site population is small.

  • Code that already correctly handles these forms: Will continue to work, since the extended ranges are a superset of what they're checking.

A go vet analyzer to flag callers whose semantics would change could ship alongside the behavior change for further safety.

Alternative if behavior change is rejected

If extending IsPrivate is considered too risky for compatibility reasons, the alternative is to add a new method with the comprehensive semantics — e.g., IsPrivateRestricted() or, mirroring @mikioh's 2019 proposal which was rejected at the time, IsLocalUnicast(). Either is preferable to the status quo.


Fallback: documentation

If neither of the above is accepted, at minimum the documentation for the existing Is* methods should warn against the documented misuse pattern. Suggested text for IsPrivate (and corresponding adjustments for IsLoopback, IsGlobalUnicast, etc.):

// IsPrivate reports whether ip is a private address, according to RFC 1918
// (IPv4 addresses) and RFC 4193 (IPv6 addresses).
//
// Security note: IsPrivate covers only the ranges in those two RFCs. It does
// not return true for other ranges that callers building SSRF deny-lists or
// outbound-restriction policies typically need to block, including:
//
//   - Loopback (RFC 1122 / RFC 4291) — see IsLoopback
//   - Link-local (RFC 3927 / RFC 4291) — see IsLinkLocalUnicast
//   - RFC 6598 Carrier-Grade NAT (100.64.0.0/10)
//   - IPv6 transition mechanisms that embed an IPv4 address
//       (RFC 3056 6to4, RFC 4380 Teredo, RFC 6052 NAT64, RFC 5214 ISATAP,
//        RFC 4291 IPv4-mapped/compatible)
//   - Cloud-provider metadata endpoints (e.g. 169.254.169.254, 168.63.129.16)
//   - Documentation prefixes (RFC 5737 / RFC 3849)
//
// Do not use IsPrivate (or any single classifier in this package) as the
// sole basis for an outbound-network security policy. For that use case,
// use a purpose-built deny-list, such as one driven from the IANA Special
// Purpose Registries.

This costs the Go team essentially nothing and addresses the most acute downstream harm — the case where a developer reaches for IsPrivate, finds documentation that confirms it does what they want, and ships a CVE.


Related issues and references

Go issue tracker:

Third-party libraries that already implement the right semantics, demonstrating the pattern is well-understood:

Cross-ecosystem precedent:

RFCs:
RFC 1918, RFC 3056 (6to4), RFC 3927 (link-local), RFC 4193 (ULA), RFC 4291 (IPv6 addressing), RFC 4380 (Teredo), RFC 5214 (ISATAP), RFC 5737 / RFC 3849 (documentation), RFC 6052 (NAT64), RFC 6598 (CGNAT), RFC 8215 (NAT64 local-use)


Asks, ranked

  1. (Preferred) Extend IsPrivate semantics per the proposal above; add a go vet analyzer for the small number of callers whose semantics would shift.

  2. (Acceptable) If behavior change is rejected for compatibility reasons, add a new comprehensive method (IsPrivateRestricted, IsLocalUnicast, or equivalent), and update the existing IsPrivate documentation to recommend it for security-sensitive uses.

  3. (Bare minimum) Update the documentation of IsPrivate, IsLoopback, IsGlobalUnicast, IsLinkLocalUnicast, IsMulticast, and IsUnspecified (in both net.IP and netip.Addr) with the security warning text above.

The 2024 stdlib bug in the same Is* family, the four direct-codepath CVEs in 2026 alone, and the maintainer team's own on-the-record acknowledgement that the API is misused — together these should make option (3) untouchable. There is no defensible case for leaving the documentation silent. Options (1) and (2) are what "safe defaults" requires, and "use a third-party library" is not an acceptable answer when the third-party library is necessary to avoid an active recurring bug class.

Metadata

Metadata

Assignees

No one assigned

    Labels

    DocumentationIssues describing a change to documentation.Proposal

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions