# API 子域凭据边界真的得到强制执行了吗？

主机名不是所有权提示。它是目标身份的一部分，决定 API 凭据会发送到哪里。如果客户端只持有一个 bearer 令牌，并因为 `api.example.com`、`files.example.com`、`api.eu.example.com` 和 `customer.example.com` 共享同一个后缀，就把它们视为大致等价，那么危险的决定已经做出了。

这种错误常常藏在看似友好的架构背后。公司可能拥有父域下的所有名称。DNS 可能把多个名称发送到同一个负载均衡器。证书也可能覆盖所有这些名称。但这些都不能说明，为某个 API 签发的凭据应该到达另一台主机。凭据需要明确的目标规则，而这条规则还需要配套测试，确保日后有人扩大范围时测试会失败。

## 主域不会让主机自动继承信任

`api.example.com` 和 `admin.example.com` 可能共享同一个可注册域，但它们属于不同的源。源由协议、主机和端口组成。`https://api.example.com` 与 `https://api.example.com:8443` 也属于不同的源。这个区别很重要，因为客户端是根据这个 authority 选择网络目标，而不是根据服务之间的品牌或业务关系选择目标。

RFC 9110 使用源和可选的 realm 定义 HTTP 身份验证保护空间。它还提醒我们，只依赖 realm 可能会让凭据暴露给同一源上的其他资源，并建议在不同参与方需要隔离时使用不同的主机名或端口。这是一个有价值的警告，但它并没有授权我们把同一后缀下的所有名称视为同一个保护空间。

工程师经常把三个不同概念混为一谈：

- 父域命名的是 DNS 命名空间。
- site 会按照某些浏览器安全规则把相关的 Web 源归在一起。
- 源标识的是一个 HTTP 目标所使用的协议、主机和端口。

只有前两个概念会让子域看起来有关联。你的 API 凭据分发器应该使用第三个概念。

即使凭据颁发方在令牌中放入了宽泛的 `aud` 声明，这一点仍然适用。宽泛的受众声明告诉接收服务它可以接受什么，并不会指示客户端把令牌提供给所有可能接受它的端点。发送方仍然有责任尽量减少凭据的分发范围。

我见过这种错误以一种非常固定的方式发生。团队从一个端点开始，加入 `api.staging.example.com`，并保留了一个名为 `getApiKey()` 的辅助函数。六个月后，这个辅助函数已经位于五个服务的底层。它没有主机参数，没有允许列表，也不知道为什么应该拒绝某次调用。密钥并不是因为有人使用了什么奇特的攻击手段才变得不安全。它变得不安全，是因为代码不再携带这样一个事实：这个密钥属于某个特定目标。

让主机绑定同时出现在配置和代码审查中。一个只接受凭据名称的凭据选择器，已经缺少一半输入信息。

## 四种主机名形态会以不同方式失败

兄弟主机、品牌主机、区域主机和客户专属域名应该分别编写测试用例，因为它们失败的原因不同。只写一个模糊的断言，要求令牌留在“我们的域名”内，至少会漏掉其中一种情况。

**兄弟主机**位于目标主机旁边，例如用 `metrics.example.com` 替代 `api.example.com`。它可能暴露共享入口、旧服务，或者一个从未打算接收生产凭据的内部工具。当通用代理不做修改地转发标头，并根据路由配置选择上游时，兄弟主机尤其危险。

**品牌主机**是一个友好的别名，例如 `api.brand.example` 或 `developer.example.com`。提供商可能会在产品改名、收购或迁移期间引入这些名称。这个别名可能在不同的边缘节点终止连接，使用不同的遥测系统，或者重定向到规范主机。不要因为你曾经在浏览器中看到过一次重定向，就授予它凭据访问权限。

**区域主机**会改变地理位置，也经常改变服务所有权，例如 `api.us.example.com`、`api.eu.example.com` 或 `api.ap-southeast.example.com`。应用拒绝令牌之前，请求可能已经携带了业务数据。收到 401 响应并不能证明此前把凭据和请求正文发送到该区域是可以接受的。

**客户专属主机**会让多租户错误更加严重：`acme.vendor.example` 和 `northwind.vendor.example` 可能通过同一套基础设施解析，但每个主机名都代表一个租户边界。足够宽泛、可以在两者上工作的令牌，可能适用于中央控制平面，但绝不应该成为面向租户请求的默认凭据。

把每个类别都写入资产清单。不要把它们压缩进一个名为 `allowed_domains` 的字段，然后认为通配符会让策略更简单。通配符只是把困难的决定移到了视线之外。

## 将凭据选择绑定到完整的 authority

安全的客户端会把请求 authority 映射到一条凭据记录，然后在没有匹配记录时拒绝请求。完整的 authority 包括协议、规范化后的主机名和生效端口。对于使用 443 端口的普通 HTTPS API，外部配置可以保持简单，但实现仍必须拒绝意外端口，不能悄悄复用令牌。

一个可行的资产清单如下：

```yaml
credentials:
  billing-production:
    allowed:
      - https://api.billing.example.com:443
    header: Authorization
    scheme: Bearer

  telemetry-eu:
    allowed:
      - https://ingest.eu.example.net:443
    header: X-Write-Key
```

重点不在 YAML。重点是令牌名称不能单独存在。`billing-production` 只有一个明确目标，`telemetry-eu` 也不会因为调用方只修改了主机名字符串，就泄露到美国端点。

避免使用下面这种模式：

```js
const headers = {
  Authorization: `Bearer ${process.env.PRODUCTION_API_TOKEN}`
};

await fetch(userSuppliedUrl, { headers });
```

这段代码把不受限制的目标和高权限凭据绑定在了一起。开发人员有时会说 URL 来自可信配置文件，以此为它辩护。但这个文件仍然是一个输入边界。部署工具、环境覆盖、拉取请求、功能开关和被入侵的构建任务，都可能修改它。

使用请求构造器，只有在匹配到规范化目标后，它才允许构造经过身份验证的请求。主机名规范化应当严格而简单：

1. 使用真正的 URL 解析器解析 URL，不要使用字符串后缀检查。
2. 除非存在有文档记录的本地开发例外，否则必须要求 `https:`。
3. 通过解析器将主机名转换为小写并规范化。
4. 将解析后的协议、主机和端口与精确批准的 authority 进行比较。
5. 只有匹配成功后，才添加身份验证标头。

不要写 `host.endsWith("example.com")`。它会接受 `notexample.com`。也不要仅仅改成 `endsWith(".example.com")`，就认为问题已经解决。这个表达式仍然会把令牌交给现在和未来的所有子域，包括委托给客户、供应商或遗忘的开发环境的名称。

目标授权和凭据授权之间的区别必须保持清晰。接收 API 可以拒绝超出范围的令牌，但发送方应该在令牌离开客户端之前，就阻止它前往该 API。前一个控制措施限制暴露范围，后一个决定访问权限。两者都需要。

## 重定向会产生新的目标，而不是延续旧请求

重定向会产生第二个凭据边界。第一个主机可能已获批准，但它随后返回一个 `Location` 标头，指向品牌名称、区域端点、对象存储，或攻击者通过开放重定向控制的主机。如果客户端自动跟随重定向，就必须在发送任何凭据或请求正文之前，重新执行目标授权。

libcurl 很清楚地体现了这种区别。默认情况下，重定向到不同主机时，它不会发送内部生成的身份验证信息，也不会发送显式设置的 cookie 标头。`CURLOPT_UNRESTRICTED_AUTH` 选项会改变这一行为，可能把凭据发送到由重定向响应选定的主机。curl 项目还提醒我们，自定义标头需要单独处理，因为库无法推断任意标头中哪些携带了秘密。

最后这一点会让有经验的团队也陷入问题。他们使用标准客户端测试 Basic 身份验证，看到跨主机重定向表现安全。之后，生产集成改用 `X-Api-Key`、`Authorization: Bearer` 或 `X-Signature` 这样的通用标头。只要应用没有主动移除，库就可能保留这些标头。针对一种身份验证机制通过的测试，不能说明另一种机制也安全。

按照请求类别处理重定向：

- 对于写操作，除非 API 合约明确要求，否则应拒绝重定向。
- 对于读操作，检查每个重定向目标，并根据目标的批准凭据记录重新构建标头。
- 对于签名请求，在目标获授权后重新生成签名。绝不能转发为第一个主机创建的签名。
- 对于上传，不要把原始身份验证标头转发到预签名存储 URL。查询参数中的签名或表单字段已经携带了存储端点所需的有限权限。

重定向测试必须检查第二台服务器实际收到的请求。只检查最终状态码是不够的。一个无害测试接收器返回 200，可能掩盖它已经收到了你想保护的生产标头。

重试也需要遵守同样的纪律。有些 HTTP 封装器会从缓存的标头映射中重建请求。如果重试过程通过服务发现转到了新的 authority，就应丢弃缓存映射，再次请求凭据选择器。复用标头更快，但它也会让目标上下文消失。

## 在信任允许列表之前，先建立否定测试装置

真正重要的是否定测试：凭据必须出现在目标主机上，但不能出现在所有可能的错误主机上。你可以使用两台本地 HTTPS 测试服务器来完成测试，但接收器只能记录敏感标头是否存在及其名称。不要把真实值写入测试日志。

对于 shell 级检查，可以使用 curl 的 `--resolve` 选项，把无害名称映射到本地服务器，并使用一次性令牌。在批准主机的 8443 端口启动一个接收器，在兄弟主机的 9443 端口启动另一个接收器。每个接收器都应输出如下形状的记录：

```text
host=api.test.example
path=/v1/ping
authorization=present
x-api-key=absent
```

兄弟主机接收器应输出相反的结果：

```text
host=metrics.test.example
path=/v1/ping
authorization=absent
x-api-key=absent
```

然后运行实际的 HTTP 客户端封装器，而不是重新实现一份与生产代码无关的逻辑。curl 探针可以暴露基本连接方式：

```bash
curl --silent --show-error \
  --resolve api.test.example:8443:127.0.0.1 \
  --header 'Authorization: Bearer test-token-do-not-use' \
  https://api.test.example:8443/v1/ping
```

这条命令会有意把标头放在请求上，因此它只能证明接收器记录了什么，并不能证明应用选择凭据的方式是安全的。应用测试应该使用相同的批准 authority 调用正常的 `request()` 函数，然后对每个错误 authority 重复测试，并断言它在建立连接前就抛出错误。

使用一个迫使人们面对通常被忽略决策的测试矩阵：

| 请求的 authority | 预期的凭据行为 |
| --- | --- |
| `https://api.test.example` | 发送指定的测试凭据 |
| `https://metrics.test.example` | 在发送前拒绝 |
| `https://api.eu.test.example` | 除非单独配置，否则拒绝 |
| `https://tenant-a.test.example` | 除非明确绑定租户，否则拒绝 |
| 批准主机重定向到兄弟主机 | 只有重新授权后才跟随，通常不携带原始凭据 |

不要使用外部请求捕获服务来进行这项测试。你会一边检查应用是否把秘密发送给第三方，一边教会团队把测试秘密发送给第三方。本地接收器很容易搭建，也能让证据留在你的控制范围内。

把这个矩阵加入持续集成。针对 `isAllowedHost()` 的单元测试很有帮助，但集成测试能捕捉常见回归：有人在主机检查完成后，又在更底层的 HTTP 层加入了默认标头。

## 浏览器规则不等于代理规则

浏览器术语经常会让代理和后端代码形成错误假设。“同站”可以包含子域，而“同源”不包含子域。MDN 使用 `https://example.org` 和 `https://login.example.org` 作为两个同站但不同源的例子。这个区别之所以存在，是因为受入侵的子域可能通过同站路径攻击兄弟域。

Fetch 默认使用 `credentials: "same-origin"`，因此浏览器 fetch 不会自动在跨源请求中包含凭据。开发人员可能看到这一默认设置，测试一次前端调用，然后得出 bearer 令牌不会从一个子域移动到另一个子域的结论。但这个结论不适用于服务器端代码。后端 fetch 封装器可以附加作者提供的任何标头。命令行工具也可以这样做。只要工具没有让凭据脱离代理的可接触范围，自主代理就可以使用 URL 和标头调用通用 HTTP 工具。

CORS 无法修复这个问题。CORS 主要控制浏览器 JavaScript 是否可以读取响应。它不会把宽泛的服务器端标头注入器变成安全的凭据分发器。在某些浏览器请求中，浏览器可能发送凭据，然后拒绝向脚本公开响应。这不是可接受的数据丢失防护措施。

Cookie 会带来另一种混淆。Cookie 的 domain 属性可能允许 cookie 到达子域，而仅限主机的 cookie 则不会。Bearer 标头没有类似的内置域范围。如果客户端附加了 `Authorization`，它就是在为这次请求作出明确决定。不要把 cookie 的思维模型套用到 API 密钥上。

## 客户域名需要颁发方边界，而不是命名约定

中央 API 可以合法地调用许多客户端点，但它需要使用能够说明为什么这次调用可以跨越租户边界的凭据。最安全的设置，是为每个客户主机提供单独的凭据记录，并设置狭窄的范围。次优的设置，是使用短生命周期令牌，让目标验证其中的受众和租户声明，同时让客户端允许列表仍然逐一列出可访问的主机。

全局令牌加宽泛角色很受欢迎，因为接入工作会变得简单。添加租户，把集成指向它的子域，调用就能成功。同样的便利也意味着，一个拼写错误、恶意配置修改或判断混乱的代理，就可能使用没有实际目标限制的凭据访问另一个客户的服务。

不要通过创建 `*.customers.example.com` 这样的批准模式来解决问题，也不要告诉自己每个匹配项都是客户。你需要问清楚：谁可以创建这些名称，谁可以委托 DNS，哪些主机会路由到预览环境，以及客户退出后名称是否仍然存在。通配符会把所有这些问题变成安全决策，而且通常不会留下审查记录。

有些情况下，受控通配符是合适的。提供商可能会签发按租户划分的令牌，令牌中包含租户标识，服务会拒绝不匹配的请求，同时内部注册表会在客户端发送请求前验证租户主机。在这种设计中，通配符不是授权规则，而是位于权威租户注册表之后的一种狭窄便利。如果你无法说清这个注册表是什么，也无法测试它失败时的行为，就使用精确条目。

私有 DNS 也会产生同样的问题。`payments.prod.internal` 和 `payments.dev.internal` 可能不是公共名称，但它们是不同的目标，拥有不同的运维控制措施。内部 DNS 不能替代凭据范围控制。

## 主机检查必须在服务发现之前，并在规范化之后进行

服务发现、自定义路由和代理配置可能悄悄绕过原本合理的允许列表。如果代码检查的是原始 URL，而解析器随后替换了内部目标，应用就可能把凭据发送到不同的 authority。反过来，如果检查使用的是可变的 `Host` 标头，也可能批准一个实际网络连接会前往其他位置的请求。

应使用解析后的 URL authority 作为策略输入。为该 authority 建立 TLS 连接，并按正常流程验证证书。不要为了让内部路由或测试装置工作而关闭证书验证。curl 自身的安全指南指出，如果客户端无法验证对端身份，就无法知道自己是否连接到了目标服务器。

然后明确你的信任模型如何看待代理。如果客户端通过正向代理连接到批准的源，并且仍然对批准的源建立 TLS，那么正向代理是传输选择，而不是新的凭据目标。终止 TLS 的反向代理属于服务边界的一部分，需要像 API 本身一样接受审查。接收明文身份验证标头的 HTTP 代理可以访问凭据。不要把这个细节称为“只是基础设施”。

通过符合标准的 URL 实现规范化国际化主机名，并比较规范化结果。拒绝 `https://token@api.example.com/` 这样的 URL 中的 userinfo，因为凭据会泄露到日志、历史记录和调试输出中。拒绝 HTTP 请求中的片段，并明确决定查询字符串是否可以包含预签名凭据。一个通用的清理函数无法挽救这样一种设计：接受所有 URL，然后希望之后再识别有问题的 URL。

重要的顺序很简单：解析、规范化、授权目标、选择凭据、构造标头、建立连接。如果后续任何操作改变了 authority，就从目标授权重新开始。

## 审计决策，而不是审计秘密

审计记录应该证明客户端认定的目标是什么，以及它选择了哪条凭据规则，但不能记录秘密。当有人询问部署变更后令牌是否可能到达兄弟主机时，你需要这类记录。

可以保存请求 ID、时间、规范化后的 authority、凭据记录 ID、授权结果、重定向源和目标，以及结果代码等字段。如果路径包含客户标识符，就对路径进行哈希或脱敏。不要因为 `Authorization`、自定义密钥标头、包含签名的查询字符串或完整请求正文有助于调试，就把它们写入日志。

一条好的审计记录应该能回答一个具体问题：

```text
request_id=01J...
authority=https://api.billing.example.com:443
credential=billing-production
destination_check=allowed
redirect_count=0
result=201
```

对于被拒绝的兄弟主机请求，记录应显示 `credential=none` 和 `destination_check=denied`。这个区别可以证明客户端在选择秘密之前就拒绝了请求。如果日志先记录了凭据名称，随后又报告错误主机返回 403，就说明系统已经发送了不该发送的信息。

Sallyport 将秘密保存在加密保险库中，并在代理获得任何凭据材料之前评估操作。它的 Activity 日志和 Sessions 日志让团队能够检查操作路径；当绑定主机的请求出现问题时，也可以撤销正在运行的代理会话。

先做资产盘点。对于每个凭据，写下一个目标 authority，再列出一个不应接收它的兄弟主机、一个品牌或迁移名称、一个区域名称和一个客户专属名称。如果客户端今天还无法做出这些否定断言，那么它就没有凭据边界，只有一个寄希望于约定的规则。
