安全获取 LLM 建议的 URL:一个 Go 实现的 SSRF 防护
模型返回的 URL 是不可信的输入。一个带有连接时 IP 检查、DNS 重绑定防御、逐跳重定向验证和页面验证功能的 Go 获取器。
越来越多的后端现在会要求语言模型寻找一个 URL,然后去获取它:一个搜索增强模型会建议一个官方网站,一个智能体会跟随它从文档中提取的链接,一个处理管道会用模型挑选的页面来丰富记录。当你的服务器向一个来自模型的 URL 发出 HTTP 请求的那一刻,你就构建了一个典型的 SSRF 攻击面,而通常“验证主机名”的建议是不够的。这篇文章将详细介绍一个生产环境的 Go 获取器,它将模型输出视为不可信输入:静态 URL 检查,用于防范 DNS 重绑定的连接时 IP 锁定,一个覆盖了标准库辅助函数所遗漏范围的地址黑名单,逐跳重定向重新验证,以及最后检查你获取的页面确实是你想要的页面。
为什么模型返回的 URL 是不可信的输入
来自模型的 URL 由三个你无法控制的方共同决定。首先,模型本身可能会产生幻觉或选择一个看起来相似的域名。其次,当模型使用搜索工具时,返回的链接通常是搜索提供商拥有的跟踪重定向器,因此你看到的 URL 并非你将访问的最终 URL。第三,也是最糟糕的一点,模型读取的内容可以引导它:页面可能包含能说服模型发出攻击者所选 URL 的文本。提示注入将“模型建议了一个链接”变成了“攻击者建议了一个链接”,只是多了一些步骤。
从服务器的角度来看,这与用户提交的 webhook URL 是同一种威胁。关键的攻击是服务器端请求伪造 (Server-Side Request Forgery):让你的后端向只有它才能访问的目标发出请求。典型的“奖品”是云元数据端点(它们会分发服务账户令牌)和信任私有网络上任何调用者的内部服务。如果你的抓取器运行在云虚拟机或容器平台上,这两者都只需一个 HTTP 请求即可触及。
因此,设计规则很简单:抓取器必须自行强制执行,确保它打开的每个连接都指向一个公共地址,无论 URL 本身怎么说,也无论你第二次询问时 DNS 怎么说。
静态 URL 检查先行,但还不够
低成本检查先行,因为它们会在进行任何网络操作前,就拒绝掉大部分垃圾信息:
func validateFetchURL(u *url.URL) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme not allowed: %q", u.Scheme)
}
if u.User != nil {
return fmt.Errorf("userinfo in URL rejected")
}
switch u.Port() {
case "", "80", "443", "8080":
default:
return fmt.Errorf("port not allowed: %s", u.Port())
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if strings.EqualFold(host, "metadata.google.internal") {
return fmt.Errorf("metadata host rejected")
}
return nil
}
方案锁定禁用了 file、gopher 及其类似方案。拒绝 userinfo 可防止 https://[email protected]/ 这种混淆伎俩。端口允许列表可防止抓取器被用作通用端口扫描器。一个指定的元数据主机值得特殊处理,因为它是攻击者在 GCP 上首先尝试的主机名。
此函数故意不做的事情是解析主机名并检查 IP。这样做在这里似乎很自然,而这恰恰是会引发下一个漏洞的错误:如果你在验证期间进行解析,然后又让 HTTP 客户端在连接期间再次解析,那么这两个结果不一定匹配。
一次解析,检查每个 IP,连接你所检查的地址
DNS 重新绑定是一种“检查时-使用时”(time-of-check to time-of-use)的漏洞。攻击者控制的域名服务器会用一个无害的公共地址来回应你的验证查询,然后用元数据地址或内部 RFC 1918 地址来回应客户端的连接查询。你的检查通过了;但你的请求最终还是发往了内部目标。低 TTL 值使得这种攻击在实践中是可靠的。
修复方法是结构性的,而不是增加另一项检查:在拨号器(dialer)内部进行解析和策略决策,然后连接到你刚刚批准的那个确切的 IP 地址。Go 语言使这一过程变得很简洁,因为 http.Transport 接受一个自定义的 DialContext:
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("resolve failed for %s", host)
}
for _, ip := range ips {
if !publicIP(ip) {
return nil, fmt.Errorf("non-public IP blocked: %s", ip)
}
}
d := &net.Dialer{Timeout: 10 * time.Second}
var lastErr error
for _, ip := range ips {
conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}
有两个细节对安全性至关重要。每个返回的地址都必须通过策略检查,而不仅仅是第一个,因为客户端可能会故障转移到其中任何一个。并且,拨号连接的是 ip.String(),而不是返回主机名,因此攻击者没有第二次解析的机会来投毒。TLS 仍然有效:传输层使用原始主机名执行 SNI 和证书验证的握手,因此将 TCP 连接固定到经过审查的 IP 不会牺牲任何正确性。
私有范围检查会遗漏的地址
publicIP 的显而易见的实现是调用 ip.IsPrivate() 和 ip.IsLoopback() 然后就停止了。这留下了真正的漏洞。下面是一个经受住审查的谓词函数:
func publicIP(ip net.IP) bool {
if ip == nil {
return false
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
return false
}
if v4 := ip.To4(); v4 != nil && v4[0] == 169 && v4[1] == 254 {
return false
}
if v6 := ip.To16(); v6 != nil && ip.To4() == nil &&
v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b {
return false
}
return true
}
链路本地检查很重要,因为 169.254.169.254 这个主要云平台上的元数据地址是链路本地地址,而不是 RFC 1918 私有地址。IsLinkLocalUnicast 涵盖了这种情况,而显式的字节检查是一种冗余保障,确保这一契约不会在重构中丢失。
最后一块是大多数获取程序会忽略的:NAT64 熟知前缀 64:ff9b::/96。在 NAT64 网络上,该前缀内的 IPv6 地址会在其低 32 位中编码一个 IPv4 地址,而转换器会很乐意将你的连接转发过去。无法让私有 IPv4 地址通过你的过滤器的攻击者,可以转而提交其 NAT64 编码的 IPv6 形式。IsPrivate 会认为该地址没问题,因为作为一个 IPv6 地址,它是全局单播地址。你必须自己拒绝该前缀。IPv4 映射的 IPv6 地址(以 ::ffff: 为前缀的形式)在 Go 中不那么算是一个陷阱,因为 net 包会在标准断言运行前将其规范化,但 NAT64 前缀得不到这样的帮助。
重定向、代理和响应上限
一次经过审查的第一跳,并不能证明第二跳的情况。搜索重定向器是模型建议链接的常见情况,因此抓取器必须遵循重定向,而每一次跳转都是一次访问到内部位置的新机会。客户端在每一次跳转时都会重新运行相同的静态验证,并对链条设置上限:
client := &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: safeDialContext,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return validateFetchURL(req.URL)
},
}
Proxy: nil 很容易被忽略。默认的 transport 会遵循 HTTP_PROXY 环境变量,而代理连接会拨号到代理服务器,而不是目标地址,这意味着你精心设计的拨号器只会检查代理服务器的地址,而不会检查其他任何东西。关闭代理可以确保拨号时的防护措施具有权威性。
对响应端也应抱有同样的不信任态度。通过带有硬性字节上限的 io.LimitReader 来读取响应,这样恶意服务器就无法向你发送数 GB 的数据,要求响应为 HTTP 200,并检查 Content-Type 是否与你打算解析的类型相符。记录重定向后经过规范化的最终 URL,并将其视为你所获取内容的规范地址;存储重定向前的 URL 意味着存储的是重定向方。
验证你获取的是所请求的页面
到目前为止,所有措施都是为了保护你的网络。但这并不能保证正确性:模型可能会给你一个完全公开、完全安全但完全错误的页面。如果抓取操作的目的是为了确认某个 URL 是特定实体的官方网站,那么抓取器应该在下游任何部分信任它之前验证这一声明。
两种低成本的检查可以捕获大多数错误答案。首先是语言:如果你期望的是一个韩语页面,则要求在剥离标签、注释以及脚本和样式块后,可见文本中至少包含一个韩文字符。其次是名称邻近性:通过移除空白字符和转为小写来规范化预期的实体名称和页面文本,然后要求该名称出现在文本中。规范化很重要,因为真实页面中名称的空格使用不一致,而包含和不包含内部空格的相同名称应该能够匹配。
func normalizeForMatch(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if !unicode.IsSpace(r) {
b.WriteRune(r)
}
}
return b.String()
}
在验证的同时,还应维护一个纯文本域名黑名单,用于记录在你的领域中永远不可能是正确答案的主机(例如社交个人资料、地图列表和博客平台),并在抓取前和每次重定向跳转时都检查该名单,因为重定向器会不断地跳转到这些主机。这是一个相关性过滤器,而不是一个安全控制措施,但它在同一个地方运行,并且能节省一次抓取。
本设计不试图解决的问题
一些坦诚的局限。该拨号器信任你的解析器:如果攻击者完全控制了你的 DNS 路径,他们能控制的就不仅仅是这个获取器了。IPv6 除了众所周知的前缀外,还有其他翻译前缀;如果你的网络使用自定义的 NAT64 前缀,请将其添加到阻止列表中。页面验证是一种启发式方法,坚决的攻击者可以将预期的名称放在他们控制的页面上;应将其视为减少意外的错误注册,而非身份验证。并且,如果你随后将 HTML 交给一个具有二次方行为的解析器,那么响应上限也无济于事,所以也要保持解析有界。
该模式可以推广到 LLM 输出之外的场景。Webhook URL、用户提交的 RSS 源、链接预览和 OAuth 重定向目标都需要相同的模式:预先进行静态检查,在拨号时对字面地址强制执行策略,重新验证每一跳,限制响应大小和类型,并针对用例进行域名级别的健全性检查。一旦这个防护机制作为一个带有自定义拨号器的小包存在,对于代码库中的任何客户端来说,使用它只需替换一次 Transport 即可,这就是安全审查发现项和默认设置之间的区别。