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

经过身份验证的代理调用，需要从网络边缘到操作处理程序始终保持同一种解释。如果网关看到的是无害的 JSON 请求，授权层看到的是一组字段，而处理程序看到的却是一次拥有更高权限的表单提交，那么凭据完成了自己的工作，API 仍然失败了。

这并不是只属于旧式浏览器表单的边缘问题。代理会直接生成 HTTP，频繁重试，重复使用工具描述中的示例，而且通常持有能够改变真实系统的凭据。只要请求体能选择目标、金额、环境、命令或权限，它就是授权决策的一部分。在判断调用者是否可以执行操作之前，必须让媒体类型、语法和模式都明确无歧义。

## 经过身份验证的请求仍必须只有一种含义

身份验证回答的是谁出示了凭据。授权回答的是该调用者是否可以执行某项操作。但这两个答案都不能保证所有组件对操作参数达成了一致理解。

考虑一个用于更改部署目标的端点：

```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 仍必须拒绝的输入。重复成员名称就是最明显的例子：

```json
{"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` 看起来很简单，因为它类似查询字符串。但一旦库开始为重复名称、方括号表示法、加号和空值赋予含义，情况就不再简单。

看下面这些请求体：

```text
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` 部件和一个文件，同时还接受可以覆盖元数据的顶层表单字段：

```text
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，或确认固定事件，都可以使用空请求体。在这些情况下，应让空请求体真正可强制执行。

没有请求体契约的端点应拒绝以下所有请求：

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/json
Content-Length: 2

{}
```

```http
POST /v1/sessions/revoke HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Content-Length: 11

scope=other
```

```http
POST /v1/sessions/revoke HTTP/1.1
Transfer-Encoding: chunked

0

```

最后一个示例不包含内容，但仍使用了无请求体契约可能禁止的成帧机制。是否拒绝它取决于 HTTP 协议栈，但应在边缘明确决定并进行测试。不要让代理传递一种成帧方式，而应用却以不同方式处理。

对于无请求体路由，应在业务逻辑之前执行以下规则：

- 请求不包含任何内容字节。
- 除非兼容性规则明确允许，否则路由不接受 `Content-Type`。
- 路由不把查询参数合并到命令中，除非每个允许的查询名称都在自己的模式中定义。
- 服务器将操作记录为无参数操作，而不是记录一个通用请求对象，让后续读者误以为其中包含输入。

RFC 9110 根据方法语义描述请求内容。请求使用 POST，并不意味着请求体自动拥有普遍含义。这个含义由你的资源契约提供。

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

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

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

```text
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 测试脚本开始。它刻意发送原始请求体，而不是依赖会拒绝畸形输入的生成式客户端：

```bash
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'
```

预期输出应为一个成功响应和三个客户端拒绝：

```text
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。代理需要执行无参数操作时，就不要提供请求体字段。这些小约束能让代理的预期请求更容易检查、审批、在测试环境重放，并在之后审计。

不要批准一个模糊的能力，然后指望解析器补足缺失的精确性。让端点只接受一种含义，让代理发送这种含义，并在凭据能够授权之前拒绝所有其他写法。
