# HTTP 请求头优先级：避免 API 身份冲突

带有两个凭据的请求，并不意味着它拥有备用身份验证。它只是把一条含义不明确的指令交给一串软件，而每一跳都可能以不同方式解读这条指令。我见过团队花几个小时责怪 API，最后才发现代理悄悄选中了一个 Authorization 值，而应用选中了另一个。症状看起来像随机故障，直到有人在每个边界打印原始请求头。

HTTP 请求头优先级需要一条可以用一句话说明、在网络层验证，并且在身份验证前执行的规则：如果请求为同一个安全敏感的凭据字段提供了多个值，就必须失败。不要选择第一个，也不要选择最后一个，更不要用逗号拼接。否则，偶然的实现行为就会变成你的身份验证策略。

这不仅适用于 `Authorization`。许多 API 还接受 `X-API-Key`、自定义租户请求头、签名请求时间戳或转发身份字段。当凭据经过客户端、负载均衡器、协议转换器和应用中间件时，重复字段的处理就成了安全边界的一部分。

## HTTP 不会指定一个通用的优先值

HTTP 定义了字段语法，并为部分重复字段提供了特殊的合并规则，但它没有规定所有地方都使用第一个 `Authorization` 字段，也没有规定所有地方都使用最后一个。接收方必须根据字段语义和自身实现来解释收到的字段部分。

RFC 9110 指出，如果字段定义允许逗号分隔的列表，接收方可以把多个同名字段行合并为一个以逗号分隔的字段值。如果字段定义不允许这样做，接收方就不能合并。实践中这一区别经常被模糊处理。“请求头可以合并”并不是适用于所有请求头的规则，它只适用于设计成列表的字段。

`Accept` 是一个有用的对比。客户端可以发送多个 `Accept` 行，接收方通常可以把它们当作一个可接受媒体类型列表。`Authorization` 携带的是本次请求的凭据，不是一组可以互换的通用凭据。逗号也可能在身份验证方案的语法中具有特殊含义。把两个值拼在一起，可能产生两个客户端都没有打算发送的字符串，而不同解析器又可能以不同方式读取它。

请求头字段名不区分大小写。下面两个字段是重复的：

```http
Authorization: Bearer token-a
authorization: Bearer token-b
```

如果检测器比较原始拼写，就会漏掉这种情况。统计值的数量前，先规范化字段名。

不要把重复字段行和响应中的多个 `WWW-Authenticate` 身份验证质询混为一谈。服务器可以在响应中公布多个可接受的方案，那是服务器提供选项。请求中出现两个 `Authorization` 值，则是客户端提交了相互竞争的凭据。这是两个不同的问题，需要不同的处理方式。

如果 API 同时支持标准凭据字段和自定义凭据字段，也要遵循同样的原则。API 可能有意接受 `Authorization: Bearer ...` 或 `X-API-Key: ...`。如果文档没有说明同时出现两者时如何处理，就会产生身份冲突。应拒绝请求。只存在于某个中间件里的备用逻辑，不是契约。

## 客户端让重复请求头比想象中更容易出现

客户端能否发送重复请求头，取决于库的请求模型。把请求头暴露为映射的库，通常会在代码两次设置同名字段时覆盖前一个值。提供数组或多值集合的库，则可能把两个值都发出去。这两种行为都不能证明下一跳实际收到了什么。

使用 curl 时，重复 `-H` 可以请求发送两个字段：

```bash
curl --http1.1 -v https://api.example.test/orders \
  -H 'Authorization: Bearer first' \
  -H 'Authorization: Bearer second'
```

详细跟踪信息应显示两行类似这样的出站内容：

```text
> Authorization: Bearer first
> Authorization: Bearer second
```

这只能证明 curl 在该连接上发出了两个 HTTP/1.1 字段，不能证明边缘节点、负载均衡器或应用也收到了两个值。在生产 API 上使用这种模式前，请先通过你控制的测试端点进行验证。示例中的令牌应当是一次性的，真实 Bearer 令牌绝不能出现在终端录屏或支持工单中。

在 JavaScript 中，常见的 `Headers` 对象也容易让人误判。`set()` 会替换当前值。`append()` 从概念上会增加另一个值，但序列化方式和字段语义仍然很重要。开发者以为自己发送了两个请求头，库实际却构造了一个用逗号连接的值。正因如此，身份验证测试必须检查原始入口，而不能只查看内存中的对象。

Node.js 服务端代码还有一个陷阱。`req.headers` 通常会提供规范化后的名称，并且可能只呈现合并后的视图。对于 HTTP/1.1 请求，`req.rawHeaders` 会保留收到的名称和值交替排列的列表。安全的诊断方式是从原始表示中统计规范化名称，同时避免打印秘密值：

```js
function duplicateNames(rawHeaders) {
  const counts = new Map();
  for (let i = 0; i < rawHeaders.length; i += 2) {
    const name = rawHeaders[i].toLowerCase();
    counts.set(name, (counts.get(name) || 0) + 1);
  }
  return [...counts.entries()].filter(([, count]) => count > 1);
}

console.log(duplicateNames(req.rawHeaders));
// Example output: [ [ 'authorization', 2 ] ]
```

把它当作诊断工具，不要因此在应用中手动解析所有请求头。真正的拒绝操作应由框架或网关在最早的可信位置执行。重点是让你看见便捷请求头对象隐藏的内容。

自定义请求头同样值得警惕。客户端可能通过默认请求头层和单次请求层意外添加两次 `X-API-Key`。它也可能从企业客户端封装中继承一个 `Authorization` 值，同时由代码明确添加 API 密钥。由于本地路径没有那层封装，API 也许在本地测试中看起来正常。到了生产环境，请求却带上了两个凭据，行为随之改变。

## 代理可能同时改变请求和证据

反向代理既是 HTTP 接收方，也是新的 HTTP 发送方。它并不是简单地把客户端字节搬运给应用。它会解析传入请求、应用配置、转换协议，并写出一条发往上游的新请求。因此，它完全可能保留、丢弃、合并重复字段，或创建新的凭据请求头。

最危险的情况是不同组件各自解释。假设客户端发送：

```http
Authorization: Bearer attacker-token
Authorization: Bearer service-token
```

边缘组件保留第一个值来进行访问检查，而上游库把最后一个值呈现给应用。边缘节点授权的是一个身份，应用执行的却是另一个身份。即使两个组件都没有解析漏洞，这条链路也没有为请求提供唯一含义。

更常见的故障没有那么戏剧性，却更容易发生。代理配置为服务凭据添加一个上游 `Authorization` 字段，却忘记删除传入的字段。目标服务按照没人记录的行为选择其中一个值。之后一次代理升级、路由迁移，或从 HTTP/1.1 切换到 HTTP/2，都可能改变哪个值最终保留下来。团队把它称为间歇性身份验证故障，只因为他们从未记录过重复字段。

转发字段会带来相关的身份问题。`X-Forwarded-User`、`X-Forwarded-Client-Cert` 和自定义身份请求头，通常从受信任边缘节点传到应用。面向公网的边缘节点必须先删除调用方提供的副本，再加入自己的值。如果它转发不受信任的副本，应用一旦信任列表中的错误位置，调用方就可能伪造身份。

根据不同的信任边界，可以采用以下规则：

- 在公共入口，拒绝重复的身份验证字段，并删除客户端提供的内部身份请求头。
- 在受信任网关上，先移除受保护的调用方输入，再注入上游所需的唯一凭据或身份请求头。
- 在应用中再次拒绝重复字段。入口防护必不可少，但路由变化最终可能绕过原有假设。
- 在日志中记录请求头名称、数量、路由和请求标识符。只有在安全的情况下，才记录凭据方案。

不要在没有测试的情况下依赖代理默认行为。不同产品、模块、协议和配置的默认值并不相同。处理重复响应头的设置，无法说明重复请求凭据会如何处理。应阅读准确指令或中间件的文档，然后通过实际部署路径发送重复请求。

## HTTP/2 和 HTTP/3 消除的是旧语法，不是歧义

HTTP/2 和 HTTP/3 不会在网络上传输文本形式的请求头行，但仍然携带一系列请求头字段。协议规则禁止重复的伪请求头字段，例如 `:method`，并要求它们出现在普通字段之前。这些规则有助于协议正确性，却不会为 `Authorization` 提供通用的优先级规则。

HTTP/2 端点可以收到重复的普通请求头字段。根据字段和 API 的不同，库可能将它们暴露为多个值、合并后的值，或直接报错。HTTP/3 在应用层面也有同样的问题。必须测试边缘节点接受的每个协议版本。

协议转换最容易让原有假设失效。客户端通过 HTTP/2 与 CDN 或负载均衡器通信，CDN 通过 HTTP/1.1 与网关通信，网关再通过 HTTP/2 与服务通信。每一跳都必须转换请求头表示。如果第一跳合并了字段，第二跳却保留多个字段，最终应用就无法还原原始请求。这也是应该让第一个可信接收方拒绝歧义，而不是试图在后面聪明地恢复的原因。

HTTP/2 对连接级请求头也有不同处理。`Connection` 等字段被禁止，因为它们描述的是 HTTP/1.1 的连接行为。这与凭据优先级无关。不要照搬协议专用字段的规则，认为它也能保护自定义身份验证请求头。

有些团队试图通过匹配小写名称解决问题，因为 HTTP/2 要求网络上的字段名使用小写。这只解决了一个非常简单的问题。HTTP/1.1 的名称同样不区分大小写，而且网关可能先收到 HTTP/1.1，再收到 HTTP/2。应在所有地方规范化名称。

测试矩阵应围绕路径构建，而不只是围绕协议。测试环境中要覆盖直接请求应用、公共路由，以及工作进程或部署工具使用的内部路由。对每条路由分别发送重复的 `Authorization`、重复的自定义凭据请求头，以及两种相互竞争的凭据渠道。所有情况都应得到相同且明确的客户端错误。

## 选择第一个或最后一个都会制造攻击面

“使用第一个请求头”听起来比较保守，因为它像是解析器从左向右读取。“使用最后一个请求头”听起来比较实用，因为后面的配置似乎应该覆盖前面的默认值。两种规则都不可靠，因为发送方和每个中间组件都可能以不同方式影响顺序。

如果攻击者能在受信任组件加入凭据之前插入一个值，选择第一个值就会受到攻击。如果攻击者能在受信任组件检查第一个值之后追加一个值，选择最后一个值就会受到攻击。具体利用方式取决于路由和信任关系，但设计错误始终相同：一个组件选择了某个凭据，另一个组件看到的却是不同的请求。

当逗号拼接产生一个看似有效的字符串时，问题甚至更严重。比如应用按逗号拆分自定义 API 密钥字段，而网关比较的是完整字符串。又比如 Bearer 解析器只接受第一个空格后的文本，却不拒绝逗号。这样就为携带秘密的字段创造了两种解析语言。

“让 API 自己决定”的常见建议并不正确，只要网关在请求到达 API 前执行了身份验证、限流、租户路由或审计标记，它就已经做出了安全决策。网关必须使用与服务相同且没有歧义的身份，否则就应停止请求。

一个可预测的契约应当是：

1. 规范化每个传入请求头的名称。
2. 在选择凭据前，统计所有受保护字段的出现次数。
3. 对每个受保护字段的重复出现返回通用客户端错误。
4. 当路由只接受一个身份来源时，拒绝不兼容的凭据渠道。
5. 受信任组件注入自己的上游凭据前，删除受保护的传入字段。

这些步骤应在令牌解析前完成。如果先解析令牌，一个解析器可能消费掉某个值，而下一个组件本来会拒绝整个请求。错误响应应保持简单，只告诉调用方请求包含冲突的身份验证请求头，不要回显字段值。

确实存在少数 API 会有意定义一个字段的多个值。如果你拥有这样的 API，就必须把语法、顺序、重复行为和代理要求写进身份验证契约。“框架会处理”不是文档。如果 API 不属于你，不要围绕它自行发明优先级方案。

## 自定义请求头需要凭据契约，而不是命名习惯

团队常把 `Authorization` 视为敏感字段，却把自定义请求头当成无害的传输细节。这种看法是反的。只要自定义请求头决定 API 密钥或租户，它就是身份验证材料，无论名称是否以 `X-` 开头。

写清楚每条路由接受哪些凭据渠道。一条路由可能接受来自 `Authorization` 的 Bearer 令牌，另一条可能接受专用请求头中的 Webhook 签名和时间戳，内部路由则可能只接收来自网关的身份请求头。这些都是不同的契约。避免使用“找到哪个就先接受哪个”的通用中间件。

凭据契约必须回答以下问题：

- 这个字段能否出现多次？
- 它能否和另一个凭据字段同时出现？
- 哪个受信任组件可以添加它？
- 转发前代理是否会删除它？
- API 发现冲突时返回什么错误？

对于 API 密钥，应选择两种清晰模型之一。第一种只接受一个 `Authorization` 方案。第二种只接受一个命名的 API 密钥请求头。除非出于迁移需要并且已经记录了冲突规则，否则不要在同一条路由上同时支持两者。迁移期间，拒绝同时包含两者的请求，并向客户端提供不依赖具体日期的迁移提示，说明应使用哪个替代方案。永远保留无提示的备用逻辑，只会制造长期诊断问题。

签名 Webhook 需要更多注意。签名请求头可能合法地包含结构化参数，时间戳也可能与签名一起出现。但这不代表重复的签名字段是安全的。应遵循提供方的验证文档，并保留其要求的原始请求体处理方式。如果文档没有定义重复签名，验证前就应拒绝请求。不要将它们拼接起来，再寄希望于验证库做出你想要的选择。

内部身份请求头最容易出错，因为它们很方便。如果应用从公网接受 `X-User-Id`，并假设这是代理插入的，调用方就能冒充任意身份。应将内部请求头绑定到私有网络路径或经过身份验证的代理连接，在每个公共边缘节点删除它们，并确认只有受信任代理能访问应用端口。请求头优先级无法修复已经失效的网络信任边界。

## 用有意制造的冲突测试完整路由

令牌解析器的单元测试无法测试请求头优先级。你需要跨越生产环境会经过的同一组组件进行集成测试。目标应当简单且稳定：每个含糊请求都在上游操作发生前被拒绝。

从你控制的安全端点开始。为它设置请求标识符，让它只返回安全观察结果：规范化后的请求头名称、数量、协议版本和接收请求的组件。不要返回请求头值。然后把它放在与目标 API 相同的边缘、网关和服务路由之后。

运行一组小型冲突测试：

```text
Authorization 出现两次，值相同
Authorization 出现两次，值不同
Authorization 与 X-API-Key 同时出现
X-API-Key 出现两次，值相同
调用方提供的内部身份请求头与网关提供的身份同时出现
```

相同值的情况同样重要。有些开发者只拒绝不同的值，因为他们认为相同值没有危害。这会制造一个攻击者可以探测的解析差异，也会让意外重复的错误一直隐藏。任何受保护字段重复出现都应拒绝。调用方只发送一个字段，仍然可以完成身份验证。

每种情况都要检查两个结果。第一，客户端从负责这条规则的边界收到明确的 4xx 响应。第二，上游系统没有以任一身份记录操作。单独看到 401 或 403 并不能证明路由安全，上游系统可能在拒绝前已经处理了部分请求。

然后针对支持的每种协议和路径重复测试。不要只测试 curl，还要包含自动化使用的客户端库。命令行客户端、浏览器 fetch 封装、CI HTTP 库和代理运行时可能以不同方式构造请求头集合。把测试保留在部署套件中，避免代理或框架升级悄悄把拒绝行为替换成选择行为。

发生事故时，应按顺序收集安全证据。记录客户端如何构造请求、边缘节点的访问决定、网关发出的请求头名称和数量，以及应用入口观察到的内容，并用一个请求标识符关联它们。不要通过在生产环境开启完整请求头日志来解决重复请求头事故，否则身份验证错误可能变成凭据泄露。

## 由网关负责注入出站凭据

操作网关应让代理远离原始凭据，并由一个组件负责最终的出站请求。把 Bearer 令牌交给代理，再要求它自行拼装请求头，会同时赋予它权限，并增加构造错误请求的机会。当默认请求头和自定义请求头发生冲突时，审计记录也更难解释。

Sallyport 会从加密保险库注入凭据来执行 HTTP 调用，而不是把秘密暴露给代理。只有当出站请求构造器把凭据请求头当作受保护字段，而不是当作可以被代理请求头覆盖的建议时，这个边界才真正有用。

对于受保护的出站请求头，网关应选择已保存的凭据配置，不区分大小写地删除调用方提供的同名字段，然后准确添加一个最终值。自定义凭据请求头也应遵循同一规则。如果目标请求需要代理控制同名请求头，说明配置有误，或者目标 API 需要单独的路由。不要添加会悄悄改变身份的单次请求例外。

这里有一个重要区别。在受信任的出站边界，先删除代理提供的 `Authorization`，再添加配置中的值，是正确做法。接受两个请求头，再希望目标自行解决，则不是。前一种做法建立了唯一凭据来源，后一种做法把歧义导出到外部。

网关还必须防止竞争的凭据渠道。假设已保存的连接会注入 `Authorization: Bearer ...`，而代理请求中又包含 `X-API-Key`。目标也许接受任一凭据。最安全的默认行为是将其视为冲突凭据并拒绝，除非连接定义明确允许这种组合并说明理由。通用请求头允许列表无法回答这个问题，因为具体含义属于目标 API。

审计记录应告诉操作人员网关使用了哪个命名连接或凭据标签、请求发送到了哪个主机、使用了什么方法和路径，以及是否经过人工批准。不要保存 Authorization 值或秘密自定义请求头。Sallyport 的 Activity 日志以单次调用为中心，但日志无法事后修复含糊请求。请求构造器必须在请求离开机器前消除歧义。

## 在不泄露令牌的前提下，通过日志发现优先级故障

重复请求头错误之所以长期存在，是因为团队要么记录太少，看不见路由变化，要么记录太多，制造第二起事故。其实可以在不保存凭据的情况下记录足够的信息来诊断问题。

每次拒绝冲突时，记录规范化后的受保护请求头名称及其数量。加入路由名称、协议、接收组件、请求标识符和原因代码，例如 `duplicate_authorization` 或 `conflicting_credential_channels`。如果运维流程允许，还可以记录不能用于取回秘密的凭据配置标识符。

绝不要记录原始 Bearer 令牌、API 密钥、Basic 身份验证值、签名 Webhook 请求体、Cookie 或完整 Authorization 行。在字符串格式化之后再脱敏并不可靠。库可能抛出包含原始请求的异常，调试日志也可能在脱敏器运行前执行。应从安全字段构造日志，而不是先导出请求再尝试清理。

哈希令牌也不一定安全。稳定哈希会让任何能访问日志的人关联同一凭据的使用情况；如果秘密熵较低，还可能被猜出。需要关联时，应使用保险库或配置中的非秘密凭据 ID。如果没有，就记录存在一个未命名的凭据渠道，并修正配置模型。

把缺失证据视为部署失败。如果边缘节点拒绝了重复请求，却无法在审计记录中显示是哪个边缘节点做出的决定，响应人员就会浪费时间比较根本没有收到请求的应用日志。反过来，如果应用拒绝而边缘节点放行，就找到了需要加强执行的位置。

最实际的第一步很小：列出一条公共路由上的凭据请求头，在入口拒绝重复和冲突，并通过集成测试证明不会发生上游调用。之后将同一契约应用到每条代理路径和每个出站客户端。请求头顺序永远不应该决定 API 认为调用者是谁。
