API 子域凭据边界真的得到强制执行了吗?
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,外部配置可以保持简单,但实现仍必须拒绝意外端口,不能悄悄复用令牌。
一个可行的资产清单如下:
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 也不会因为调用方只修改了主机名字符串,就泄露到美国端点。
避免使用下面这种模式:
const headers = {
Authorization: `Bearer ${process.env.PRODUCTION_API_TOKEN}`
};
await fetch(userSuppliedUrl, { headers });
这段代码把不受限制的目标和高权限凭据绑定在了一起。开发人员有时会说 URL 来自可信配置文件,以此为它辩护。但这个文件仍然是一个输入边界。部署工具、环境覆盖、拉取请求、功能开关和被入侵的构建任务,都可能修改它。
使用请求构造器,只有在匹配到规范化目标后,它才允许构造经过身份验证的请求。主机名规范化应当严格而简单:
- 使用真正的 URL 解析器解析 URL,不要使用字符串后缀检查。
- 除非存在有文档记录的本地开发例外,否则必须要求
https:。 - 通过解析器将主机名转换为小写并规范化。
- 将解析后的协议、主机和端口与精确批准的 authority 进行比较。
- 只有匹配成功后,才添加身份验证标头。
不要写 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 端口启动另一个接收器。每个接收器都应输出如下形状的记录:
host=api.test.example
path=/v1/ping
authorization=present
x-api-key=absent
兄弟主机接收器应输出相反的结果:
host=metrics.test.example
path=/v1/ping
authorization=absent
x-api-key=absent
然后运行实际的 HTTP 客户端封装器,而不是重新实现一份与生产代码无关的逻辑。curl 探针可以暴露基本连接方式:
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://[email protected]/ 这样的 URL 中的 userinfo,因为凭据会泄露到日志、历史记录和调试输出中。拒绝 HTTP 请求中的片段,并明确决定查询字符串是否可以包含预签名凭据。一个通用的清理函数无法挽救这样一种设计:接受所有 URL,然后希望之后再识别有问题的 URL。
重要的顺序很简单:解析、规范化、授权目标、选择凭据、构造标头、建立连接。如果后续任何操作改变了 authority,就从目标授权重新开始。
审计决策,而不是审计秘密
审计记录应该证明客户端认定的目标是什么,以及它选择了哪条凭据规则,但不能记录秘密。当有人询问部署变更后令牌是否可能到达兄弟主机时,你需要这类记录。
可以保存请求 ID、时间、规范化后的 authority、凭据记录 ID、授权结果、重定向源和目标,以及结果代码等字段。如果路径包含客户标识符,就对路径进行哈希或脱敏。不要因为 Authorization、自定义密钥标头、包含签名的查询字符串或完整请求正文有助于调试,就把它们写入日志。
一条好的审计记录应该能回答一个具体问题:
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,再列出一个不应接收它的兄弟主机、一个品牌或迁移名称、一个区域名称和一个客户专属名称。如果客户端今天还无法做出这些否定断言,那么它就没有凭据边界,只有一个寄希望于约定的规则。
常见问题
API 密钥会自动在不同子域之间生效吗?
不能。api.example.com 和 billing.example.com 是不同的源,即使两个名称都由同一个团队运营。除非凭据颁发方明确记录了更广泛的受众范围,并且你已经测试过该范围,否则应将凭据绑定视为精确匹配。
把同一个 bearer 令牌发送给所有子域安全吗?
只有当客户端经过明确配置,主动向这些子域发送令牌时,它们才应该收到令牌。共享父域只能说明这些名称属于同一个命名空间,不能说明某个服务的 bearer 凭据可以在其他服务中复用。
HTTP 重定向会把 API 凭据泄露给另一台主机吗?
重定向可能在首次请求之后改变目标,因此重定向处理需要单独进行主机检查。不要因为 HTTP 客户端会移除内置 HTTP 身份验证,就认为它也一定会移除自定义凭据标头。
每个客户子域都应该使用单独的凭据吗?
不一定。像 tenant-a.api.example.com 这样的主机通常应使用单独的租户绑定凭据,或者使用带有租户声明且由服务强制校验的令牌。把通用管理员密钥指向租户主机会让一次路由错误变成跨租户事件。
区域 API 端点需要单独的凭据吗?
如果区域端点使用不同的主机名,就应该分别处理。两个区域服务属于同一个提供商,并不意味着它们的名称可以互换。区域错误往往会在身份验证失败之前,就把数据发送到错误的数据驻留边界。
生产凭据可以安全地用于品牌 API 域名吗?
出于风险考虑,应该谨慎对待。品牌域名看起来无害,但通常位于不同的路由、日志或所有权安排之前。除非提供商明确说明该品牌域名是等价的已认证受众,否则应将凭据绑定到规范服务主机。
怎样测试客户端是否会把令牌发送到兄弟域?
记录确切的 URL、选中的凭据、重定向行为,以及请求是否携带身份验证标头。最有用的测试会使用批准的主机和刻意未批准的主机访问无害端点,如果凭据出现在任何不该出现的位置,就让测试失败。
CORS 能阻止凭据被发送到子域吗?
CORS 控制的是浏览器 JavaScript 能读取什么内容,cookie 也有自己的域和 SameSite 规则。只要代码不阻止请求,服务器端代理、命令行工具或自定义 HTTP 客户端就可以向任何主机发送任何标头。
API 凭据应该使用通配符主机允许列表吗?
对于 bearer 令牌、自定义 API 密钥标头和 SSH 目标,精确的主机允许列表通常更合适。只有在凭据系统本身能证明选中的租户或服务受众,并且你能持续验证具体匹配主机时,才应使用范围很窄的通配符。
凭据边界出错后,首先应该修复什么?
第一步是盘点所有凭据与主机的绑定关系,删除 *.example.com 之类的宽泛默认规则。然后为每个凭据添加否定测试:一个批准的目标必须收到凭据,而兄弟、品牌、区域和租户目标都不能收到它。