
文章开头,我们先考虑这样一个问题:在 Go 里怎么判断一个 IP 是否是私有地址?
很多人第一反应会这样写:
ip.IsPrivate()
如果在普通的业务逻辑中,这么写看起来没什么问题。Golang 标准库中关于 net.IP.IsPrivate 和 net/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/8、172.16.0.0/12、192.168.0.0/16 - IPv6 的 RFC 4193 ULA 地址,也就是
fc00::/7
这在地址分类语义上没有问题。但 SSRF 防护面对的不是教科书里的“私有地址”定义,而是真实网络里各种特殊地址、转换地址和平台保留地址。
例如下面这些地址类型,在安全策略里往往也需要谨慎处理:
- loopback,例如
127.0.0.1、::1 - link-local,例如
169.254.0.0/16、fe80::/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.1、192.168.1.1 或 127.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 标准库里的 IsPrivate、IsLoopback、IsLinkLocalUnicast、IsMulticast 等方法拼出一个 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 风险太大,可以新增一个语义更明确的方法,例如 IsPrivateRestricted、IsLocalUnicast 或类似 API,用于表达“安全策略里通常应该拒绝的本地/受限网络地址”。
第三,如果连新 API 也不接受,至少要改文档。把 IsPrivate、IsLoopback、IsGlobalUnicast、IsLinkLocalUnicast 等方法的安全边界讲清楚。
在我看来,第三点几乎没有争议。行为可以慢慢讨论,命名可以继续斟酌,但文档不能继续保持沉默。
对 Go 开发者的实践建议
在今天的 Go 项目里,如果你要做 SSRF 防护,不应该只依赖:
ip.IsPrivate()
也不应该简单堆几个方法:
ip.IsPrivate() ||
ip.IsLoopback() ||
ip.IsLinkLocalUnicast()
这比什么都不做要好,但仍然不是完整的 SSRF 防护。
更稳妥的做法是:
- 使用专门为 SSRF 防护设计的库或策略,而不是自己拼标准库分类函数。
- 基于 IANA Special-Purpose Address Registries 维护 deny-list。
- 同时处理 IPv4、IPv6、IPv4-mapped IPv6、NAT64、6to4、Teredo、ISATAP 等形式。
- 对 DNS 解析结果做固定和复查,防止 DNS rebinding。
- 跟踪 HTTP 重定向,每一次跳转后都重新校验目标地址。
- 显式阻断云厂商 metadata endpoint。
- 对出站请求做网络层隔离,而不是只依赖应用层判断。
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 防护的完整答案。