阅读需 8 分钟

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

HTTP 请求头优先级不一致,可能让 API 在客户端和代理之间得到不同身份。了解如何安全拒绝重复的 Authorization 和自定义请求头。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用 curl 时,重复 -H 可以请求发送两个字段:

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

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

> 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 会保留收到的名称和值交替排列的列表。安全的诊断方式是从原始表示中统计规范化名称,同时避免打印秘密值:

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 发送方。它并不是简单地把客户端字节搬运给应用。它会解析传入请求、应用配置、转换协议,并写出一条发往上游的新请求。因此,它完全可能保留、丢弃、合并重复字段,或创建新的凭据请求头。

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

Authorization: Bearer attacker-token
Authorization: Bearer service-token

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

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

转发字段会带来相关的身份问题。X-Forwarded-UserX-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,并假设这是代理插入的,调用方就能冒充任意身份。应将内部请求头绑定到私有网络路径或经过身份验证的代理连接,在每个公共边缘节点删除它们,并确认只有受信任代理能访问应用端口。请求头优先级无法修复已经失效的网络信任边界。

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

需要时撤销一次运行
Sessions 会单独记录代理运行,因此已批准的进程可以立即撤销。

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

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

运行一组小型冲突测试:

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

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

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

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

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

由网关负责注入出站凭据

停止把 API 密钥交给代理
Bearer、Basic 和自定义请求头凭据都加密保存在 Sallyport 保险库中,不会进入代理上下文。

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

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

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

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

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

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

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

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

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

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

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

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

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

常见问题

同一个 HTTP 请求头出现两次时,哪个值会生效?

HTTP 没有为重复字段名规定一个普遍适用的优先值。接收方可能合并某些字段、拒绝请求、保留第一个值、保留最后一个值,或把两个值继续传递。对于安全敏感字段,每个边界都需要明确规则。

API 是否应该接受两个 Authorization 请求头?

应将重复的 Authorization 请求头视为请求失败。选择第一个或最后一个值,会让最终凭据取决于客户端行为和代理改写,这不是可靠的安全契约。应在身份验证前拒绝请求,并安全地记录重复情况。

HTTP/2 会产生重复请求头吗?

可以,而且这正是容易造成混淆的地方。HTTP/2 和 HTTP/3 会以结构化请求头块传输字段,而 HTTP/1.1 跳转可能以不同方式将它们序列化。网关必须确保重复请求头的拒绝规则在协议转换前后保持一致。

反向代理会合并重复的 HTTP 请求头吗?

反向代理可能保留两个字段、将它们合并、删除其中一个,或加入自己的凭据。结果由代理配置和模块顺序决定,而不是由浏览器或 API 客户端决定。应测试实际部署的路由,不要假设代理是透明的。

可以同时发送 Authorization 和 X-API-Key 吗?

只有在服务明确说明时才使用独立请求头,例如 X-API-Key 或厂商专用凭据请求头。不要把它和 Authorization 一起作为备用凭据发送。除非文档规定了安全且确定的选择方式,否则服务器应拒绝同时提供多个凭据的请求。

Authorization 和 authorization 是不同的请求头吗?

不可以。HTTP 请求头名称不区分大小写,因此 authorization、Authorization 和 AUTHORIZATION 都表示同一个字段。重复检测必须比较规范化后的字段名,而不是原始拼写。

可以用逗号合并重复的 Authorization 请求头吗?

不要用逗号拼接 Authorization。逗号可能出现在身份验证方案的语法中,而身份验证请求头通常也不是列表字段。应拒绝请求,或在受信任边界按照文档化规则移除不需要的字段。

如何安全地调试重复请求头?

在每个环节检查原始请求:客户端、边缘代理、应用网关和应用本身。记录请求头名称及长度或凭据方案等安全元数据,但绝不要记录 Bearer 令牌。使用受控的本地监听器,可以确认客户端实际发出了什么。

第一个 Authorization 请求头是否总会生效?

不要依赖请求顺序。不同库可能保留、重排、合并或覆盖重复字段,中间件也可能改变网络上的表示方式。安全的 API 应无论顺序如何都拒绝所有重复字段。

自定义身份验证请求头也会重复吗?

不要想当然。某些客户端可以发送重复的自定义请求头,另一些会覆盖映射中的同名值,中间件还可能进行自己的规范化处理。应在隔离的测试环境中捕获网络请求,并将这项测试保留在发布检查中。

Sallyport

Sallyport 替你的 AI 智能体执行 API 调用和 SSH 命令。密钥留在你 Mac 上的本地密钥库里;每次运行由你批准,每个操作都落入一份密封的审计日志。

© 2026 Sallyport · 依据 Apache-2.0 开源 · Oleg Sotnikov