# 凭据注入应如何遵循请求验证

凭据应当在出站请求流水线的最后阶段加入，此时客户端已经决定愿意发送什么。如果某个组件在验证实际目标和请求形态之前就加入 API 密钥或 SSH 凭据，那么它其实已经做出了安全决策。之后发生的一切都只是补救。

对于自主编程代理来说，这种顺序更加重要。代理可能生成一个看似合理的请求，跟随 API 响应中的链接，复用代码仓库里的示例，或在不了解后果的情况下接受重定向。这些行为都不要求代理具有恶意。只要请求路径允许不受信任的输入影响经过身份验证的请求发送到哪里，就足够造成问题。

有一条简单而实用的规则：解析拟执行的操作，验证完整操作，冻结操作，建立与之匹配的连接，然后在最后一个合理时机注入凭据。如果请求发生变化，就丢弃原来的授权决策，从头开始。

## 凭据让请求验证成为安全边界

凭据注入器不是 HTTP 客户端外面的一层便利包装。它决定哪个远程方可以代表你行使权限。因此，验证边界不能只包含从请求对象复制出来的主机名字段。

对于 HTTP 操作，至少要验证 scheme、主机名、端口、方法、路径、查询参数规则、相关标头和请求体。对于 SSH，则要验证主机、端口、主机密钥信任决策、远程账户、命令、环境转发和文件传输目标。具体细节不同，但顺序不变。

团队经常把两个问题混在一起：

- 这个请求能到达预期服务吗？
- 这个凭据应该为这个确切请求提供授权吗？

DNS 查询成功和 TLS 证书有效，只能回答第一个问题的一部分。它们无法回答第二个问题。发送到 `https://api.example.com:8443` 的请求，并不自动等同于发送到常规 HTTPS 端口的请求。带 bearer token 的 `POST /v1/refunds`，也不能与 `GET /v1/me` 互换，即使两者最终到达同一台主机。

我最常见到的错误，是制定一条宽泛规则，例如「这个密钥用于 api.example.com」，然后让客户端接受任意 URL、添加标头，再交给库发送。这样的规则把过多权限留给了 URL 解析、重定向处理、代理行为以及由代理控制的标头。它也会让审查结果产生误导。人类批准的请求描述可能是一种内容，而实际在线传输的请求却是另一种内容。

请求验证器必须成为这道鸿沟两端的权威。它应该针对结构化请求做出明确决策，而不是在字符串中搜索一个熟悉的域名，然后希望其余部分都能按预期工作。

## 将目标解析为规范化 origin

目标检查应比较结构化的 URL 组件，而不是字符串前缀。HTTP origin 的相关身份是 scheme、host 和 port。RFC 3986 将 authority 定义为可选的用户信息、主机和可选端口。RFC 9110 则在 HTTP 请求中使用 origin 概念。这些定义很小，后果却很大。

先用真正的 URL 解析器解析 URL。拒绝集成并不需要的值。不要把格式错误的输入修补成更宽松的形式。试图帮忙的验证器经常会创建第二个解析器，最终其行为与 HTTP 客户端逐渐偏离。

对于典型的 API 凭据，保守的目标规则可以是：

```text
accepted scheme: https
accepted host: api.billing.example
accepted port: 443 only
accepted paths: /v1/invoices/* and /v1/customers/*
userinfo: forbidden
fragments: ignored before sending, rejected in proposed actions
IP literals: forbidden unless explicitly configured
```

规范化需要克制。比较前将 DNS 主机名转换为小写。将省略的 HTTPS 端口和 443 视为同一个有效端口。确保解析器已经将用户信息与主机分开。只有在随后验证规范化后的路径时，才规范化点号路径段；在确定客户端会如何解释保留字符前，不要对它们进行解码。

下面这些 URL 说明了前缀检查为什么会失败：

```text
https://api.billing.example.attacker.invalid/v1/invoices
https://api.billing.example@attacker.invalid/v1/invoices
https://api.billing.example:8443/v1/invoices
https://api.billing.example/v1/../admin/users
```

只有开头几个字符看起来熟悉。它们的 authority 或最终路径可能完全不同。第二个 URL 尤其值得测试，因为 `@` 前面的文本是用户信息，不是远程主机。浏览器可能以一种让人匆忙查看时误判的方式显示它。

国际化域名也需要同样谨慎。决定集成是只接受固定的 ASCII 主机名，还是接受一组明确的国际化名称。按照一套有文档记录的规则进行转换和比较。不要在一个地方比较显示形式，在另一个地方比较在线传输形式。

DNS 不是你的授权数据库。接受 origin 后，可以使用 DNS 建立连接，但不要仅仅因为某个目标解析到了预期地址，就接受这个目标。共享托管、负载均衡器、不断变化的服务地址和 DNS 重绑定，都会让基于 IP 的假设变得脆弱。如果需要私有网络保护，应把它作为额外的连接规则，而不是 origin 允许列表的替代方案。

## 同时验证连接目标和 HTTP authority

URL、TLS 服务器名称和 HTTP authority 必须描述同一个获准目标。如果它们不一致，凭据注入器就应该停止。

HTTP 中有多个地方可以出现 authority。HTTP/1.1 使用 `Host` 标头。HTTP/2 和 HTTP/3 使用 `:authority` 伪标头。HTTP 代理可能会收到包含另一个 authority 的绝对形式请求目标。RFC 9112 要求客户端在 HTTP/1.1 中发送 Host 标头，并将缺失、重复或无效的 Host 字段视为格式错误。这个规则存在的原因是：按 authority 路由并不是可有可无的装饰。

对于能够处理凭据的客户端，最安全的做法是让 authority 的构造脱离代理控制。受信任的传输层根据已经批准的 URL 构建 `Host` 或 `:authority`。它不接受代理另行提供的路由 authority。除非某个狭窄的集成确实需要，而且实现经过了有意设计和测试，它也不应接受代理提供的 `Connection`、`Proxy-Authorization`、`Transfer-Encoding`、`Content-Length` 或 `Expect` 标头。

这样可以避免一种危险的分裂请求。假设验证器批准了 `https://api.billing.example/v1/invoices`，随后却合并代理提供的任意标头。如果底层 HTTP 栈接受了外部提供的 `Host` 值，代理、网关或配置错误的服务器可能按这个标头路由请求。验证器批准的是一个目标，请求却到达了另一个目标。

代理配置也适用同一规则。企业代理可能完全合法，但它是传输路径，不是凭据的新 authority。让代理设置脱离代理的请求数据。独立验证最终目标，并在审计记录中明确记录代理行为。

HTTPS 必须验证 TLS 证书，但证书验证并不等于允许使用任何凭据。客户端应验证批准 URL 中的主机名，在适用时使用该主机名发送服务器名称指示，并拒绝证书不匹配。不要向代理提供「跳过验证」开关。临时诊断捷径很容易变成永久逃生通道。

## 重定向是新请求，而不是延续请求

经过身份验证的重定向是一个带有新目标的第二个请求。把它当成透明延续，是凭据离开原定边界的常见原因。

对于 API 客户端，最安全的默认设置是在请求携带凭据时关闭自动跟随重定向。将重定向响应返回给受信任的请求层，解析 `Location` 值，按照 URL 规则解析其目标，然后让得到的新请求通过完整验证器。只有这样才能决定是否发出另一个请求，也只有此时才能为这个新请求注入凭据。

重定向到不同 origin 时，不应使用原请求的凭据。不同 scheme、主机名或有效端口都属于 origin 变化。从 HTTPS 重定向到 HTTP 的经过身份验证的 API 调用必须直接失败。从 `api.example.com` 重定向到 `login.example.com` 同样是 origin 变化，即使两个名称属于同一家公司。公司所有权不是传输规则。

HTTP 状态码会改变风险。RFC 9110 规定了重定向行为，其中包括保留请求方法和请求体的状态码。RFC 9700《OAuth 2.0 Security Best Current Practice》警告，授权服务器不应使用 HTTP 307 重定向可能包含用户凭据的请求。原因很直接：客户端可能会在新位置重复发送原来的方法和请求体。

因此，可以采用以下实用的重定向策略：

1. 对经过身份验证的机器到机器调用，默认拒绝重定向。
2. 只有在集成确实需要时，才允许一小组有文档记录的重定向。
3. 每经过一跳，都重新验证解析后的目标、方法、标头和请求体。
4. 考虑下一跳前，移除所有凭据。
5. 设置较低的重定向上限，并记录每次决策。

不要通过信任所有子域名来解决问题。`uploads.example.com` 和 `api.example.com` 可能由不同团队运行，使用不同基础设施，或暴露不同的攻击路径。设置时觉得方便的通配符，往往会比它最初存在的理由活得更久。

签名请求还有一个相关陷阱。如果 API 签名了方法、路径、选定标头或请求体摘要，重定向通常也无法保留原签名。只有在下一个请求通过自己的验证后，重新签名才合适。签名只能证明掌握秘密的人签署了这些数据，不能证明这些数据仍然描述着获准的目标。

## 先明确标头归谁，再决定如何过滤

只要确定每个标头的所有者，标头过滤就会容易许多。请求层应负责凭据和路由。代理只能负责特定集成允许的应用标头。

凭据标头包括 `Authorization`、供应商专用的 API 密钥标头、cookie，有时还包括签名标头。验证后再注入它们。即使代理声称自己只提供了占位符，也绝不要从代理那里接收这些标头。占位符容易引入意外替换逻辑，也会教给接口错误的观念：代理提出操作，受信任组件提供权限。

路由和消息分帧标头包括 `Host`、`Content-Length`、`Transfer-Encoding`、`Connection`、`Upgrade` 以及 HTTP/2 伪标头。让传输库构建这些标头。用户输入不应覆盖它们。

应用标头可以允许，但只能通过模式定义。假设某个 API 接受客户标识符、幂等键和内容类型。允许这些名称，验证它们的值，并拒绝其他所有内容。不要因为大多数调用只使用无害标头，就直接传递任意标头映射。真正危险的往往是那个罕见标头，它能把普通请求变成代理指令、缓存变体、替代身份或调试路径。

日志也需要特别处理 `Authorization`。记录注入器使用了凭据引用 `billing-prod-readwrite`，而不是记录其值或编码形式。在通用日志器已经捕获标头后再进行脱敏，并不可靠。应在任何组件序列化请求之前，根据结构化字段构建安全事件。

自定义标头凭据并不会因为名称类似 `X-Api-Key` 就比 bearer token 不敏感。如果接收服务把这个值视为权限，得到它的人就可能重放它。不同标头名称会改变互操作性和日志习惯，但不会改变验证凭据发送目标的必要性。

## 方法、路径和请求体共同定义操作

当一个凭据既能读取数据、修改数据，又能触发资金转移时，主机允许列表就太宽泛了。请求形态必须参与权限决策。

先从方法开始。只允许集成真正需要的方法，拒绝其他方法。不要把 `POST` 说成天然危险，把 `GET` 说成安全。许多 API 会在 GET 端点后面提供改变状态的操作，而 GET 也可能通过查询参数或日志泄露私密信息。方法规则仍然重要，因为它能让策略审查变得具体。

接着根据路由模板验证路径，而不是使用模糊的前缀。例如 `/v1/projects/{project_id}/deployments` 这样的路由模板，可以限制路径段数量、标识符允许使用的字符，并判断代理能否选择分配范围之外的项目。如果端点使用查询参数选择账户，也要验证该参数。主机名正确，并不意味着 `/v1/accounts/other-team/export` 就可以接受。

请求体必须属于被冻结的请求。验证器批准一个 JSON 对象后，如果另一层还能重新序列化或修改它，那么最终离开机器的字节可能并未获得授权。重复 JSON 键、表单编码、multipart 边界、浮点数转换，以及为请求添加字段的中间件，都会暴露这个问题。

一种实用设计是让验证器生成不可变的执行计划：

```json
{
  "method": "POST",
  "url": "https://api.billing.example/v1/invoices/inv_123/cancel",
  "headers": {
    "content-type": "application/json",
    "idempotency-key": "job-7f3c"
  },
  "body_sha256": "4d94c2...",
  "credential_ref": "billing-cancel"
}
```

传输层接收这个计划和已经准备好的请求体字节。在打开经过身份验证的请求前，它会确认请求体摘要。它根据 URL 派生路由标头，从 `credential_ref` 添加秘密，然后准确发送这些字节。如果摘要不同，操作就应失败，而不是猜测是哪一层修改了请求。

这种方法也能改善人工批准。批准卡可以显示通俗易懂的操作说明，以及规范化 origin、方法、路由、选定的账户标识符和金额或资源名称。不要让人批准一段危险字段可能埋在末尾的原始 JSON。

## 小型验证器比通用策略语言更安全

常见做法是构建一个宽泛的规则引擎：任意条件、正则表达式、变量、例外和紧急绕过。它看起来很灵活，但当有人必须判断某个凭据能否通过代理标头和由代理组装的 JSON 请求体到达重定向目标时，这种灵活性就成了负担。

大多数凭据注入只需要更小的模型。每个凭据都应有明确的通道和紧凑的请求契约。对于 HTTP，契约应列出允许的 origin、方法、路由、标头、重定向行为和请求体约束。对于 SSH，则应列出主机、用户、主机密钥预期、允许的命令形式和传输约束。

紧凑的配置可以是这样：

```yaml
credential: billing-cancel
channel: https
origins:
  - https://api.billing.example:443
methods: [POST]
routes:
  - /v1/invoices/{invoice_id}/cancel
headers:
  content-type: application/json
  idempotency-key: generated
redirects: deny
body:
  required_fields: [reason]
  allowed_fields: [reason]
```

这段配置不是完整的安全系统，但它展示了重要约束：凭据绑定到一种狭窄的操作形态。如果下一个集成需要 `GET /v1/invoices/{invoice_id}`，就为它提供独立路由，并尽可能使用独立的只读凭据。不要悄悄把取消操作凭据变成通用账户密钥。

这里应当对正则表达式保持怀疑。它们可以用于某个语法定义清晰的字段，但不能替代 URL、JSON、Shell 命令或 HTTP 标头解析。一个看似限制路径的表达式，可能在解码、规范化或下游路由器以不同方式解释同一字节时失效。

Sallyport 对代理操作采取了有意保持较小的路径：它将秘密保存在加密保险库中，在不向代理暴露秘密的情况下执行 HTTP 或 SSH 操作。只有当操作网关在请求保险库使用凭据前验证操作时，这种分离才真正有用。

## 验证必须跨越交给传输层的边界

如果后续层可以修改目标，再完美的验证器也没有意义。边界需要一种能够保留已批准内容的交接方式。

不要验证一个可变请求对象，然后把同一个对象交给能够重写 URL、合并标头、附加 cookie、跟随重定向或根据代理控制的环境变量选择代理的中间件。应验证并生成新的不可变计划。尽量让传输层接收表达能力最低的输入。

执行顺序应该固定而单调：

1. 将代理提出的操作解析为类型化字段。
2. 验证规范化目标和允许的请求形态。
3. 只序列化一次获准的载荷，并记录其摘要。
4. 使用获准的 scheme、主机和端口建立连接。
5. 在发送前的最后一刻，由受信任的传输层注入凭据。

不要为了让重试代码更容易而提前注入。重试是另一次发送，需要经过同样的目标和请求检查。当没有实质变化时，可以复用获准的不可变计划。如果重试改变了主机、路由、代理模式、方法、请求体或身份验证方案，它就是一次新的操作。

只有在 HTTP 库能够保持 authority 边界时，连接复用才是安全的。连接池不能让一个请求的授权元数据泄漏到下一个请求。这听起来很明显，但共享的可变标头映射和作用域不当的拦截器，常常是这类漏洞的来源。

对于 SSH，对应的失败情况是：验证了主机名，却允许命令包装器在批准后传入不同的 `ProxyCommand`、代理套接字、目标跳板主机或远程命令。信任决策必须绑定完整的路由和执行请求，而不能只绑定代理最先看到的主机名。

## 测试普通集成测试会跳过的失败情况

凭据注入器应该有测试证明它会拒绝可疑输入。成功路径测试只能确认 API 调用能够工作。拒绝测试才能确认，在库升级或代理新增功能后，设计仍然符合你的预期。

建立一个包含拟执行操作和预期决策的测试表，至少加入以下情况：

- 完全匹配的获准 HTTPS origin 和路由，应当成功。
- 类似 `api.billing.example.attacker.invalid` 的主机名后缀，应当失败。
- `@` 前带有用户信息的 URL，应当失败。
- 有效主机但端口不符合预期，应当失败。
- 重定向到不同 origin，应当失败，且不能发送凭据。
- 意外的 `Host`、`Authorization` 或代理标头，应当失败。
- 批准后发生变化的请求体，应当无法通过摘要检查。

使用一个能记录每个收到的请求标头的本地测试服务器。这比检查模拟请求对象更有说服力。测试应断言：未获准的重定向目标服务器没有看到 API 密钥、bearer token、cookie 或签名标头。既要测试保留请求体的重定向响应码，也要测试通常会变成 GET 的响应码，因为不同库的默认行为可能不同。

还要测试解析器之间的不一致。将相同的异常 URL 同时交给验证器和生产 HTTP 客户端，包括百分号编码、空端口、重复斜杠、点号路径段，以及在支持时的 IPv6 字面量和国际化名称。如果它们对 authority 或路径的理解不同，在行为一致前拒绝这类输入。

审计记录应捕获网络调用前验证器的决策，以及之后传输层的结果。一条有用的记录应说明请求了哪个凭据引用、批准了哪个规范化 origin 和路由、是否出现重定向，以及请求为何被拒绝。它绝不能包含秘密材料。如果无法还原某个出站凭据为何被使用，就没有足够证据进行事件调查。

## 只有请求具体化后，批准才有意义

人工批准提示可以阻止代理在错误时机使用凭据，但提示必须描述一个系统已经验证过的请求。先请求批准、之后再解析，会让人充当能力很弱的 URL 解析器。

显示 origin、操作、方法、路由和重要业务字段。对于支付调用，显示收款方、币种和金额。对于源代码管理，显示仓库、分支和操作。对于基础设施 API，显示账户、区域、资源和破坏性影响。不要把原始秘密和不受限制的请求体放进提示中。

对可能造成重大损害的凭据，逐次批准很合适。对重复、低风险的调用，如果会话具有可识别的进程身份且生命周期很短，可以使用会话批准。两者都不能替代请求验证。用户可以批准一个受信任的编程进程，但这不意味着该进程组装的每个 URL 都值得使用同一个凭据。

Sallyport 的逐会话授权和可选的逐次密钥批准适合放在这个边界之后：应用可以请求用户授权一个已知的代理进程或某次特定凭据使用，而受信任的操作路径保留秘密并记录操作。批准必须覆盖具体且经过验证的执行计划，而不是代理对其意图的非正式描述。

通常，第一步不需要启动一个庞大的策略项目。对经过身份验证的调用关闭自动重定向。拒绝代理控制的路由和凭据标头。将目标解析为规范化 origin。然后，在任何秘密进入请求之前，绑定方法、路由、标头和请求体。这个顺序可以阻止一整类泄露，而再谨慎的秘密存储也无法修复已经发生的泄露。
