请求体限制约束的是实际接受的输入,而不只是 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 结构,没有验证反向代理,也没有证明数据库事务持久性。这些内容必须通过各自的测试补充,不能从计数器的正确变化直接外推。

延伸阅读

来源