阅读需 8 分钟

经过身份验证的代理 API 调用中的内容类型混淆

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

经过身份验证的代理 API 调用中的内容类型混淆

经过身份验证的代理调用,需要从网络边缘到操作处理程序始终保持同一种解释。如果网关看到的是无害的 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/promoteapplication/json必须是符合 PromoteRequest 的 JSON 对象
POST /v1/artifactsmultipart/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 编码表单”的常见建议通常是错误的。它之所以一直存在,是因为这样容易编写演示客户端,而且许多框架默认启用这两种解析方式。结果是每个操作都要维护两份表示契约,而查询字段与请求体合并后,又悄悄增加了第三份契约。

如果必须支持表单端点,应为它制定端点专用的解析策略:

  1. 除非模式将字段定义为列表,否则拒绝重复名称。
  2. 除非模式定义了方括号语法的精确编码,且解析器实现一致,否则拒绝方括号语法。
  3. 保持查询参数与表单字段分离,不允许任一来源覆盖另一来源。
  4. 只有在验证完成后,才把解析后的字段转换为与 JSON 路由相同的类型化内部命令。
  5. 通过生产请求路径测试百分号编码、+%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,并不意味着请求体自动拥有普遍含义。这个含义由你的资源契约提供。

一个棘手情况是客户端库总是发送 {}。不要只是为了迁就它而放宽端点。修复客户端,或提供一个独立且有文档说明的路由。当前没有作用的请求体,常常会在处理程序后续修改后意外变成输入通道。

在授权前验证,并从已验证的命令执行

验证操作轨迹
使用 `sp audit verify` 离线验证经过加密并由哈希链接的审计轨迹,无需保险库密钥。

最安全的请求管道只有一个前进方向。原始字节进入后,路由选择一个允许的解析器。解析器生成类型化值。验证生成规范命令。授权针对该命令进行评估。执行器接收同一个命令。

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 密钥留在代理之外
Sallyport 会自行注入 HTTP 凭据,因此代理永远不会拿到 API 密钥。

团队经常只关注应用解析器,却忘了它之前的组件。反向代理可能会规范化标头。API 网关可能解析 JSON 以应用规则。WAF 可能解析表单数据。可观测性中间件可能读取并重新构造请求体。框架可能在路由处理程序运行前填充查询、表单和 JSON 字段。

OWASP 关于 HTTP 请求走私的指导描述了这个问题更大的一种形式:中间件与后端服务器可能对请求边界产生不同理解,尤其是在协议转换和成帧方面。内容类型混淆并不需要请求走私才能造成危险,但两类失败都源于允许不同层作出互不兼容的解析决定。

盘点操作路径中读取请求体的每个组件。对每个组件写清楚它解析哪些媒体类型、是否保留重复值、是否解压内容、是否设置大小限制,以及是否能够重写请求体。如果没人能回答这些问题,这个端点就还不适合接收代理凭据。

让网关承担有限职责。它可以执行路由级请求体大小限制,也可以阻止路由永远不会接受的媒体类型。它还可以在应用之前拒绝格式错误的标头。但不要通过网关转换把表单数据变成 JSON,也不要用网关“清理”重复字段。应用仍必须使用实际执行时的准确语义拒绝歧义。

测试生产环境实际使用的 HTTP 版本和部署路径。直接访问本地开发服务器时行为正常的请求,可能在 HTTP/2 客户端访问代理、而代理再转发 HTTP/1.1 给应用时发生变化。目的不是建立攻击研究实验室,而是让生产链证明,每个被接受的请求都会生成一个命令对象。

代理网关应维护这条边界

代理网关应让凭据远离模型,并保留操作记录,但它无法单独让一个宽松的目标 API 变得安全。网关必须发送目标路由明确支持的表示,目标服务也必须在评估权限前验证该表示。

Sallyport 的 HTTP 通道会注入凭据,同时让 API 密钥留在代理之外,因此代理可以请求执行操作,却不会拿到秘密本身。这是一条有用的凭据边界,但还要配合拒绝歧义请求体的端点契约,因为受保护凭据最终仍会授权到达 API 的请求。

为代理提供体现契约的工具,不要在敏感系统中暴露一个通用的“发送任意 HTTP 请求”操作。部署提升工具应接受类型明确的 environmentrelease 参数。它的实现应序列化一个 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 仍应把每个请求体当作来自恶意代码的输入进行验证。人工审批不是格式验证器,也无法修复代理发送请求后产生的歧义。

Sallyport

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

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