multipart/form-data 是一种由多个独立 part 组成的 HTTP 表示。一条请求可以同时携带文本字段和二进制文件,但每一段仍要有自己的头部和明确的分隔。客户端选择 boundary 参数,把它写入 Content-Type,再在正文中写出匹配的分隔行。边界只负责分框,不负责判断文件是否安全,也不会替应用决定哪些字段可以提交。

边界负责把各段框起来

每条分隔线以两个连字符和声明的边界值开头,结束分隔线还要再加两个连字符。multipart 的 boundary 参数是必需的,长度一般为一到七十个允许字符,末尾不能有空白;必要时参数要用引号包住。RFC 7578 要求分隔符不能出现在任何封装 part 中。使用较长、不可预测的值可以把偶然撞值的概率降得很低,但随机并不等于数学上绝对不相撞,生成器仍要遵守语法。

第一个分隔符之前是 preamble,结束分隔符之后是 epilogue。MIME 读取器会忽略这两处内容,因此应用不能把尾声误当成另一个表单字段;不过它们仍是 HTTP 请求正文的字节,应计入整条请求的预算。multipart 解析器返回 EOF,不代表 HTTP 正文已经全部消费完,服务器还要决定怎样处理剩余内容。

每个 part 都有自己的头

form-data 的每个 part 都需要带有包含 name 参数的 Content-Disposition。文件通常还带 filename 与 Content-Type,但文件名是外部输入,不能直接拼进本地路径,也不能保留其中的目录组件。相同字段名可能出现多次,而且顺序有意义;把它们压成一个 map 可能悄悄改变用户提交的表单。part 头部也不能替代对文件真实字节、扩展名、大小和格式的验证。

请使用实现了 MIME 规则的解析器,不要把整个正文按某个字符串直接 split。解析器需要处理 CRLF、带引号的 boundary、最终分隔线、重复名字、损坏头部和任意二进制字节。每个 part 都设置单独上限,同时限制总正文、part 数量、头部和临时存储;全部验证成功后再写数据库或移动上传文件。流式读取只是在不一次性缓存全文时节省内存,如果没有给读取量和临时落盘量设预算,资源风险仍然存在。

坏掉的样子

浏览器代码的一种常见错误是手动写 Content-Type: multipart/form-data。FormData 已经在正文里生成了边界,但头部没有对应参数,服务器便无法可靠解析。另一个错误是从示例复制一个固定边界,而上传文件恰好包含符合分隔符语法的同样一行;解析器会误认为遇到了新 part,文件因此被截断。缺少关闭分隔线也必须拒绝,不能把前缀当作完整上传。

很多小 part 叠在一起,同样可以消耗大量内存;超长文件名、不支持的 Content-Transfer-Encoding、错误的换行和超大的 epilogue 也会绕过只看单个 part 的检查。不要因为 NextRawPart 成功返回,就认为整条消息已经合法。解析失败发生在副作用之前,拒绝响应中只记录计数与大小,不要把文件正文、Cookie 或认证材料写进日志。客户端上传限制、边缘代理限制和应用解析限制也要明确谁负责哪一层。

若解析器已经把前几个 part 写进临时目录,后一个 part 才发现边界损坏,返回 400 并不会自动删除前面的文件。临时路径应纳入取消和失败清理流程,只有全部验证成功后才移动到最终位置。边界解析正确,只说明找到了各个 part,不能证明内容适合发布;文件类型验证与存储提交仍是独立的检查。

快速验证

历史回环集成示例保持原样,本次没有重跑;当前不能创建监听器。仅编译不能证明 HTTP 行为。

把下面程序保存为 multipart_probe.go,用 Go 1.23 或更新版本运行 go run multipart_probe.go。回环服务会构造带引号的 boundary、重复字段、二进制字节、尾声、总量上限和 part 上限;提交计数只是模拟,不会写数据库或磁盘。实验要求字段名称非空,拒绝 Content-Transfer-Encoding,并使用 NextRawPart,避免读取器隐式进行 quoted-printable 解码。

package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"reflect"
	"runtime"
	"sync/atomic"
	"time"
)

const boundary = "probe:2026"
const totalLimit int64 = 2048
const partLimit int64 = 32
const maxParts = 3

type item struct {
	Name, Filename string
	Body           []byte
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func build(items []item) ([]byte, string) {
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	must(writer.SetBoundary(boundary)) // Deterministic fixture, not a production boundary policy.
	for _, entry := range items {
		var part io.Writer
		var err error
		if entry.Filename == "" {
			part, err = writer.CreateFormField(entry.Name)
		} else {
			part, err = writer.CreateFormFile(entry.Name, entry.Filename)
		}
		must(err)
		_, err = part.Write(entry.Body)
		must(err)
	}
	must(writer.Close())
	return body.Bytes(), writer.FormDataContentType()
}

func main() {
	want := []item{
		{Name: "tag", Body: []byte("first")},
		{Name: "tag", Body: []byte("second")},
		{Name: "upload", Filename: "sample.bin", Body: append([]byte("inline--probe:2026-marker"), 0, 255)},
	}
	valid, contentType := build(want)
	check(contentType == "multipart/form-data; boundary=\"probe:2026\"", "boundary must be quoted")
	var committed, handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		defer r.Body.Close()
		if r.Method != http.MethodPost {
			w.Header().Set("Allow", "POST")
			http.Error(w, "POST required", 405)
			return
		}
		media, parameters, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
		if err != nil || (media == "multipart/form-data" && parameters["boundary"] == "") {
			http.Error(w, "invalid multipart declaration", 400)
			return
		}
		if media != "multipart/form-data" {
			http.Error(w, "multipart/form-data required", 415)
			return
		}
		fail := func(err error) {
			var oversized *http.MaxBytesError
			if errors.As(err, &oversized) {
				http.Error(w, "total body limit", 413)
			} else {
				http.Error(w, "invalid multipart body", 400)
			}
		}
		limited := http.MaxBytesReader(w, r.Body, totalLimit)
		reader := multipart.NewReader(limited, parameters["boundary"])
		var received []item
		for {
			part, err := reader.NextRawPart()
			if err == io.EOF {
				break
			}
			if err != nil {
				fail(err)
				return
			}
			if len(received) == maxParts {
				http.Error(w, "part count limit", 413)
				return
			}
			if part.FormName() == "" || part.Header.Get("Content-Transfer-Encoding") != "" {
				http.Error(w, "named unencoded form part required", 400)
				return
			}
			data, err := io.ReadAll(io.LimitReader(part, partLimit+1))
			if err != nil {
				fail(err)
				return
			}
			if int64(len(data)) > partLimit {
				http.Error(w, "part size limit", 413)
				return
			}
			must(part.Close())
			received = append(received, item{part.FormName(), part.FileName(), data})
		}
		// Multipart EOF need not be HTTP-body EOF: epilogue bytes still count.
		if _, err := io.Copy(io.Discard, limited); err != nil {
			fail(err)
			return
		}
		committed.Add(1) // Simulated acceptance; no files or database writes.
		w.Header().Set("Content-Type", "application/json")
		must(json.NewEncoder(w).Encode(received))
	}))
	server.Config.ReadHeaderTimeout = time.Second
	server.Config.ReadTimeout = 2 * 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, media string, payload []byte, status int) {
		before := committed.Load()
		response, err := client.Post(server.URL, media, bytes.NewReader(payload))
		must(err)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.Proto == "HTTP/1.1" && response.StatusCode == status, label+": wrong status/protocol")
		delta := int64(0)
		if status == 200 {
			var received []item
			must(json.Unmarshal(body, &received))
			check(reflect.DeepEqual(received, want), label+": names, order, or binary bytes changed")
			delta = 1
		}
		check(committed.Load() == before+delta, label+": rejected body was accepted")
		fmt.Printf("%s: status=%d accepted=%d\n", label, status, committed.Load())
	}
	truncated := bytes.TrimSuffix(valid, []byte("\r\n--"+boundary+"--\r\n"))
	check(len(truncated) < len(valid), "fixture must remove the closing delimiter")
	many, _ := build(append(append([]item(nil), want...), item{Name: "extra", Body: []byte("x")}))
	large := append([]item(nil), want...)
	large[2].Body = bytes.Repeat([]byte("x"), int(partLimit+1))
	largePart, _ := build(large)
	withEpilogue := func(extra []byte) []byte { return append(append([]byte(nil), valid...), extra...) }
	probe("valid quoted boundary", contentType, valid, 200)
	probe("small epilogue", contentType, withEpilogue([]byte("ignored epilogue")), 200)
	probe("missing boundary", "multipart/form-data", valid, 400)
	probe("wrong boundary", "multipart/form-data; boundary=other", valid, 400)
	probe("truncated delimiter", contentType, truncated, 400)
	probe("wrong media type", "application/octet-stream", valid, 415)
	probe("too many parts", contentType, many, 413)
	probe("part too large", contentType, largePart, 413)
	probe("total with epilogue", contentType, withEpilogue(bytes.Repeat([]byte("x"), int(totalLimit))), 413)
	probe("recovery", contentType, valid, 200)
	check(handled.Load() == 10 && committed.Load() == 3, "ten requests must yield three acceptances")
	fmt.Println(runtime.Version())
}

十次请求中只有合法正文、带少量尾声的合法正文和最后恢复请求被接受,共三次提交。第一条结果必须保留两个同名 tag 的顺序,并保留上传内容中的零字节与 255 字节;尾声被解析器忽略,却仍属于 HTTP 正文。缺少或写错 boundary、缺少结束分隔线、错误媒体类型、part 数超限、单 part 超限和总量超限都不会增加提交计数。最后一次成功说明拒绝后服务仍能接受下一条合法请求。macOS、Go 1.27.0 下全部断言、race 和 vet 检查通过;浏览器 FormData、磁盘清理、损坏 part 头部、边界冲突注入和生产负载没有受检,不能据此宣称这些边界已经安全。

延伸阅读

来源

  • RFC 7578:multipart/form-data,boundary 语法、字段名、文件名和顺序,已核验 2026-09-09。
  • RFC 2046:媒体类型,分隔符冲突、长度、preamble 与 epilogue 规则,已核验 2026-09-09。
  • Go mime/multipart,流式读取、原始 part 和 writer 边界,已核验 2026-09-09。
  • Go mime,媒体类型参数解析与 boundary 提取,已核验 2026-09-09。
  • Go net/http,请求总量限制、超时和请求生命周期,已核验 2026-09-09。
  • MDN:FormData,浏览器自动设置 boundary 的行为,已核验 2026-09-09。