请求体限制约束的是实际接受的输入,而不只是 Content-Length 里写出的数字。应在消费正文时执行限制,并在验证完成之前避免业务提交。压缩输入还需要分别限制编码后的字节和解码后的字节。如果昂贵的内存分配或不可逆写入已经发生,最后再返回 413,并没有守住原本要保护的边界。
明确预算到底计算哪些字节
采用长度分帧时,Content-Length 描述消息正文的长度;HTTP/1.1 分块传输则可能没有预先确定的长度。已声明长度过大,可以提前拒绝,但缺少长度不等于允许无限读取。消息分帧交给 HTTP 实现处理,应用再针对它提供的正文读取器施加自己的预算,不能自行混淆报文边界。
正文读取器不包含 HTTP 头和分块编码的分帧开销,因此这里的限制不是整个网络请求的总字节配额。Content-Encoding: gzip 下,这些正文是压缩后的数据,解码表示可能大得多。应在解压之前约束编码输入,在解压之后再次约束输出;一个很小的压缩包,也可能超过合法的解码大小上限。
限制应来自具体端点的契约,包括单位和支持的内容编码。整个请求体的总量限制,与某个 multipart 文件或字段的限制也不同。流式处理避免一次缓存全部内容,却不会自动限制最终存储量、解析工作或慢速发送方占据请求的时间,不能把“使用流”直接等同于“资源已经受控”。
验证完成之后再暴露副作用
Go 的 MaxBytesReader 在读取超过限制时返回 MaxBytesError。普通 LimitReader 则会在边界处返回 EOF;如果没有额外判断,应用可能把被截断的前缀当成完整输入。最多读取“解码上限加一”个字节,才能区分恰好达到上限的合法正文与确实超过上限的正文。
对 gzip 而言,成功读到一个前缀并不代表校验和有效。在接受未超限的表示之前,需要读到结尾;只关闭解码器不会完成校验。文件发布、数据库修改等动作应放在完整验证成功之后,或者采用带明确中止路径的暂存方案。不能忽略读取错误,仅因为读取器同时返回了一些数据就执行提交。
坏掉的样子
只看 Content-Length 会遗漏未知长度输入;只在解压前使用一个大小上限,会遗漏解压膨胀;先解析、后拒绝,则可能已经花掉需要保护的资源。损坏 gzip 流可以在报错时同时返回正文前缀,错误处理若只检查正文非空,就可能把受损数据变成被接受的业务输入。
字节限制不能修复冲突的 HTTP 分帧,不能验证调用方或文件类型,也不能限制所有并发请求的内存总和。它还无法阻止发送方把很小的正文无限拖慢。因此,时间和并发量需要独立预算;不能把字节上限叫作空闲超时,也不能从一次拒绝成功推导出已经具备高负载防护能力。
快速验证
保存为 body_probe.go,使用 Go 1.23 或更新版本运行 go run body_probe.go。环回测试服务允许最多 64 个编码输入字节和八个解码字节,支持无内容编码或单层 gzip。这里刻意使用很小的常量,是为了验证边界,不是推荐生产参数。计数器代表应用提交点,不会真的写入数据库或上传文件。
package main
import (
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"sync/atomic"
"time"
)
const encodedLimit int64 = 64
const decodedLimit int64 = 8
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
var committed, handled atomic.Int64
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handled.Add(1)
defer r.Body.Close()
w.Header().Set("X-Received-Length", fmt.Sprint(r.ContentLength))
fail := func(err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "encoded limit", http.StatusRequestEntityTooLarge)
} else {
http.Error(w, "invalid body", http.StatusBadRequest)
}
}
if r.ContentLength > encodedLimit {
http.Error(w, "encoded limit", http.StatusRequestEntityTooLarge)
return
}
limited := http.MaxBytesReader(w, r.Body, encodedLimit)
var reader io.Reader = limited
coding := strings.TrimSpace(strings.ToLower(strings.Join(r.Header.Values("Content-Encoding"), ",")))
switch coding {
case "":
case "gzip":
decoded, err := gzip.NewReader(limited)
if err != nil {
fail(err)
return
}
defer decoded.Close()
reader = decoded
default:
http.Error(w, "unsupported coding", http.StatusUnsupportedMediaType)
return
}
body, err := io.ReadAll(io.LimitReader(reader, decodedLimit+1))
if err != nil {
fail(err)
return
}
if int64(len(body)) > decodedLimit {
http.Error(w, "decoded limit", http.StatusRequestEntityTooLarge)
return
}
committed.Add(1) // No side effect before the complete body passes validation.
fmt.Fprint(w, "accepted")
}))
server.Config.ReadTimeout = 2 * time.Second
server.Config.ReadHeaderTimeout = time.Second
server.Start()
defer server.Close()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
probe := func(label string, payload []byte, coding string, chunked bool, want int) {
request, err := http.NewRequest(http.MethodPost, server.URL, bytes.NewReader(payload))
must(err)
if chunked {
request.ContentLength = -1
request.TransferEncoding = []string{"chunked"}
}
if coding != "" {
request.Header.Set("Content-Encoding", coding)
}
before := committed.Load()
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.Proto == "HTTP/1.1" && response.StatusCode == want, label+": wrong status/protocol")
wantLength := len(payload)
if chunked {
wantLength = -1
}
check(response.Header.Get("X-Received-Length") == fmt.Sprint(wantLength), "wrong wire framing")
delta := int64(0)
if want == 200 {
delta = 1
check(string(body) == "accepted", "wrong successful body")
}
check(committed.Load() == before+delta, label+": rejected input caused a side effect")
fmt.Printf("%s: status=%d committed=%d\n", label, response.StatusCode, committed.Load())
}
compress := func(value, name string) []byte {
var buffer bytes.Buffer
writer := gzip.NewWriter(&buffer)
writer.Name = name
_, err := writer.Write([]byte(value))
must(err)
must(writer.Close())
return buffer.Bytes()
}
smallGzip := compress("12345678", "")
expansion := compress(strings.Repeat("a", 1024), "")
largeHeader := compress("12345678", strings.Repeat("h", 80))
check(int64(len(expansion)) <= encodedLimit && int64(len(largeHeader)) > encodedLimit, "gzip fixtures must cross different limits")
corrupt := append([]byte(nil), smallGzip...)
corrupt[len(corrupt)-8] ^= 0xff
probe("exact decoded limit", []byte("12345678"), "", false, 200)
probe("known oversized", []byte("123456789"), "", false, 413)
probe("chunked oversized", []byte("123456789"), "", true, 413)
probe("known encoded limit", bytes.Repeat([]byte("x"), 65), "", false, 413)
probe("compressed expansion", expansion, "gzip", false, 413)
probe("exact gzip body", smallGzip, "GZip", false, 200)
probe("chunked gzip header", largeHeader, "gzip", true, 413)
probe("bad gzip checksum", corrupt, "gzip", false, 400)
probe("unsupported coding", []byte("12345678"), "br", false, 415)
probe("stacked coding", smallGzip, "gzip, gzip", false, 415)
probe("recovery", []byte("small"), "", false, 200)
check(handled.Load() == 11 && committed.Load() == 3, "eleven requests must yield three commits")
fmt.Println(runtime.Version())
}
十一次请求依次检查:恰好达标的普通正文、已知长度超限、分块正文超限、声明的编码长度超限、gzip 膨胀、恰好达标的 gzip、大头部且未知长度的 gzip、损坏校验和、不支持的编码、叠加编码以及恢复请求。只有合法普通正文、合法 gzip 和最后的小正文增加提交计数,总计三次。
超限返回 413,损坏校验和返回 400,不支持或叠加的编码返回 415。混合大小写的 GZip 可以接受,因为内容编码名称不区分大小写。服务端还回显实际收到的 ContentLength,未知长度场景必须显示 -1;这样确认的是实际请求分帧,而不是客户端对象里某个配置字段已经被设置。
大 gzip 头部的输入超过编码限制,但解码正文仍然很小;膨胀样本则符合编码限制,却超过解码限制。解码器沿用 Go 默认的拼接成员行为,因此解码预算覆盖多个 gzip 成员;若只允许一个成员,需要另行明确和实现该契约。两者分别验证不同预算,不能把每次 413 都算成同一条分支已经受检。失败场景逐次核对提交计数不变,最后的正常请求则证明拒绝之后服务仍然能够继续接受合法输入。
此前记录的 macOS、Go 1.27.0 运行通过全部断言;本轮没有重跑这个需要套接字的实验。读取与请求均有限时,响应正文均被关闭。实验没有测量内存分配,没有注入慢速客户端,没有解析 multipart 或 JSON 结构,没有验证反向代理,也没有证明数据库事务持久性。这些内容必须通过各自的测试补充,不能从计数器的正确变化直接外推。
延伸阅读
来源
- RFC 9110:HTTP 语义,Content-Length、内容编码和 413、415 语义,已核验 2026-10-15。
- Go net/http,MaxBytesReader、请求正文生命周期、分帧和超时,已核验 2026-10-15。
- Go compress/gzip,解码流与读取到 EOF 时的校验和验证,已核验 2026-10-15。
- OWASP:文件上传安全清单,上传预算、解压后限制和其他验证边界,已核验 2026-10-15。