# 经过身份验证的代理请求的 HTTP 重定向验证

HTTP 重定向验证必须在代理向下一个 URL 转发凭据之前完成。重定向不是同一请求的无害延续，而是远程服务器发出的指令，要求客户端再次发起请求。这个请求往往会前往不同的权限边界，使用不同的方法，并连接到不同的网络目标。

当人们把代理接入 API 客户端，却保持客户端默认开启的重定向设置时，这个区别很容易被忽略。代理向已批准的 API 发送请求。API 返回 `302 Location: https://somewhere-else/...`。客户端跟随重定向。如果身份验证过早加入，控制第一个端点、重定向参数或受入侵依赖项的攻击者，就能把一次获准的调用变成凭据投递服务。

我见过有人对此不以为然，因为成熟的 HTTP 库通常会在主机变化时移除 `Authorization`。这项保护确实有帮助，但既不普遍，也远远不够。它无法处理自定义凭据标头、签名查询参数、Cookie、重定向后的请求正文、DNS 变化，或同一主机上指向危险端点的重定向。应明确构建重定向决策，不要把库的默认行为当成策略。

## 重定向会产生新的授权决策

每一跳重定向都需要像第一个 URL 一样仔细检查，因为服务器决定下一个目标。客户端请求了一个资源，却收到了一份响应，其中通过 `Location` 字段提出另一个 URI。最初的允许决策不会自动覆盖这个被提出的 URI。

RFC 9110 通过状态码定义重定向，并说明用户代理可以或应该如何响应。但它没有说，附加在初始请求上的 API 凭据就自动获得了访问服务器可能返回的每个 URI 的授权。这个缺口必须由调用方负责填补。对于代理网关来说，调用方负责决定它将执行哪些下游操作。

在代码和日志中都要区分以下两个事件：

1. 服务器为已经发出的请求发送重定向响应。
2. 客户端决定向解析后的目标发起新请求。

第一个事件是事实，第二个事件是特权操作。如果实现把两者合并在 `client.Do(request)` 这类开启自动重定向的便捷调用中，就隐藏了必须运行策略的时刻。

一次重定向可能同时跨越多个边界。`https://api.example.test/v1/export` 可能指向 `https://downloads.example.test/file`，后者又指向存储服务上的限时对象 URL。这条链可能完全合法，但也说明「第一个主机已获批准」这种宽松规则，几乎无法说明最终连接会去哪里。

相对重定向同样需要谨慎处理。应使用符合标准的 URL 解析器，将 `Location: ../admin` 相对于产生它的 URL 进行解析。不要拼接字符串。以 `//` 开头的路径是相对协议的 URL，可能改变主机。编码后的分隔符，以及空 Location 值等不常见形式，已经造成了足够多的客户端差异，因此网关应拒绝含糊情况，而不是自行猜测。

## 只有目标通过检查后，才注入凭据

安全的顺序很简单：接收响应，解析候选目标，验证目标，构造下一个请求，然后只注入获准用于该目标的凭据。凭据注入应位于最终发送点，不应放在一个会跨越整个重定向链的可变请求对象中。

大多数泄露都源于这个设计错误。某个封装层构造带有 `Authorization: Bearer ...` 的请求，发送请求，然后让 HTTP 客户端在重定向后克隆或重放请求。封装层可能只想让令牌用于一个主机，但它已经无法控制每次发送。

使用不包含任何秘密材料的请求描述。它可以携带方法、正文、允许使用的凭据引用和初始 URL。每一跳都应在验证后创建新的网络请求。发送层只有在目标获准后，才查找对应凭据。

一个简化模型如下：

```text
request intent:
  method: POST
  initial URL: https://api.acme.test/v1/reports
  credential reference: billing-api-prod
  body digest: sha256:...

for each response:
  if status is not a supported redirect: return response
  target = resolve(response.request_url, response.headers["Location"])
  decision = validate(target, request intent, response.status)
  if decision is reject: record rejection and stop
  next_request = build(decision.method, target, permitted body)
  inject(credential reference, target, next_request)
  send(next_request)
```

重点不在伪代码，而在秘密的生命周期。令牌只存在于已经通过目标验证的那个请求中。它不会放在通用对象里，也就不会被重定向回调意外转发。

这样的顺序也让凭据范围真正可执行。用于 `api.acme.test` 的 bearer 令牌，不应因为 `uploads.acme.test` 也属于同一家公司，就自动用于后者。为每条目标规则指定明确的凭据引用。如果两个服务确实需要共享凭据，就在规则中记录这种关系，不要根据 DNS 后缀自行推断。

## 只匹配来源能阻止一种泄露，却会漏掉其他问题

严格匹配来源可以阻止明显的主机变化，但它无法决定重定向请求是否安全。来源由协议、主机和端口组成。除非存在明确例外，否则其中任何一项发生变化，都应视为跨越边界。

协议很重要。从 HTTPS 重定向到 HTTP，可能让标头或请求内容在网络中暴露。对于带身份验证的流量，应拒绝降级。从 HTTP 重定向到 HTTPS 看起来更安全，但它仍改变了权限边界，也可能掩盖意外端点，因此同样需要正常验证。

端口也很重要。`https://api.example.test` 和 `https://api.example.test:8443` 是不同的来源。开发者常常忽略这一点，因为浏览器会隐藏默认端口，而本地测试环境经常使用备用端口。如果允许规则只检查主机名，原本用于公共 API 的令牌可能最终到达管理服务。

当一个 API 主机承载多个无关应用时，路径也很重要。从 `/v1/files` 重定向到 `/internal/debug/export` 虽然仍处于同一来源，但可能暴露请求正文或触发不安全的状态变化。路径允许列表不能替代应用授权，但应把重定向目标限制在集成真正需要的 API 前缀内。

查询参数需要特别处理。重定向目标可能携带预签名 URL、状态参数或一次性下载令牌。即使这些值没有使用 `Authorization` 标头，实际上也属于凭据。不要把旧请求的查询参数复制到新 URL 中。应只使用解析后的重定向目标，并应用禁止 userinfo 字段、片段或看起来像凭据的参数的规则。

一个实用的基线是：只有在协议仍为 HTTPS、端口仍获批准、目标路径位于集成允许的 API 范围内，并且重定向状态码允许所需方法时，才允许同源重定向。跨来源重定向应作为独立类别处理，并配置带名称的目标规则。

## 重定向状态码会改变即将发送的请求

重定向状态码携带方法语义。忽略这些语义的客户端，可能把安全的读取变成意外写入，也可能重放敏感正文。不要把所有 3xx 响应简化成「跟随 Location」。

RFC 9110 规定，307 和 308 会保留请求方法和内容。如果原始请求是包含报告定义的 `POST`，307 或 308 就要求客户端将同一个 `POST` 和正文发送到新目标。这是风险最高的重定向情形，因为新主机可能同时收到业务数据和一个会产生副作用的请求。

303 要求客户端使用 `GET` 或 `HEAD` 获取表示内容，无论原始方法是什么。它通常出现在表单式提交之后。对于代理操作，只有在重定向后的 GET 目标单独获批，并且集成确实预期这种模式时，才允许它。初始 POST 带有授权标头，并不意味着 303 后的请求可以继承该标头。

历史上最容易出问题的是 301 和 302。RFC 9110 描述了这些响应长期以来将 POST 改为 GET 的实践，而 307 和 308 则用于必须保留方法的调用方。库在细节上并不一致，尤其是处理非 POST 方法和正文时。不要让这种含糊情况决定自主客户端发送什么。

把行为明确写下来。例如：

- 只有原始请求为 `GET` 或 `HEAD` 时，才跟随 301 和 302。
- 303 只能通过发送不带凭据的 `GET` 或 `HEAD` 来跟随，然后再次验证目标，之后才能添加获准的读取凭据。
- 只有在目标规则明确允许原始方法和正文类别时，才跟随 307 和 308。
- 对于 `POST`、`PATCH` 或 `DELETE` 等非幂等方法，除非集成有记录在案的重定向流程，否则拒绝重定向。

这可能会让设计不佳的 API 无法工作，但总好过把支付指令、源代码上传或管理命令静默重放到远程服务器选择的地方。如果供应商确实需要重定向写入，就增加范围狭窄的例外，并测试完整的精确流程。

## 目标规则需要字段，而不是一个主机名字符串

有用的重定向验证器应将目标与一条足够详细的规则进行比较，以描述你真正想调用的服务。裸主机名允许列表很有吸引力，因为它简短，但例外会不断累积，最终没人能解释代理可以把什么发送到哪里。

对于每个允许的重定向目标，都要决定以下事项：

- 允许哪些协议，通常只允许 HTTPS。
- 允许哪些规范化的主机名和端口。
- 重定向请求可以使用哪些方法和路径前缀。
- 可以在该目标注入哪条凭据引用，或者是否完全不能注入。
- 目标是否可以解析到私有或本地网络地址。

匹配前先规范化。将 DNS 主机名转换为小写，统一处理末尾句点，正确解析带方括号的 IPv6 字面量，并拒绝格式错误的百分号编码。永远不要比较原始 URL 字符串。`https://api.example.test@evil.test/` 的主机是 `evil.test`，即使 `@` 符号前包含受信任的名称。

不要用 `endsWith("example.test")` 检查权限边界，因为它会接受 `notexample.test`。即使是 `*.example.test` 这种考虑了分隔符的后缀规则，也需要审查所有权。通配符子域名可能包含预览系统、客户控制的名称、重定向器，或由其他团队管理的基础设施。

验证器应返回带原因的决策，而不是布尔值。操作人员需要知道拒绝原因是协议降级、端口未获批准、方法不允许、地址为私有地址，还是重定向次数已耗尽。这些信息应写入操作记录，同时移除令牌值。

设置一个小而固定的重定向上限。上限可以阻止循环，也能让长链条暴露出来。不要接受代理提供的次数。网关负责网络操作，因此也应由网关掌握这个上限。

## 常见的下载流程也可能泄露特权请求

危险情况很少会明确标注为攻击。它们通常看起来像普通的便捷端点，例如接受一个 URL 或返回下载位置的端点。

假设代理被要求从已批准的计费 API 获取报告。代理携带 bearer 令牌发送 `POST /v1/exports`。API 返回指向下载 URL 的 303。网关自动跟随，并携带 bearer 令牌，因为自定义标头注入器在 HTTP 库的重定向钩子之前运行。

起初，下载主机是同一团队运行的另一项服务。几个月后，一次配置变更让导出端点接受 `destination` 参数，以便客户使用存储服务。拥有报告参数访问权限的攻击者提交了一个自己控制的 URL。已批准的 API 将请求重定向到该 URL。HTTP 库将其视为普通 303，而网关早已附加了 `X-Service-Token`。

这里没有发生什么复杂的事情。第一个 API 请求获得了允许，重定向语法有效，令牌也从未出现在代理提示词中。失败原因是目标尚未确定时就分配了凭据。

正确的实现会记录 303，解析目标，发现该主机没有目标规则，然后停止。操作人员看到的是一次被拒绝的重定向，而不是一条神秘的出站调用。如果预期的下载服务确实需要访问，就为它的确切主机创建单独规则，并使用不能调用计费 API 的下载凭据。

代理还会增加另一条通往此类失败的路径。代理可能通过问题文本、代码仓库配置文件、API 响应字段或工具指令影响 URL。不要因为 URL 出现在代理创建的请求中，就假设它来自开发者。数据很容易迅速变成路由输入。

## DNS 检查必须在连接时进行

仅验证主机名无法阻止服务器端请求伪造。某个主机名在检查期间可能解析到公共地址，但客户端连接时却解析到回环、链路本地或私有地址。重定向处理会给攻击者反复利用这个缺口的机会。

在接近连接时解析每个已批准的主机名，并根据网络规则检查返回的每个地址。除非规则明确允许，否则应拒绝回环范围、未指定地址、链路本地范围、私有范围、多播地址及其 IPv6 等价范围。重定向 URL 中的字面 IP 地址也要同样处理。

不要解析主机名一次，批准其中一个地址，然后让另一个 HTTP 栈再次解析。这样会产生检查时与使用时之间的间隙。连接必须使用已经评估过的地址集合，或者传输层必须提供可信方式确认对端地址并拒绝不匹配的情况。当连接池、代理和双栈 DNS 同时出现时，这比听起来更难。

企业代理部署需要明确的例外模型。如果网关只连接到指定代理，应验证代理作为网络对端，同时在形成代理请求前保留应用层目标规则。不要因为组织使用私有服务地址，就宣布所有私有范围都安全。应明确列出代理真正需要访问的内部主机和端口。

DNS 重绑定是重定向规则不能永久批准某个主机名的原因之一。缓存行为、生存时间和解析器差异都可能改变解析结果。应针对即将建立的连接做出决策，并记录选定的对端地址，但不要把它当成应用目标已获授权的证据。

## Cookie、签名 URL 和正文也属于凭据

团队经常保护 `Authorization`，却放任其他出站材料不受限制。重定向还可能通过多种其他渠道泄露凭据或敏感数据。

Cookie 有域和路径规则，但代理网关不应依赖通用的浏览器式 Cookie 容器来管理机器凭据。特权调用默认应禁用 Cookie。如果集成确实需要 Cookie，就将容器限定在该集成范围内，并针对每个重定向目标重新评估是否附加 Cookie。

预签名 URL 会故意把授权放在查询字符串中。它们通常不需要添加其他服务令牌。如果目标规则识别出预签名存储 URL，就应仅使用 URL 中已有的授权材料发送请求，并限制方法，通常只允许 `GET` 或 `PUT`。不要出于习惯附加标准标头。

请求正文同样可能包含秘密。JSON 正文可能含有源代码压缩包、客户数据或不透明的签名断言。对于 307 和 308，必须有明确规则允许将正文重放到该精确服务。对于其他重定向状态码，不要静默转换或重新发送正文。客户端库的便利性不是复制敏感内容的理由。

客户端添加 HTTP Referer 时，可能泄露路径和查询值。API 客户端通常不应在重定向过程中携带 Referer 标头。同样，除非目标规则明确需要，否则应移除描述原始内部路由、代理会话或用户身份的标头。

将标头分为三组：始终可以重新生成的标头；只允许发往指定目标的标头；绝不能跨越重定向的标头。这样比维护一份模糊的「敏感标头」列表更可靠。对于获准的上传，`Content-Type` 可能没有问题；但 `X-Internal-Actor` 可能会暴露一个从未授权下一个主机的用户身份。

## 将整条链作为一次多跳操作审计

审计记录应显示重定向链，同时不暴露使请求获得特权的材料。只记录最终 URL 和状态码远远不够，因为这会隐藏客户端是否跨越了来源、改变了方法、移除了身份验证，或是在连接前拒绝了可疑目标。

记录初始请求意图、每个响应状态、脱敏后的每个原始 Location 值、解析后的目标、策略结果、选定的方法，以及该跳使用的凭据类别。记录稳定的凭据标识符，绝不要记录令牌、密码或完整签名 URL。对完整 URL 做哈希有助于关联，但当输入熵较低时，不要把哈希误当成安全脱敏。

将所有跳关联到一个父操作标识符。这样调查人员就能区分「代理请求导出」和「网关在完成导出时发起了四次网络调用」。当链条开始表现异常时，操作人员也可以据此撤销实时会话。

防篡改日志能在事后提供有价值的证据，但无法阻止不安全的重定向。预防措施必须位于转发路径中。Sallyport 会在一份加密且由哈希链保护的审计日志中记录代理会话和单次调用，因此支持重定向的操作记录可以保留完整的决策轨迹，而不是将整条链压缩成最终结果。

用刻意拒绝的案例测试日志。触发跨来源 302、HTTPS 到 HTTP 的重定向、POST 后的 307、格式错误的 Location 字段，以及解析到回环地址的目标。如果这些记录无法告诉审查人员网关为何停止，就应在事故迫使你处理之前改进事件结构。

## 将重定向行为纳入工具契约

调用 HTTP API 的工具必须告诉用户它是否跟随重定向，以及在什么条件下跟随。「使用标准 HTTP」不是契约。不同库的行为各不相同，当有人替换客户端、添加代理或将身份验证移入中间件时，行为也会改变。

针对本地重定向测试装置编写测试，并为不同端点设置区分明显的地址。测试装置应返回受控的状态码和 Location，同时捕获传入的方法、正文摘要、主机和标头。断言应证明未批准的目标不会收到凭据标头，303 不会重放 POST 正文，而获准的 307 只会发送规则允许的标头和正文。

不要让代理在工具参数中选择重定向策略。代理可以请求一个已知集成，并提供普通请求参数。网关决定该集成是否允许跟随重定向、最多允许多少跳，以及可以使用哪些凭据。这样的分离可以防止提示词注入变成 `follow_redirects=true`。

第一版实现应选择一个刻意收窄的契约：只跟随已批准的 HTTPS 重定向，并且仅限 GET 和 HEAD；每个目标在验证后单独注入凭据；拒绝所有重定向写入。只有在能够解释供应商流程、目标边界、方法行为和审计记录后，才添加例外。少数明确的拒绝会让开发者感到不便，但把令牌发送给攻击者会让所有人付出更大代价。
