经过身份验证的代理 API 调用中的内容类型混淆
内容类型混淆可能让经过身份验证的代理 API 调用绕过操作意图。针对同一模式测试 JSON、表单、multipart 和空请求体。

经过身份验证的代理调用,需要从网络边缘到操作处理程序始终保持同一种解释。如果网关看到的是无害的 JSON 请求,授权层看到的是一组字段,而处理程序看到的却是一次拥有更高权限的表单提交,那么凭据完成了自己的工作,API 仍然失败了。
这并不是只属于旧式浏览器表单的边缘问题。代理会直接生成 HTTP,频繁重试,重复使用工具描述中的示例,而且通常持有能够改变真实系统的凭据。只要请求体能选择目标、金额、环境、命令或权限,它就是授权决策的一部分。在判断调用者是否可以执行操作之前,必须让媒体类型、语法和模式都明确无歧义。
经过身份验证的请求仍必须只有一种含义
身份验证回答的是谁出示了凭据。授权回答的是该调用者是否可以执行某项操作。但这两个答案都不能保证所有组件对操作参数达成了一致理解。
考虑一个用于更改部署目标的端点:
POST /v1/deployments/promote HTTP/1.1
Authorization: Bearer <token>
Content-Type: application/json
{"environment":"staging","release":"2026.07.22"}
授权代码可能允许部署到 staging,但拒绝部署到 production。只有当授权代码收到的 environment 值与操作处理程序使用的值相同时,这段代码才可靠。如果中间件读取 JSON,处理程序随后又读取表单参数,而两者都能填充同一个请求对象,你就制造了两个事实来源。
这类失败不需要密码学令牌损坏。拥有合法会话的代理可以发送一段被某一层忽略、却被另一层认可的请求体。被攻陷的代理也可以这样做。最终形成的授权绕过,表现为输入格式差异。
RFC 9110 说明,Content-Type 表示相关表示的媒体类型,同时定义数据格式以及接收方应如何处理数据。因此,这个标头属于请求语义,而不是装饰。该 RFC 也允许在缺少 Content-Type 时将请求视为 octet-stream,或检查数据内容。这对于通用文件处理很有用,但不适合作为受保护操作 API 的默认行为。
对于操作端点,应建立以下不变量:
只有一种被接受的媒体类型,它把请求字节映射为唯一的、经过验证的命令对象。所有安全决策和副作用都使用这个对象。
端点可以支持多种表示,但每种表示都需要自己的契约和测试套件。不要把多个解析器当成可以互换的便利功能。
Content-Type 标头不是模式
Content-Type: application/json 并不意味着“这就是我期待的请求形状”。它只表示发送方声称请求体使用 JSON 媒体类型。你仍需决定该类型是否受此路由支持、是否允许参数、请求体在语法上是否有效,以及解码后的值是否符合操作契约。
受保护端点应有意识地缩小允许的表示集合。许多命令端点只应接受 JSON。上传端点可以只接受 multipart。没有参数的操作则应完全不接受请求体。允许的集合越宽,需要维护的解析路径就越多。
OWASP 的 REST Security Cheat Sheet 给出了明确的实践建议:记录支持的内容类型,拒绝意外或缺失的类型;对于内容长度为零的请求,可以允许省略内容类型。它还提醒,数据体必须与声明的类型匹配,避免生产者和消费者产生不同理解。
对于经过身份验证的操作,这条建议还需要一个限定。不要通过查看第一个字符来“匹配”声明的类型并选择解析器。以 { 开头的请求体,并不意味着可以把声明为表单数据的请求当作 JSON 处理。内容嗅探会把清晰的契约变成实现猜测。
一个有用的路由契约可以这样写:
| 路由 | 允许的请求媒体类型 | 请求体规则 |
|---|---|---|
POST /v1/deployments/promote | application/json | 必须是符合 PromoteRequest 的 JSON 对象 |
POST /v1/artifacts | multipart/form-data | 必须包含符合 ArtifactUpload 的部件 |
POST /v1/sessions/revoke | 无 | 必须包含零字节 |
要明确媒体类型参数。如果 JSON 解析器接受 application/json; charset=utf-8,就记录这一点,并通过同一个库统一处理参数。如果只接受裸的 application/json,就拒绝该参数,不要让代理和应用采用不同规则。具体选择没有统一答案,关键在于只应用一次。
还要把 Accept 的响应偏好与请求的 Content-Type 分开。客户端可以要求返回 JSON,同时发送无效的请求体。永远不要让 Accept 标头扩大操作端点会解析的请求格式范围。
JSON 需要超越语法有效的规则
JSON 解析器可以成功解析某些 API 仍必须拒绝的输入。重复成员名称就是最明显的例子:
{"environment":"staging","environment":"production","release":"2026.07.22"}
RFC 8259 规定对象名称应当唯一,并解释了原因:接收方对重复名称的处理并不一致。许多解析器保留最后一个值,有些会失败,还有一些会暴露全部键值对。这是有记录的互操作性问题,不是理论上的风格偏好。
假设授权中间件使用保留第一个 environment 值的解析器,而下游解码器保留最后一个值。中间件批准 staging,处理程序却把部署推进到 production。你无法通过更好的角色名称或增加另一个令牌声明来修复它。应在任一组件作出决策之前拒绝请求。
对于在宽松语言绑定中看似无害的值,也应执行同样严格的处理:
- 除非有明确的兼容性理由,否则操作请求应拒绝未知对象成员。
- 要求正确的 JSON 类型。布尔值不是恰好写成
true的字符串,整数标识符也不是浮点数。 - 在解析前设置有上限的请求体大小。读取超大请求体已经耗尽的内存,无法再由模式验证器保护。
- 明确字段能否省略、取
null或使用空字符串。这是三种不同状态。 - 拒绝尾随数据,以及库支持的注释、
NaN或未加引号名称等解析扩展。
不要直接从通用映射中进行授权。应把请求解码为带有明确模式的请求类型,执行语义验证,然后构造一个不保留原始解析器痕迹的内部命令类型。接收 PromoteCommand { environment, release } 的处理程序,比同时接收映射、查询集合、请求对象和原始请求体的处理程序,更不容易重新解释输入。
数字需要特别小心。JSON 语法允许很大的数字字面量,但许多运行时默认会把数字解码为浮点表示。如果某个值代表金额、配额、数据库记录或签名载荷,应使用字符串格式,或使用具有明确范围的整数解析器。不要让一层先对数字进行舍入,再让另一层比较它。
表单请求体会带来隐藏的数组和嵌套规则
application/x-www-form-urlencoded 看起来很简单,因为它类似查询字符串。但一旦库开始为重复名称、方括号表示法、加号和空值赋予含义,情况就不再简单。
看下面这些请求体:
role=user&role=admin
role[]=user&role[]=admin
role[user]=1&role[admin]=1
role=user%26role%3Dadmin
不同框架可能把它们处理成最后一个标量、第一 个标量、数组、对象、字面字段名,或解析错误。有些中间件会为所有请求方法解析表单。有些应用框架会把查询参数和表单参数合并到一个便利对象中。受保护 API 往往就是在这个便利对象中失去对调用者实际发送内容的追踪。
OWASP 关于 HTTP 参数污染的测试建议指出,行为取决于应用、Web 服务器、WAF 和中间件之间的相互作用。这正是应测试原始重复参数,而不是只相信某个框架解析器文档的原因。
“为了兼容客户端,让每个端点同时接受 JSON 和 URL 编码表单”的常见建议通常是错误的。它之所以一直存在,是因为这样容易编写演示客户端,而且许多框架默认启用这两种解析方式。结果是每个操作都要维护两份表示契约,而查询字段与请求体合并后,又悄悄增加了第三份契约。
如果必须支持表单端点,应为它制定端点专用的解析策略:
- 除非模式将字段定义为列表,否则拒绝重复名称。
- 除非模式定义了方括号语法的精确编码,且解析器实现一致,否则拒绝方括号语法。
- 保持查询参数与表单字段分离,不允许任一来源覆盖另一来源。
- 只有在验证完成后,才把解析后的字段转换为与 JSON 路由相同的类型化内部命令。
- 通过生产请求路径测试百分号编码、
+与%20、空值、缺少=以及重复字段。
不要通过选择“第一个优先”或“最后一个优先”来解决问题。这只能在一个组件内部产生确定答案,却保留其他组件之间的分歧。受保护的标量字段应当只出现一次。
Multipart 是上传协议,不是灵活的 JSON
multipart/form-data 有明确用途:携带多个带有独立标头的部件,通常用于文件内容。RFC 7578 为表单值定义了这种格式,并要求使用边界参数分隔部件。每个部件还可以携带自己的标头和文件名元数据。
这类结构让 multipart 不适合充当普通经过身份验证命令的后备表示。它有更多语法、更多大小处理逻辑、更多重复字段名称的可能,也有更多机会让网关检查一个部件,而应用选择另一个部件。
一种常见的错误设计,是接受一个 JSON metadata 部件和一个文件,同时还接受可以覆盖元数据的顶层表单字段:
Content-Disposition: form-data; name="metadata"
{"project":"alpha","visibility":"private"}
Content-Disposition: form-data; name="visibility"
public
一个组件可能根据 metadata.visibility 进行授权,另一个组件却把后面的表单部件绑定到处理程序的 visibility 参数。这样,同一个敏感属性就以两种语法出现了两个值。
应围绕名称明确、职责独立的部件设计 multipart 端点。例如,只接受一个 file 部件和一个 manifest 部件。要求 manifest 使用带有严格模式的 JSON。拒绝上传契约未列出的部件名称,拒绝重复的单例部件,为总请求体和文件大小分别设置限制,并明确是否要求部件级 Content-Type 值。
不要把文件名当作路径,把 MIME 声明当作文件分类,也不要把 multipart 解析器的临时文件行为当作安全控制。这些属于独立的上传问题。解析器混淆的规则更简单:授权输入必须来自一个命名且经过验证的来源。如果 manifest.project 决定文件写入的位置,那么任何其他部件、查询参数或标头都不能改变这个 project。
命令不包含文件时,不要接受 multipart。多一种被接受的媒体类型,就多一种让两个组件产生分歧的方式。
空请求体是一份契约,不是缺少验证
有些经过身份验证的操作不需要参数。撤销当前会话、轮换服务器生成的 nonce,或确认固定事件,都可以使用空请求体。在这些情况下,应让空请求体真正可强制执行。
没有请求体契约的端点应拒绝以下所有请求:
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/json
Content-Length: 2
{}
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11
scope=other
POST /v1/sessions/revoke HTTP/1.1
Transfer-Encoding: chunked
0
最后一个示例不包含内容,但仍使用了无请求体契约可能禁止的成帧机制。是否拒绝它取决于 HTTP 协议栈,但应在边缘明确决定并进行测试。不要让代理传递一种成帧方式,而应用却以不同方式处理。
对于无请求体路由,应在业务逻辑之前执行以下规则:
- 请求不包含任何内容字节。
- 除非兼容性规则明确允许,否则路由不接受
Content-Type。 - 路由不把查询参数合并到命令中,除非每个允许的查询名称都在自己的模式中定义。
- 服务器将操作记录为无参数操作,而不是记录一个通用请求对象,让后续读者误以为其中包含输入。
RFC 9110 根据方法语义描述请求内容。请求使用 POST,并不意味着请求体自动拥有普遍含义。这个含义由你的资源契约提供。
一个棘手情况是客户端库总是发送 {}。不要只是为了迁就它而放宽端点。修复客户端,或提供一个独立且有文档说明的路由。当前没有作用的请求体,常常会在处理程序后续修改后意外变成输入通道。
在授权前验证,并从已验证的命令执行
最安全的请求管道只有一个前进方向。原始字节进入后,路由选择一个允许的解析器。解析器生成类型化值。验证生成规范命令。授权针对该命令进行评估。执行器接收同一个命令。
raw HTTP request
-> route and media-type check
-> bounded body read
-> one strict parser
-> schema and semantic validation
-> canonical command
-> authorization
-> execution and audit record
不要颠倒中间两个阶段。授权通常需要项目 ID、环境、收件人或命令模式等字段,因此团队很容易在早期检查宽松解析的输入。这会创建一个授权前解析器,并要求它永远与最终解码器保持完全一致。很少有系统能长期做到这一点。
规范命令是实际的边界,不是图表中的设计模式。它只应包含执行器需要的值,并排除原始请求体文本、表单集合、框架请求对象和别名。如果执行器收到 target_environment,就不应因为目标缺失或取值不方便而继续查看 req.query.environment。
这也能改善审计记录。记录经过身份验证的主体、端点、接受的媒体类型、请求摘要、可以安全保留的规范命令字段、授权决定和结果。默认记录原始请求体会制造另一个问题,因为请求体可能包含凭据、上传文件和用户数据。摘要可以让你将事件与保留的证据关联起来,同时避免把日志变成秘密存储。
请求签名也需要同样的纪律。如果客户端签署的是字节,而服务器授权的是规范化对象,就要记录签名表示规则和规范化规则。如果客户端签署的是规范对象,就应在验证签名之前拒绝所有替代编码。否则,两组字节可能承载同一个业务请求,或者同一组字节在解析后获得不同含义。
测试分歧,而不只是测试正常解析
只对一个有效 JSON 固件进行反序列化的单元测试,几乎无法证明解析器之间达成了一致。测试目标应是公开请求路径:负载均衡器或反向代理、网关、框架中间件、路由处理程序,以及任何会重新解析请求体的服务。
为每个经过身份验证的操作建立一组紧凑的负面样本。样本应在 CI 中针对临时环境运行,并同时断言响应和副作用不存在。即使返回了 400,如果队列消息、审计事件或部分文件写入已经发生,也不能算测试通过。
从下面这个 shell 测试脚本开始。它刻意发送原始请求体,而不是依赖会拒绝畸形输入的生成式客户端:
base=https://api.test.example
bearer='test-token'
send() {
name=$1
type=$2
body=$3
code=$(curl -sS -o "/tmp/${name}.out" -w '%{http_code}' \
-X POST "$base/v1/deployments/promote" \
-H "Authorization: Bearer $bearer" \
-H "Content-Type: $type" \
--data-binary "$body")
printf '%-28s %s\n' "$name" "$code"
}
send valid_json 'application/json' \
'{"environment":"staging","release":"2026.07.22"}'
send duplicate_json 'application/json' \
'{"environment":"staging","environment":"production","release":"2026.07.22"}'
send form_body 'application/x-www-form-urlencoded' \
'environment=production&release=2026.07.22'
send false_json 'application/json' \
'environment=production&release=2026.07.22'
预期输出应为一个成功响应和三个客户端拒绝:
valid_json 200
duplicate_json 400
form_body 415
false_json 400
实际状态码约定可能会对语法有效但模式验证失败的请求返回 422。重要的是保持这一区分:媒体类型不匹配的请求绝不能进入后备解析器,重复的 JSON 成员也绝不能到达授权阶段。
继续加入针对组件边界的测试:
| 情况 | 必须发生的结果 |
|---|---|
非空请求体缺少 Content-Type | 在解析前拒绝 |
| JSON 对象包含未知字段 | 拒绝,或记录明确说明的兼容行为 |
| 重复的表单标量字段 | 拒绝 |
| 查询值与 JSON 值冲突 | 拒绝,或根据路由契约忽略查询值 |
Multipart 包含两个 manifest 部件 | 拒绝 |
无请求体路由收到 {} | 拒绝 |
然后检查审计记录。每个被拒绝的输入都应留下能够识别路由和拒绝类别的追踪记录,但不能记录敏感请求内容。每个被接受的输入都应产生一个规范命令。如果日志显示网关看到一个目标,而处理程序记录了另一个目标,那么即使测试得到 2xx 响应,你也已经找到了分歧。
代理和中间件也是解析器
团队经常只关注应用解析器,却忘了它之前的组件。反向代理可能会规范化标头。API 网关可能解析 JSON 以应用规则。WAF 可能解析表单数据。可观测性中间件可能读取并重新构造请求体。框架可能在路由处理程序运行前填充查询、表单和 JSON 字段。
OWASP 关于 HTTP 请求走私的指导描述了这个问题更大的一种形式:中间件与后端服务器可能对请求边界产生不同理解,尤其是在协议转换和成帧方面。内容类型混淆并不需要请求走私才能造成危险,但两类失败都源于允许不同层作出互不兼容的解析决定。
盘点操作路径中读取请求体的每个组件。对每个组件写清楚它解析哪些媒体类型、是否保留重复值、是否解压内容、是否设置大小限制,以及是否能够重写请求体。如果没人能回答这些问题,这个端点就还不适合接收代理凭据。
让网关承担有限职责。它可以执行路由级请求体大小限制,也可以阻止路由永远不会接受的媒体类型。它还可以在应用之前拒绝格式错误的标头。但不要通过网关转换把表单数据变成 JSON,也不要用网关“清理”重复字段。应用仍必须使用实际执行时的准确语义拒绝歧义。
测试生产环境实际使用的 HTTP 版本和部署路径。直接访问本地开发服务器时行为正常的请求,可能在 HTTP/2 客户端访问代理、而代理再转发 HTTP/1.1 给应用时发生变化。目的不是建立攻击研究实验室,而是让生产链证明,每个被接受的请求都会生成一个命令对象。
代理网关应维护这条边界
代理网关应让凭据远离模型,并保留操作记录,但它无法单独让一个宽松的目标 API 变得安全。网关必须发送目标路由明确支持的表示,目标服务也必须在评估权限前验证该表示。
Sallyport 的 HTTP 通道会注入凭据,同时让 API 密钥留在代理之外,因此代理可以请求执行操作,却不会拿到秘密本身。这是一条有用的凭据边界,但还要配合拒绝歧义请求体的端点契约,因为受保护凭据最终仍会授权到达 API 的请求。
为代理提供体现契约的工具,不要在敏感系统中暴露一个通用的“发送任意 HTTP 请求”操作。部署提升工具应接受类型明确的 environment 和 release 参数。它的实现应序列化一个 JSON 对象,设置一种媒体类型,并拒绝无法满足 API 模式的工具输入。接收服务仍必须重复验证。工具模式可以减少错误,但不能取代服务器端的不信任。
代理需要上传时,应提供一个独立工具,并明确命名文件和 manifest。代理需要执行无参数操作时,就不要提供请求体字段。这些小约束能让代理的预期请求更容易检查、审批、在测试环境重放,并在之后审计。
不要批准一个模糊的能力,然后指望解析器补足缺失的精确性。让端点只接受一种含义,让代理发送这种含义,并在凭据能够授权之前拒绝所有其他写法。
常见问题
API 中的内容类型混淆是什么?
这是指不同组件对经过身份验证的 HTTP 请求产生了不同理解。网关、模式验证器、框架解析器、授权检查和处理程序可能分别以不同方式检查同一组字节,让请求以一种含义通过控制,却以另一种含义执行。
为什么内容类型混淆对 AI 代理尤其危险?
代理可以快速发送大量经过身份验证的请求,而且一个会话可能拥有较广泛的操作权限,因此风险会更加明显。即使人类批准了发起请求的代理进程,API 仍必须把每次调用都当作不可信输入。
application/json 能保证 JSON 请求安全吗?
不能。application/json 只声明了请求使用的媒体类型,并不保证 JSON 有效、对象成员名称唯一、字段类型正确,或请求形状符合要求。应严格解析,拒绝重复名称,然后根据端点模式验证结果。
JSON API 应该接受 application/x-www-form-urlencoded 请求吗?
除非端点明确接受表单数据,并为其制定独立且完整的契约,否则应拒绝。不要在授权前把表单字段转换成 JSON 结构,因为重复字段和方括号语法在不同库中可能有不同含义。
经过身份验证的 API 什么时候应该允许 multipart/form-data?
只有在端点确实需要文件上传,或现有客户端协议要求使用 multipart 时才接受。应把 multipart 当作独立的解析和模式路径,限制部件名称及其标头,不要把它悄悄当成 JSON 操作的另一种写法。
经过身份验证的 POST 端点可以要求空请求体吗?
如果操作没有请求表示,可以。此时应明确规定请求不得包含请求体,Content-Length: 0 的请求也必须遵守这一契约,并拒绝试图通过内容类型、传输编码或字节内容把它变成另一种操作的请求。
对于错误的 Content-Type,API 应返回什么状态码?
严格端点会在评估授权或执行业务逻辑前返回客户端错误。可以使用 415 表示不支持的媒体类型,使用 400 表示语法错误,使用 422 表示语法有效但不符合端点模式,前提是这些区分符合你的 API 约定。
如何测试 API 是否存在解析器分歧?
通过生产环境使用的同一公开入口、网关和应用路径发送原始请求。针对每个受保护操作,改变媒体类型、重复字段、重复参数、multipart 部件标头、请求体长度和内容编码,然后确认所有无效变体都在到达操作层之前失败。
API 网关或 WAF 能单独解决解析器混淆吗?
WAF 或 API 网关可以拒绝明显的错误输入,但它本身也是解析链中的一个解析器,也可能引入不同的解释。负责授权和执行操作的应用仍必须解析并验证一个规范表示。
人工审批能取代逐请求模式验证吗?
审批应覆盖代理进程及其获得的能力,而 API 仍应把每个请求体当作来自恶意代码的输入进行验证。人工审批不是格式验证器,也无法修复代理发送请求后产生的歧义。