# 解码大小限制能保护代理免受压缩 API 影响吗？

压缩响应并不会因为到达时很小，就真的很小。只有在客户端完成解码、解析、存储、记录日志，并可能把它交给一个把响应当作证据的代理之前，它才显得很小。如果只限制从网络接收的字节数，你测量的只是整个事务中最不重要的部分。

我见过一些团队设置看起来很合理的 5 MB HTTP 限制，测试通过后很满意，接着却发现，几 KB 的压缩重复数据仍然能展开成足以拖慢进程或污染代理运行的响应。这里不需要什么复杂的漏洞利用。某个端点返回的报告中，有一个字段的重复次数远超预期。压缩做得非常出色，而客户端做了最危险的事：先收集完整的解码响应体，再决定是否真的需要它。

解码大小限制必须放在解码流上，并且要先于解析器、日志记录器、缓存或代理上下文读取响应体。压缩流的字节数也要单独设置上限，因为这能阻止缓慢或异常庞大的传输。这两个限制针对的是不同的故障。把它们混为一谈，防护检查就会变成虚假的安全感。

## 传输层大小只能说明一部分情况

Content-Length 通常描述传输中的 HTTP 消息体。当响应带有 `Content-Encoding: gzip` 或 `Content-Encoding: br` 时，这个数值描述的是压缩字节数。一个 40 KB 的响应体，解码后可能膨胀到几十 MB，甚至几百 MB，只要输入中有足够多的重复内容。具体膨胀比不是重点。只要它超过你的分配预算或上下文预算，就足以造成问题。

RFC 9110 将内容编码视为应用于某个表示的转换。这个措辞很重要。应用使用的表示是解码后的形式，而传输过程承载的是编码后的形式。如果客户端根据传输形式设置应用层限制，就把防护放在了转换的错误一侧。

Transfer-Encoding 让情况更加复杂。分块传输没有可供客户端依赖的最终 Content-Length，HTTP/2 或 HTTP/3 响应也不会以同样的方式使用分块传输。即使标头存在，服务器也可能发送错误的值。可以把标头用作提前拒绝的提示，但绝不能把它当作响应体安全的证明。

一条响应值得记录三个数值：读取的传输字节数、产生的解码字节数，以及为调用方保留的字节数。它们有时相同，但经常不同。一个 JSON 响应解码后可能是 12 MB，解析后占用的内存远超 12 MB，最后还需要裁剪到 64 KB，代理才能安全使用。

这个区分还能避免围绕单个「最大响应大小」设置展开的常见争论。一位工程师说的是套接字字节数，另一位说的是解码字节数，第三位说的是插入工具结果的文本大小。给每个限制取一个明确的名称，并在它所描述的边界上执行。

## 先解码，再进行任何无界缓冲

安全的顺序很简单：限制原始响应流，选择并初始化解码器，限制解码流，然后只解析或保留调用方需要的内容。不要调用会把整个解码响应读入字节切片的便捷方法，再在事后检查长度。到了那一步，解码器已经用掉了你原本想保住的内存。

对于 Go 中的 Gzip 响应，解码限制器应包在 Gzip reader 外面。这个 helper 会有意多读取一个超出上限的字节。没有这一个额外字节，就无法区分实际大小刚好达到上限的响应和在上限处被截断的更大响应。

```go
var ErrDecodedBodyTooLarge = errors.New("decoded response exceeds limit")

func readGzipBody(r io.Reader, limit int64) ([]byte, error) {
    zr, err := gzip.NewReader(r)
    if err != nil {
        return nil, err
    }
    defer zr.Close()

    bounded := &io.LimitedReader{R: zr, N: limit + 1}
    body, err := io.ReadAll(bounded)
    if err != nil {
        return nil, err
    }
    if int64(len(body)) > limit {
        return nil, ErrDecodedBodyTooLarge
    }
    return body, nil
}
```

在调用 `gzip.NewReader` 之前也要限制原始流。这个限制不能替代解码限制。它能阻止对端发送巨大的压缩流，也能限制客户端在解码器获得足够输入并开始工作前所接受的工作量。

不要静默截断后继续处理。截断的 JSON 文档通常会解析失败，但截断的文本或按行协议看起来可能仍然合理。若要保留诊断预览，请在一个不会被误认为完整响应体的字段中明确标记它是预览。操作本身应当失败，因为客户端没有获得完整且获准接收的响应。

解码器可能要读到流末尾，才能发现校验和损坏。解码大小限制触发后，应停止读取并拒绝响应。对于已经决定丢弃的超大响应，不必为了完成校验而继续读完。当响应保持在限制以内时，要读到 EOF，让解码器正常报告截断或损坏。

## 响应可能先耗尽上下文，再耗尽内存

保护内存是必要的，但代理工具还有另一项预算：可以安全进入代理对话的结果文本量。一个 2 MB 的 JSON 响应对桌面进程可能没有问题，却可能完全不适合作为工具结果。它会挤占任务内容，诱使代理追踪无关记录，或者迫使模型在一个看似完整、实际不完整的表示上进行推理。

不要因为上下文预算有限，就提高解码响应体的限制。这两个限制保护的是不同操作。解码限制让传输和解析能够安全完成。展示限制则控制成功解码后，以及在适用时完成结构化解析后，代理最终能接收到什么内容。

对于结构化数据，应选择投影结果，而不是随意截断字节。如果端点返回记录列表，就限制保留的记录数，以及每个字段保留的文本量。只有在解析器没有保留完整列表的情况下成功获得总记录数，才应包含总数。要说明结果已缩减，并给出选择规则，例如「按时间戳排序后的前 50 条记录」。这样结果可供审计，也能防止代理把片段当成完整搜索结果。

对于纯文本，尽可能保留完整行。半行结束的日志预览不太有用，还可能隐藏解释问题的字段。设置字节预算，逐行读取并限制行长度，同时报告保留数量和省略其余内容的原因。如果行读取器没有自己的令牌大小限制，只是把分配问题转移到了另一个 helper 中。

很多「模型可以总结这些内容」的想法，正是在这里失效的。模型可以总结你有意选择的数据，却无法让无界传输变得安全。你也不应在检查原始远程输出之前，就让模型决定进程要分配多少内存。

## 内容编码必须明确写进客户端契约

应把 Content-Encoding 当作选择解码器的输入，而不是包裹在普通字节流外面的装饰。客户端只接受自己实现的编码，并清楚地拒绝未知值。如果服务返回 `gzip`，就使用 Gzip 路径；如果返回 `br`，就使用 Brotli decoder，并包在同样的解码字节限制器外面；如果没有内容编码，就把原始响应体包在这个解码限制器中，因为这种情况下原始字节也就是解码字节。

不要随意接受多个内容编码。HTTP 允许编码列表，而且这些转换有明确顺序。支持 `gzip, br` 意味着必须按相反顺序解码，并在每个膨胀阶段之后设置大小防护。只设置最终限制并不可靠，因为中间阶段可能先膨胀到很大，之后才被最终阶段缩小。如果你的集成不需要叠加编码，就先拒绝这类响应，直到你拥有测试和经过明确设计的实现。

自动解压缩值得仔细检查。许多 HTTP 库会自动添加 Accept-Encoding、解码 Gzip，并对应用代码隐藏这一变化。这对普通请求很方便，但也可能让代码在错误的层级统计字节。要确认响应体返回的是传输字节还是解码字节，库是否移除了 Content-Encoding，以及库是否提供压缩字节计数。针对你的确切客户端配置编写测试，证明它的实际行为。

Brotli 需要和 Gzip 一样认真对待。有人可能会因为每个环境都有 Gzip 命令，就只写一个 Gzip 测试，然后以为工作已经完成。不同的解码器意味着不同的错误处理、缓冲行为，以及不同依赖版本中的支持情况。限制必须包住每个解码器返回的 reader，而不能放在只有 Gzip 路径会调用的 helper 中。

服务器也可能声明了一种编码，却发送无效响应体。应将这种情况保留为解码错误，并与解码响应过大的错误区分开。运维人员需要知道，问题究竟是远端发送了坏数据、配置的预算过低，还是客户端没有实现声明的编码。

## 使用可检查的文件测试膨胀

一个有用的测试样例应从可识别、可测量的解码内容开始。重复字节能提供明显的膨胀案例。以下命令会创建一个 32 MiB 的文本响应体，然后在安装了 Brotli 命令时生成 Gzip 和 Brotli 版本。

```sh
python3 -c 'open("repeat.txt", "wb").write(b"A" * (32 * 1024 * 1024))'
wc -c repeat.txt
gzip -9 -c repeat.txt > repeat.txt.gz
brotli -f repeat.txt -o repeat.txt.br
wc -c repeat.txt.gz repeat.txt.br
```

第一个 `wc` 输出应类似 `33554432 repeat.txt`。由于输入只重复一个字节，压缩文件应比源文件小很多。不要让测试断言某个特定的压缩大小。压缩版本和设置可能发生变化。应断言解码后的长度、客户端结果，以及客户端拒绝响应后没有保留完整响应体。

使用同一个源文件构造边界案例：解码后比配置限制少一个字节、刚好达到限制，以及超过限制一个字节。通过一个正确设置 Content-Encoding 的本地 HTTP handler 运行每个案例。文件解码器测试很有用，但它无法发现客户端库在你的限制代码运行前就自动解压缩的问题。

再使用一组结构更接近真实情况的样例。生成每条记录都带有重复 payload 字段的 NDJSON，然后通过 Gzip 和 Brotli 提供它。这能发现这样的代码问题：虽然字节切片处理正确，却让 JSON 解析器或行扫描器在解码后构建无界数组。加入一条超过字段或行限制的记录，因为攻击者不必重复短行，也能让解析器分配大量内存。

只有在压缩形式很小、源文件可以在测试期间生成时，才将测试样例提交到代码库。能生成确定解码长度的脚本，比神秘的二进制文件更容易审查。在测试名称中记录预期解码大小，不要只把它写在边界失败时没人会看到的注释里。

## 故障通常从一个有用的便捷方法开始

设想一个代理要调用 API 获取问题数据。这个端点平时返回一小页 JSON。客户端发送 `Accept-Encoding: gzip`，收到一个 Content-Length 为 18 KB 的响应，并把这个标头记录下来，作为结果规模不大的证据。随后 HTTP helper 透明地解压响应体，在解析 JSON 前调用 `ReadAll`。

上游返回了一个异常响应，其中每条记录都带有一个很大的重复 description 字段。18 KB 的传输数据膨胀成远超正常页面的数据。进程先分配字节切片，解析时又创建字符串和映射，之后还要将选定记录序列化给代理。请求的至少一部分时间里，每个阶段都持有一份不同的副本。等到解析后才设置限制，只能在昂贵的工作已经完成后发现问题。

第一次修复通常是在 `ReadAll` 后面加上 `if len(body) > limit`。这个检查能让单元测试变绿，却保留了分配峰值。第二次修复将限制器包在解码 reader 外面，方向是对的，但仍然把前 `limit` 个字节作为备用结果发送给代理。这样代理可能会依据半截响应采取行动，而审计记录也不会显示一次明确的失败。

完整的修复应在解码 reader 处尽早拒绝，丢弃部分内容，记录编码和字节预算，并向调用方返回可分类的错误。如果端点确实需要大型导出，就将这个场景移到明确的下载路径，使用更高且经过批准的限制、由用户选择的目标位置，并且不要自动把文件插入代理上下文。

这个区分很重要，因为报告下载和代理工具查询是两种不同的操作。它们都调用 HTTP 请求，并不意味着风险或预算相同。

## 每个限制都需要负责人和理由

全局默认值只是起点，不是一套适合所有 HTTP 操作的策略。应根据端点预期返回的结果设置解码响应限制。状态检查可能只需要几 KB。分页搜索可以返回规模适中的结构化响应。二进制导出可能需要更大的上限，但应写入文件路径，而不是对话结果。

把限制和设置它的理由写在操作定义旁边。「API 文档说页面大小是 100」不是充分理由，因为单条记录仍可能非常庞大。好的理由应说明预期表示和调用方的用途，例如「解析一个状态对象并返回选定字段」，或「在明确批准后保存用户请求的归档文件」。

不要只根据服务器提供的页面大小参数推导上限。服务器可能忽略参数，代理可能请求范围很广的查询，而单个文本字段就可能占据响应的大部分。在合适的边界分别限制页面大小、查询范围、解码字节数、解析记录数和代理结果字节数。这些控制有意重叠，因为每一项都能拦截不同类型的恶意请求或异常响应。

拒绝响应时，要记录足够的诊断信息：HTTP 方法、主机或操作标识符、可用时的状态码、声明的内容编码、观察到的压缩字节数、解码限制，以及解码器是否已经开始产生输出。默认不要记录被拒绝的响应体。记录它可能会在所谓的诊断路径中重新制造内存问题和敏感数据问题。

人工批准不会让响应变得无害。批准可能授权了与远程服务执行某项操作，但无法预测该服务会返回多少数据。在批准之后、数据到达代理之前，仍要执行响应边界限制。

## 取消和超时覆盖的是另一类故障

解码大小上限会在解码器产生足够输出后阻止继续膨胀。它无法阻止对端极慢地发送字节，无法阻止解码器在精心构造的流上消耗过多 CPU，也无法处理永不结束的响应。应设置请求截止时间，并确保取消操作会关闭响应体、中断 reader。

在指标和错误中分别记录时间、压缩字节数、解码字节数和解析项目数。如果所有故障都显示为「请求失败」，有人就会通过提高大小限制来解决超时，或通过延长超时来解决解析失败。这些改动会让事故更难诊断，而且经常扩大攻击面。

针对一个发送有效压缩前缀后暂停的端点测试取消行为。客户端应返回超时或取消错误，且不会让 goroutine 一直等待解码器。然后测试一个会快速膨胀到超过解码上限的响应体。即使服务器还会继续发送压缩字节，这条路径也应迅速返回大小错误。

提前拒绝后要谨慎处理连接复用。在许多客户端中，关闭响应体就足以释放资源，但如果客户端没有读完响应的剩余部分，连接可能无法复用。这是可以接受的。正确性和有界资源使用，比从恶意或损坏的响应中再挤出一个 keep-alive 连接更重要。

不要自动重试超大响应。网络临时错误可以在有界策略下触发重试。超过已知限制的响应通常是确定性的。重复请求只会浪费带宽，还可能加重已经在处理超大操作的进程压力。

## 解析限制应位于解码之后，而不是替代解码限制

流式 JSON 解析器可以避免存储每一条记录，但它不能消除解码字节上限。解析器仍然需要缓冲区，单个字符串可能非常大，错误报告也可能保留源片段。把字节上限放在解析器前面，然后再根据所接受的数据添加格式限制。

对于 JSON，可以考虑最大嵌套深度、最大字符串长度、最大记录数，以及代理将使用字段的严格 schema。对于 CSV 或按行协议，设置最大行长度和最大行数。对于 XML，禁用外部实体处理，并在库提供相关功能时设置解析器限制。这些是解析规则，而解码响应体上限是传输边界。不要让其中一项冒充另一项。

渲染前先验证。远程服务返回的名为 `instructions`、`command` 或 `message` 的字段，仍然是远程内容。它的大小可能没有超过解码上限，但仍不适合放进代理的控制流程。按 schema 选择字段，并将其作为数据进行编码。大小限制只能防止一类故障，它无法证明字节所表达的内容值得信任。

这种分离能让错误处理更清晰。超过解码限制的响应体不应到达解析器。大小合适但记录过多的响应，应返回解析或应用层限制错误。符合 schema 但超过代理展示预算的响应，应按照明确的结果规则进行缩减。每种结果都能告诉运维人员是否需要调整，以及应该调整什么。

## 把审计记录当作边界报告

一条有用的审计记录应说明进程尝试做什么，以及客户端为何拒绝继续。它不需要包含被拒绝的内容。记录操作标识、授权上下文、目标地址、请求时间、已知的响应状态、编码、传输字节数、解码字节数或其下界，以及终止调用的限制。

对于提前拒绝的情况，解码计数可能是 `limit + 1`，而不是实际最终大小，因为客户端有意停止了读取。要如实记录这一点。声称知道完整的解码大小，会让人误以为你已经读取了本该受限制的整个流。下界已经足以解释拒绝决定。

当代理在操作员修改查询后重复发起操作时，这一点尤其有用。你可以看到第一次调用超过了展示预算或解码预算，而第二次使用更窄的请求后成功。没有这个区分，失败的工具调用看起来就和身份验证或网络问题没有差别，人们也会采用错误的修复方式。

Sallyport 的 Activity 轨迹可以保留操作结果路径，而不会把 API 凭据本身交给代理。调用方仍需将超大结果报告为受限制的失败，而不是让 Activity 记录变成响应的第二个副本。

## 把超大响应纳入发布门禁

不要把解压缩检查留作只有有人记得才会运行的安全测试。把 Gzip 和 Brotli 边界样例放入正常的客户端测试套件。通过每一条可能向代理返回数据的 HTTP 代码路径运行相同断言，包括客户端会跟随的重定向，以及为诊断而读取响应体的错误响应。

断言应当具体。对于低于限制的响应，预期获得完整的解码结果，并成功验证校验和。刚好达到限制时，预期结果相同。超过限制一个字节时，预期得到类型明确的大小错误，不生成解析对象，不产生代理结果，并关闭响应体。对于格式错误的编码，预期得到解码错误。对于不支持的编码，预期在解析开始前得到明确的不支持编码错误。

增加一个压缩响应传输体积很小、但解码后超过上限的测试。这正是能发现原始错误的测试。一个大的未压缩响应只能证明普通字节限制器有效，它无法说明解码边界是否正确。

审查引入新 HTTP 库、新便捷获取 helper 或新响应日志路径的改动。这些改动经常绕过经过仔细限制的 reader，因为单独看起来没有风险。代码审查的问题很简单：解码字节第一次可用的位置在哪里？什么限制包住了那个确切的 reader？

如果答案不清楚，实现就还没有准备好交给自主调用方。传输层上看起来很小的响应并没有因此获得特殊信任。让它在解码后、消耗你的内存或代理的注意力之前，证明自己的大小。
