# 为会注入凭据的智能体工具设计 SSRF 测试

会注入凭据的智能体工具，会把普通的出站 HTTP 请求变成一道边界判断。如果工具接受智能体提供的目标，验证了一个看起来正常的主机名，然后让 HTTP 客户端跟随 DNS 和重定向前往任何地方，那么它就把凭据交给了攻击者选择的服务。

解决办法不是在 URL 验证器中贴上一份更长的拒绝列表。你需要一份目标矩阵，覆盖整个请求生命周期：解析、DNS 解析、地址选择、建立连接、处理重定向，最后才是注入凭据。测试必须观察实际收到请求的对端，因为秘密最终就是发送到了那里。

## 必须在批准目标后注入凭据

工具应先判断自己是否可以联系某个目标，再附加任何认证材料。这意味着 bearer token、基本认证凭据、自定义标头、请求签名、客户端证书和 SSH 身份，都应放在目标检查之后。

团队经常把检查放错位置。他们验证原始 URL 字符串，构造带有 Authorization 标头的请求，然后调用一个开启自动跟随重定向的便捷 HTTP helper。接着，helper 解析名称、选择 IPv6 或 IPv4、跟随 Location 标头，还可能以验证代码完全没有观察到的方式复用标头或连接状态。

这种设计有一个优点：很容易编写。问题是，它把危险的决定交给了一个不知道请求是否携带凭据的组件。

把两个决定分开：

- 这个 URL 在语法上是否符合工具要求？
- 这个具体的网络目标是否获准建立经过认证的连接？

第一个决定负责解析 scheme、host、port、path、userinfo 和百分号编码。第二个决定负责对解析出的地址进行分类，并控制实际连接。主机名白名单可以为两个决定提供信息，但不能代替第二个决定。

RFC 3986 清楚说明了解析问题。authority 组件的形式是 `[userinfo@]host[:port]`。一个字符串在 `@` 前面看起来包含受信任的主机名，实际却可能指向 `@` 后面的 IP 地址。RFC 3986 在讨论具有误导性的 URI authority 字符串时，甚至使用了这种构造。不要自己拆分 URL，也不要在原始字符串中搜索受信任名称。请使用一个了解标准的解析器，若工具没有正当理由使用 userinfo，就拒绝目标中的 userinfo，然后读取解析后的 host 字段。

这个安全不变量足够简单，也很容易转化为测试：

> 在批准该请求所连接的对端和请求 authority 之前，工具不得发送任何经过认证的字节。

只有当请求行或 Host 标头本身会泄露秘密时，它们才算“经过认证的字节”。在大多数 HTTP 场景中，关键界限是携带凭据的标头或签名载荷。如果路径、查询字符串或请求体中包含 capability URL、租户标识符或其他敏感值，就应采用更严格的标准。

## 编写测试用例前先确定决策点

在明确 SSRF 控制到底在哪个位置做出允许或拒绝决定之前，无法真正测试它。“请求之前”这个说法太模糊。一个请求有多个时刻，目标可能在这些时刻发生变化。

把下面的顺序作为注入凭据的 HTTP 工具模型：

1. 解析提供的绝对 URL，拒绝不支持的 scheme、格式错误的 authority、userinfo，以及超出工具约定的端口。
2. 通过进程实际使用的解析器解析主机名。收集 A 和 AAAA 应答，而不是只收集开发机器碰巧优先使用的应答。
3. 对每个候选地址进行分类。如果集合中包含不在获批目标类别内的地址，就拒绝请求，除非连接器能够将连接固定到获批地址。
4. 连接到获批地址，然后在写入凭据前验证远端对端地址。
5. 只有验证通过后才构造经过认证的请求。明确设置预期的 Host 标头和 TLS server name。
6. 将重定向视为一个从解析开始的新请求，而不是一个可以继承信任的延续请求。

这比检查 `hostname != "localhost"` 要复杂得多，但这是必要的。RFC 8305 描述了客户端如何几乎同时发起 AAAA 和 A 查询，并考虑 IPv6 优先级来尝试地址。只解析 A 记录的测试套件，可能会在出现一个可访问的 AAAA 记录时立即暴露问题。

决策点也决定了测试装置需要记录什么。对每次尝试，记录：

- 工具收到的 URL
- 返回给工具的 DNS 应答
- 连接时选择的地址
- 接收服务器观察到的地址
- 每个请求标头，报告中要对测试凭据进行脱敏

不要只断言状态码或抛出了异常。捕获服务器返回 403，证明它收到了请求。超时可能什么也证明不了。你真正需要的断言是：“被阻止的监听器没有观察到任何携带凭据的请求。”

## 围绕目标而不是主机名构建矩阵

目标矩阵是这项工作的长期产物。每一行都记录一种输入形式、解析器应答、可选的重定向行为以及预期结果。它能避免常见的测试漂移：一个开发者测试 `127.0.0.1`，另一个测试 `10.0.0.1`，却没人测试运行时接受的奇怪形式。

先从一份拥有明确判定依据的小型矩阵开始。下面的例子假定 `public.test` 指向一个被分类为允许的测试装置监听器，而其他所有地址类别都必须在注入凭据前拒绝。

| 用例 | 提交的 URL | DNS 或重定向行为 | 预期结果 |
| --- | --- | --- | --- |
| 公共 IPv4 | `https://public.test/echo` | A 记录指向测试装置的公共 fixture | 允许并发送测试凭据 |
| IPv4 回环 | `http://127.0.0.1:18080/` | 字面地址 | 在连接前拒绝 |
| IPv4 回环变体 | `http://127.1:18080/` | 取决于解析器的简写形式 | 拒绝，或分类为回环地址，绝不能允许 |
| RFC 1918 私有地址 | `http://10.20.30.40/` | 字面地址 | 在连接前拒绝 |
| RFC 1918 私有地址 | `http://172.20.30.40/` | 字面地址 | 在连接前拒绝 |
| RFC 1918 私有地址 | `http://192.168.20.40/` | 字面地址 | 在连接前拒绝 |
| IPv6 回环 | `http://[::1]:18080/` | 字面地址 | 在连接前拒绝 |
| IPv6 链路本地 | `http://[fe80::1]/` | 字面地址 | 在连接前拒绝 |
| IPv4 映射的 IPv6 | `http://[::ffff:127.0.0.1]/` | 字面地址 | 在连接前拒绝 |
| 混合 DNS 应答 | `https://mixed.test/` | 允许的 A，阻止的 AAAA | 拒绝，或将连接固定到允许的地址 |
| Rebinding 名称 | `https://rebind.test/` | 第一次允许，下一次阻止 | 拒绝向被阻止的对端发送经过认证的请求 |
| 重定向到回环地址 | `https://public.test/to-local` | 302 到 `http://127.0.0.1:18080/` | 拒绝第二个请求 |
| 重定向到私有 DNS | `https://public.test/to-private` | 302 到 `https://internal.test/` | 重新解析并拒绝第二个请求 |
| Userinfo 欺骗 | `http://public.test@127.0.0.1/` | `@` 后面的字面主机 | 在连接前拒绝 |

RFC 1918 只定义了三个 IPv4 私有地址块：10/8、172.16/12 和 192.168/16。测试边界，尤其是 `172.15.255.255`、`172.16.0.0`、`172.31.255.255` 和 `172.32.0.0`。粗糙的前缀检查经常会阻止整个 172/8，导致合法公共端点无法使用，或者只阻止 172.16/16，从而漏掉大部分已分配的私有范围。

不要把这张表误认为适用于所有生产环境的通用策略。有些内部工具确实应当能够联系特定的私有服务。如果这是产品要求，就为该服务配置明确规则，限制主机名、端口和身份检查。不要把“公司办公室里的私有地址没问题”设为所有智能体选择目标的默认规则。

## URL 解析测试会在 DNS 之前发现错误

解析器必须识别连接器实际使用的主机。在某个测试发现一个组件和另一个组件以不同方式规范化输入之前，这一点听起来显而易见。

把下面这些用例放入只测试解析器的套件中。它们不应执行 DNS 查询，也不应打开网络套接字：

```text
http://public.test@127.0.0.1/admin
http://127.0.0.1.nip.example/
http://[::1]/
http://[::ffff:7f00:1]/
http://0177.0.0.1/
http://2130706433/
http://127.0.0.1%2f.example/
http://public.test:80@127.0.0.1/
http://public.test./
```

预期结果不总是“这个精确字符串必须被解析为回环地址”。URI 和 URL 库对旧式数字 IPv4 格式、区域标识符和无效百分号编码的处理各不相同。测试应描述安全属性，而不是强行规定具体解析结果：如果运行时接受该输入，并且会将它连接到被阻止的地址，工具就必须拒绝。如果运行时直接拒绝输入，也完全可以。

这一点很重要，因为许多团队会先编写自定义主机过滤器，再把未修改的 URL 交给另一个库。过滤器可能把 `2130706433` 看成一个不熟悉的注册名称，而连接器却把它解释为 127.0.0.1。此时你测试了两个解析器，却信任了更安全的那个答案。

验证和连接应使用同一个解析后的表示。如果无法做到，就让最终连接器报告它选择的数字对端，然后在凭据离开进程前做最后一次地址类别检查。

还应测试主机名规范化。在普通 DNS 使用中，`public.test.` 末尾的点与 `public.test` 指向同一个 DNS 名称，但简单的白名单经常比较原始字符串。国际化主机名又增加了一个可能产生分歧的地方。只进行一次转换，将名称变成运行时的规范表示，然后按标签边界匹配 DNS 名称。像 `endsWith("trusted.example")` 这样的后缀检查，会接受 `untrusted.example` 和 `trusted.example.attacker.test`，但它们都不是受信任的子域名。

## DNS rebinding 测试必须强制发生第二次查询

DNS rebinding 是一种时序问题。初始查询返回一个可接受的地址，之后对同一名称的查询却返回回环地址、私有地址或其他被阻止的目标。如果验证和连接没有使用同一个解析结果，攻击者就能通过第一次检查，再操控第二次结果。

一个有用的测试装置需要一个由你控制的小型权威 DNS 服务器和两个 HTTP 监听器。一个监听器是允许的 fixture，另一个是被阻止的 fixture，它会记录自己是否收到了测试 Authorization 标头。让 DNS 服务器为 `rebind.test` 按下面的顺序返回应答：

```text
query 1: rebind.test. A     198.51.100.20
query 2: rebind.test. A     127.0.0.1
query 1: rebind.test. AAAA  2001:db8::20
query 2: rebind.test. AAAA  ::1
```

使用只在测试装置中有效的地址，并根据需要在测试网络中映射允许的监听器。这个例子里的公共地址文档只是 fixture 的标签，不是测试应当在互联网中访问的端点。

现在运行两种变体。第一种，让工具自行执行验证查询，然后调用一个会再次解析名称的 HTTP 库。如果实现存在你要查找的间隙，被阻止的监听器应该会收到凭据。第二种，将连接路径配置为使用已经批准的解析地址，只把原始主机名保留给 Host 和 TLS 名称验证，并断言被阻止的监听器什么也没有观察到。

缓存可能会掩盖问题。解析器缓存、连接池或操作系统缓存，可能让第二次应答根本没有发生。测试装置应提供缓存控制，在每次运行之间关闭空闲连接，必要时每次运行使用新的主机名，并记录每一次 DNS 查询。如果 rebinding 测试通过时没有确认发生过两次不同的查询，那它就没有测试 rebinding。

不要通过信任 DNS TTL 来解决问题。TTL 是缓存提示，不代表名称在验证和连接之间仍然安全。代码必须把批准的地址传递给连接过程，或者在套接字打开后验证对端。

## 重定向是独立的目标决策

重定向会改变目标。它可能改变 scheme、host、port、path，或者同时改变这四者。把重定向当作受信任的延续请求，会让一个表面安全的公共 URL 变成向 `127.0.0.1`、实例元数据服务、路由器或内部控制平面发出的请求。

在携带凭据的请求所使用的传输层中，关闭自动跟随重定向。读取重定向响应，设置适度的重定向次数上限，根据库的 URL 解析规则，基于当前 URL 解析 Location 值，然后再次执行完整的目标决策。

重定向 fixture 至少应包含以下行为：

- 公共 URL 返回一个指向 IPv4 回环字面地址的 302。
- 公共 URL 返回一个指向解析为私有 IPv4 地址的主机名的 307。
- 公共 URL 返回相对 Location，例如 `/next`。经过正常解析后，它应继续使用已经批准的 authority。
- 公共 URL 返回 scheme-relative Location，例如 `//other.test/path`。这会改变 authority，需要进行完整验证。
- 公共 URL 返回一条重定向链，第一跳允许，后续某一跳被阻止。

状态码很重要。301、302 或 303 可能促使客户端把非 GET 方法改成 GET。307 或 308 设计上会保留方法和请求体。当涉及签名 POST 或 bearer 认证的写入操作时，不要依赖客户端库的默认处理。对每个重定向 fixture，记录方法、请求体哈希、Host 标头和 Authorization 标头。

当 authority 改变时删除 Authorization 是很好的防护措施，但这不代表可以跳过目标验证。没有 Authorization 标头的请求仍可能在路径中携带签名 URL、客户端证书、cookie 或敏感请求体。同一主机的重定向也可能在 DNS 发生变化后，从普通路径指向本地服务。

这里的测试应该故意保持简单。让被阻止的重定向监听器返回 200。如果断言只是期待客户端报错，那么一个会跟随重定向的客户端可能成功发起 SSRF 请求，而你的测试却会因为错误的理由把它判为通过。应断言监听器捕获到的请求数量，以及捕获到的凭据状态。

## IPv6 是 SSRF 的一等攻击面

遗漏 IPv6 通常是无意造成的。开发者测试了 `127.0.0.1`，阻止了 RFC 1918 范围，然后发布了把 `[::1]` 当成奇怪主机名的代码。现代客户端可以同时查询 AAAA 和 A 记录，即使 IPv4 测试结果正常，IPv6 路由仍可能获胜。

RFC 4291 将 `::1/128` 定义为 IPv6 回环地址，将 `::/128` 定义为未指定地址。它还定义了 `fe80::/10` 范围内的链路本地单播地址。这些地址类别不应意外成为智能体选择的、经过认证的目标。

明确测试下面这些类别：

| 地址类别 | 示例 | 默认测试预期 |
| --- | --- | --- |
| 未指定 | `[::]` | 拒绝 |
| 回环 | `[::1]` | 拒绝 |
| 链路本地 | `[fe80::1]` | 拒绝 |
| 唯一本地 | `[fc00::1]`、`[fd12:3456::1]` | 除非明确批准，否则拒绝 |
| 组播 | `[ff02::1]` | 拒绝 |
| IPv4 映射的回环地址 | `[::ffff:127.0.0.1]` | 拒绝 |
| IPv4 映射的私有地址 | `[::ffff:192.168.1.10]` | 拒绝 |

地址分类必须基于二进制地址，而不是字符串写法。同一个地址可以使用压缩或展开的 IPv6 形式表示。针对 `::1` 的字符串比较会漏掉 `0:0:0:0:0:0:0:1`。先解析成真正的 IP 类型，这一步虽然不起眼，却能防止这一类错误。

也应明确处理作用域标识符。像 `[fe80::1%25en0]` 这样的输入，在百分号编码之后包含一个接口区域。大多数工具都应拒绝来自智能体输入的带区域字面目标，而不是尝试判断哪个本地接口安全。如果产品确实有合法的本地网络使用场景，就把接口选择作为由用户控制的明确配置，绝不要让它成为智能体提供的 URL 细节。

混合的 A 和 AAAA 应答需要明确决定。如果解析器返回一个允许的 IPv4 地址和一个被阻止的 IPv6 地址，最安全的默认行为是拒绝。如果为了可用性必须使用允许的应答，连接器就必须将套接字固定到该地址，并且之后不能再把主机名交回解析器。

“我们偏好 IPv4”不是控制措施。网络库、操作系统和连接竞争机制都可能做出其他选择。

## 在封闭测试装置中运行矩阵

不要把 SSRF 测试指向笔记本上真实运行的 localhost 服务，也不要指向云元数据端点。你需要的是证据，而不是一个令人焦虑的下午。将解析器和所有监听器放在隔离的测试网络中，使用一次性凭据，并让监听器记录自己的地址和收到的标头。

一个简单的测试装置包含四个参与者：

1. 测试运行器，使用 URL 和一次性凭据引用调用工具。
2. 可控的 DNS 服务器，能够按预先编排的顺序返回 A 和 AAAA 应答。
3. 允许的 HTTP fixture，记录成功的认证请求。
4. 被阻止的 HTTP fixture，记录任何连接或请求，并将其作为测试失败。

让被阻止的 fixture 返回一个能让本地日志明显显示泄露的响应体，但绝不要打印完整 token。例如，它可以返回对端地址、请求方法、Host 标头，以及一个表示是否存在 `Authorization` 的布尔值。运行器将这些证据与目标矩阵中的预期行进行比较。

结果格式应足够简单，便于在 CI 中检查：

```text
case=redirect_to_loopback
submitted=https://public.test/to-local
resolved=198.51.100.20
redirect=http://127.0.0.1:18080/
decision=deny
allowed_requests=0
blocked_connections=0
blocked_credentials=0
```

失败时应保留相同字段，并增加实际对端。这样，模糊的回归就会变成可以立即处理的报告：

```text
case=rebind_ipv6
submitted=https://rebind.test/export
validation_answer=2001:db8::20
connected_peer=::1
blocked_credentials=1
result=FAIL
```

这份报告会立即指出问题：代码批准了一个 DNS 应答，却连接到了另一个地址。它还会告诉你凭据是否跨越了边界，而这正是你需要关心的风险。

如果工具支持代理配置，就把代理相关行加入矩阵。HTTP 代理会改变 TCP 连接的去向，而 CONNECT 仍可能让代理访问攻击者选择的目标。明确代理本身是否属于受信任的传输层，以及工具是否验证最终 authority。只检查代理地址的代理测试，可能会把一个能够访问内部目标的开放中继错误地认证为安全。

## 在实现中分开 authority 和目标

TLS 很容易让这个问题变得混乱。为了安全地连接到某个主机名，工具可能需要将套接字打开到一个已批准的数字地址，同时使用原始的获批主机名作为 TLS server name 和 HTTP Host 标头。这些是三个不同的字段，各自承担不同职责。

数字对端回答的是：“这个套接字要连接到哪里？”TLS server name 回答的是：“服务器必须证明哪个证书身份？”Host 标头回答的是：“这个 HTTP 请求针对哪个 authority？”实现应明确保留这三个值，不要让 HTTP 便捷 API 从一个可变的 URL 字符串中自行推断。

这种分离也会暴露一个仍然流行的错误建议：解析一次，然后把 URL 中的主机名替换成数字地址。这样可能避免第二次 DNS 查询，却会破坏 TLS 证书验证、虚拟主机和签名请求方案。开发者经常因此关闭证书验证，或削弱主机检查。这比原始漏洞更糟糕。

正确做法是使用支持以下行为的传输层：连接到批准的地址，同时保留对原始主机名的严格 TLS 验证。握手完成后，确认套接字对端就是获批地址。如果运行时无法提供这些控制能力，就不要声称它能防止经过认证的智能体请求遭遇 DNS rebinding。应把这一限制写进产品边界，并避免向智能体选择的目标注入凭据。

SSH 虽然没有 HTTP 重定向，但结构相同。解析并分类请求的主机，将连接固定到批准的地址，并根据预期的主机身份验证主机密钥。主机密钥提示或宽松的 known-hosts 规则都不是目标策略。

Sallyport 将凭据保存在加密保险库中，并自行执行 HTTP 和 SSH 操作，而不是把秘密材料交给智能体。只有当操作层在使用存储的凭据之前应用目标控制，这个边界才真正成立。

## 让矩阵成为发布门禁，而不是安全文档

目标矩阵只有在针对即将发布的同一条请求路径运行时才有价值。`isPrivateIp()` 的单元测试无法测试 HTTP 客户端的解析器、重定向代码、代理行为或连接池。保留单元测试，但每当你更新传输库、URL 解析器、解析器配置或凭据注入代码时，都必须运行测试装置套件。

修复漏洞时添加新行。不要把一次痛苦的事故压缩成“改进 SSRF 验证”这样的宽泛表述。保留精确的输入、DNS 顺序、重定向响应以及凭据不应出现的预期结果。未来的维护者需要以可执行的形式看到这些经验。

按类别检查失败结果。解析差异说明存在重复的 URL 处理。被阻止的监听器看到了 TCP 连接，却没有看到请求，可能意味着你验证得太晚，即使没有 token 泄露。被阻止的监听器看到 Authorization 标头，则意味着安全不变量已经失效，发布流程应立即停止。

这项工作的价值在于，它改变了团队提出问题的方式。不要再问主机名看起来是否来自外部。要问哪个对端收到了经过认证的请求，它是如何被选中的，以及测试能否证明答案。如果你无法从测试输出中回答这些问题，工具仍然存在 SSRF 盲区。
