go isprivate ssrf cover

文章开头,我们先考虑这样一个问题:在 Go 里怎么判断一个 IP 是否是私有地址?

很多人第一反应会这样写:

ip.IsPrivate()

如果在普通的业务逻辑中,这么写看起来没什么问题。Golang 标准库中关于 net.IP.IsPrivatenet/netip.Addr.IsPrivate 的文档也很直接:它判断一个地址是否属于 RFC 1918 定义的 IPv4 私有地址,或者 RFC 4193 定义的 IPv6 ULA 地址。

但是如果在 SSRF 防护代码里这么写,事情就变得危险了。

最近(2026 年 6 月 10 日),在 Go issue tracker 里安全研究员 Jonathan Leitschuh 就提交了一个关于 IsPrivate 的提案:proposal: net: IP.IsPrivate is widely misused as a security primitive,编号 79925。它讨论的不是一个简单的 API 命名问题,而是一个工程安全问题:当标准库提供了一个看起来“刚好能用”的分类函数,开发者很容易误把它当成安全边界。

SSRF 防护里常见的错误写法

SSRF,全称 Server-Side Request Forgery,中文通常叫服务端请求伪造

假设一个服务提供了“根据 URL 抓取图片”的功能:

https://example.com/fetch?url=https://example.org/avatar.png

服务端会根据用户传入的 URL 发起请求,然后把资源下载回来。问题是,攻击者可以把 URL 换成内网地址:

http://127.0.0.1:8080/admin
http://169.254.169.254/latest/meta-data/
http://10.0.0.1/internal-api

攻击者自己也许访问不到这些地址,但你的服务器可以。于是服务器就成了攻击者进入内网的跳板。

为了防 SSRF,很多代码会先解析目标域名,拿到 IP,然后判断它是不是内网地址。一个非常常见的 Go 写法类似这样:

if ip.IsGlobalUnicast() && !ip.IsPrivate() {
    // allow outbound request
}

这段代码的问题在于:它把 IsPrivate 当成了“是否安全可访问”的判断。

IsPrivate 从来不是一个 SSRF 防护 API,它只是一个很窄的地址分类函数

“不是 private”不等于“可以安全访问”

IsPrivate 当前主要覆盖两类地址:

  • IPv4 的 RFC 1918 私有地址,例如 10.0.0.0/8172.16.0.0/12192.168.0.0/16
  • IPv6 的 RFC 4193 ULA 地址,也就是 fc00::/7

这在地址分类语义上没有问题。但 SSRF 防护面对的不是教科书里的“私有地址”定义,而是真实网络里各种特殊地址、转换地址和平台保留地址。

例如下面这些地址类型,在安全策略里往往也需要谨慎处理:

  • loopback,例如 127.0.0.1::1
  • link-local,例如 169.254.0.0/16fe80::/10
  • 云厂商 metadata 地址,例如 169.254.169.254
  • Carrier-Grade NAT,例如 100.64.0.0/10
  • IPv4-mapped IPv6,例如 ::ffff:127.0.0.1
  • NAT64、6to4、Teredo、ISATAP 等 IPv6 过渡机制地址

其中最容易被忽略的,是最后一类:IPv6 地址里可能嵌入 IPv4 地址

也就是说,一个地址表面上是 IPv6,不落在 RFC 1918 私网范围里,但它实际表示的目标可能是 10.0.0.1192.168.1.1127.0.0.1 这类敏感地址。

如果防护代码只问一句:

!ip.IsPrivate()

它就可能把这些地址放过去。

IPv6 过渡机制为什么会影响 SSRF

IPv6 过渡机制的存在,是为了让 IPv4 和 IPv6 网络在迁移过程中互通。很多机制会把 IPv4 地址编码进 IPv6 地址里。

提案中重点提到几类:

  • IPv4-mapped IPv6:::ffff:0:0/96
  • IPv4-compatible IPv6:::/96
  • 6to4:2002::/16
  • NAT64 well-known prefix:64:ff9b::/96
  • NAT64 local-use:64:ff9b:1::/48
  • Teredo:2001::/32
  • ISATAP:形如 <prefix>:5efe:<ipv4>

这些机制的细节各不相同,但安全语义可以用一句话概括:

如果一个 IPv6 地址通过标准机制编码了一个 IPv4 目标地址,那么安全判断时不能只看外层 IPv6 地址,还应该解码出里面的 IPv4 地址,再判断它是否敏感。

比如一个 NAT64 地址可能代表到 10.0.0.1 的连接。此时从 SSRF 防护的角度看,它就不应该被当成普通公网 IPv6 地址放行。

这也是提案的核心主张:IsPrivate 至少应该对这些标准化的 IPv4-in-IPv6 表达做递归判断。如果嵌入的 IPv4 是 RFC 1918 私有地址,那么外层 IPv6 地址也应该返回 true

这不是潜在隐患:Go 生态已经踩过坑

这个提案之所以值得关注,是因为它不是在讨论一个抽象的问题。

作者列举了多个 Go 生态里的 SSRF 相关漏洞,模式非常相似:项目使用 Go 标准库里的 IsPrivateIsLoopbackIsLinkLocalUnicastIsMulticast 等方法拼出一个 deny-list,以为已经拦住了内网地址,结果漏掉了 IPv6 过渡地址、CGNAT、NAT64、Teredo、6to4 或云厂商特殊地址。

更有意思的是,有些项目并不是完全没做安全检查。它们恰恰是“认真做了”,甚至手写了一些额外规则,但仍然漏掉了另一种地址形式。

这说明问题不只是“开发者粗心”。真正的问题是:SSRF 地址判断的边界比 IsPrivate 这个名字暗示的复杂得多

如果一个标准库 API 看起来像安全原语,文档却没有明确警告它不能用于安全边界,误用就会不断发生。

标准库的 API 设计会塑造开发者直觉

安全 API 最危险的地方,往往不是它完全不可用,而是它“看起来刚好够用”。

IsPrivate 这个名字非常有吸引力。开发者要防止访问内网地址,看到这个方法,很自然会觉得:

我只要拒绝 private IP,不就可以了吗?

但这里有两个概念被混在了一起:

  • “是否属于 RFC 定义的 private address”
  • “是否应该被 SSRF 防护策略拦截”

前者是地址分类问题,后者是安全策略问题。

安全策略通常要覆盖更多范围,包括 loopback、link-local、metadata endpoint、特殊用途地址、IPv6 transition forms,以及重定向、DNS rebinding、多次解析不一致等问题。IsPrivate 只解决其中很小一块。

所以提案里一个很重要的最低要求是:即使 Go 团队不改变 IsPrivate 的行为,也应该在文档里明确写出安全警告。

类似:

// Do not use IsPrivate as the sole basis for an outbound-network security policy.

这类文档不是装饰。它能在开发者复制粘贴出漏洞之前,打断错误直觉。

Go 应该改变 IsPrivate 的行为吗?

提案给了三个层次的请求。

第一,最理想的方案是扩展 IsPrivate 的语义,让它识别标准 IPv6 过渡机制中嵌入的 RFC 1918 IPv4 地址。

这不是 API 变更,而是行为变更。过去某些地址返回 false,以后会返回 true。对 SSRF deny-list 来说,这是更安全的默认行为。

但行为变更总有兼容性风险。可能有些代码确实只想判断“严格意义上的 RFC 1918 / RFC 4193 地址”。这些代码在升级后可能受到影响。

第二,如果 Go 团队认为直接改 IsPrivate 风险太大,可以新增一个语义更明确的方法,例如 IsPrivateRestrictedIsLocalUnicast 或类似 API,用于表达“安全策略里通常应该拒绝的本地/受限网络地址”。

第三,如果连新 API 也不接受,至少要改文档。把 IsPrivateIsLoopbackIsGlobalUnicastIsLinkLocalUnicast 等方法的安全边界讲清楚。

在我看来,第三点几乎没有争议。行为可以慢慢讨论,命名可以继续斟酌,但文档不能继续保持沉默。

对 Go 开发者的实践建议

在今天的 Go 项目里,如果你要做 SSRF 防护,不应该只依赖:

ip.IsPrivate()

也不应该简单堆几个方法:

ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsLinkLocalUnicast()

这比什么都不做要好,但仍然不是完整的 SSRF 防护。

更稳妥的做法是:

  1. 使用专门为 SSRF 防护设计的库或策略,而不是自己拼标准库分类函数。
  2. 基于 IANA Special-Purpose Address Registries 维护 deny-list。
  3. 同时处理 IPv4、IPv6、IPv4-mapped IPv6、NAT64、6to4、Teredo、ISATAP 等形式。
  4. 对 DNS 解析结果做固定和复查,防止 DNS rebinding。
  5. 跟踪 HTTP 重定向,每一次跳转后都重新校验目标地址。
  6. 显式阻断云厂商 metadata endpoint。
  7. 对出站请求做网络层隔离,而不是只依赖应用层判断。

SSRF 防护的本质不是“判断是不是 private IP”,而是定义清楚:这个服务允许访问哪些网络资源,以及所有可能绕过这个定义的路径。

Python 的做法提供了一个参照

提案里还提到 Python 3.13 的 ipaddress.is_private。Python 已经把一些 IPv6 过渡地址纳入更完整的判断逻辑,比如 IPv4-mapped IPv6、6to4、NAT64 等。

这并不意味着 Go 必须完全照搬 Python。不同语言对兼容性和 API 语义有不同取舍。

但 Python 的例子至少说明一件事:标准库不是不能承载更完整的地址语义。问题在于设计者是否愿意承认,现实中的开发者已经把这些方法用于安全场景。

当一个 API 长期被误用,标准库维护者通常有三种选择:

  • 改变 API 行为,让默认值更安全
  • 增加新 API,把正确用法显式化
  • 至少改文档,告诉用户这里有坑

什么都不做,是最差的选择。

结语:安全边界不能靠“看起来像”的 API

IsPrivate 的争议提醒我们:标准库 API 的名字、文档和行为,会直接影响整个生态的安全直觉。

从纯粹定义上说,Go 当前的 IsPrivate 并没有撒谎。它确实是在判断 RFC 1918 和 RFC 4193 意义上的 private address。

但从工程现实看,这个 API 被大量开发者用在 SSRF 防护里,而 SSRF 防护需要的远不止这个判断。IPv6 过渡机制、特殊用途地址、云平台 metadata、DNS rebinding 和重定向,都会让“是不是 private IP”这个问题变得不够用。

所以这份 Go 提案真正有价值的地方,不只是要求 IsPrivate 多识别几个网段。它指出了一个更普遍的问题:

当一个标准库函数被广泛误认为安全原语时,文档和 API 设计就必须承担起纠偏责任。

对 Go 团队来说,至少应该把警告写进文档。对 Go 开发者来说,现在就应该停止把 IsPrivate 当作 SSRF 防护的完整答案。