原创

Go HTTP 严格读取 JSON:请求大小、未知字段与尾随值

  • 发布时间:2026-10-23 21:13:57
  • 本文热度:浏览 2 赞 0 评论 0
  • 全文共1字,阅读约需1分钟

把请求体交给 json.NewDecoder(r.Body).Decode(&input),只能说明解出了一个 JSON 值。它不代表整个请求体只有这一个值,不限制总字节数,也不会默认拒绝结构体不认识的字段。

这三个边界缺一个,接口都可能“看起来校验过”:超大正文占用读取和解析资源;字段拼错以后被静默忽略;合法对象后面再跟一个对象,第一次 Decode 仍可能成功。应该把它们分别表达出来,别指望一次解码顺便完成整套输入协议。

下面用 Go 1.24.1 标准库搭一个本地预览接口。它不写数据库,只接收标题和份数,返回规范化结果。完整例子包含 HTTP 方法、媒体类型、请求大小、JSON 文档数量及业务字段验证,并用 httptest 覆盖错误路径。

入口先确定自己接受什么

接口约定为 POST /preview,正文类型为 application/json,最大 512 字节。根值必须是对象;title 去掉首尾空白以后为 1 到 80 个码点,copies 必须明确给出,范围 0 到 100。

份数使用指针,而非普通 int。这样可以把“合法的 0”与“没有给出”分开。在这份协议里,缺失和 null 都视为未提供,因此两者不再额外区分。如果需要表达“清除已有值”,就要设计包含字段存在性的类型,不能再拿一根指针承载三种状态。

新建目录,保存 go.mod:

module example.invalid/json-entry

go 1.24.1

保存 main.go:

package main

import (
    "encoding/json"
    "errors"
    "io"
    "log"
    "mime"
    "net/http"
    "strings"
    "time"
    "unicode/utf8"
)

const maxBody int64 = 512

type PreviewInput struct {
    Title  string `json:"title"`
    Copies *int   `json:"copies"`
}

func problem(w http.ResponseWriter, status int, message string) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.WriteHeader(status)
    _ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

func decodeProblem(w http.ResponseWriter, err error) {
    var tooLarge *http.MaxBytesError
    if errors.As(err, &tooLarge) {
        problem(w, http.StatusRequestEntityTooLarge, "request body too large")
        return
    }
    problem(w, http.StatusBadRequest, "invalid JSON document")
}

func preview(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        w.Header().Set("Allow", http.MethodPost)
        problem(w, http.StatusMethodNotAllowed, "POST required")
        return
    }
    mediaType, parameters, err := mime.ParseMediaType(
        r.Header.Get("Content-Type"),
    )
    charset := parameters["charset"]
    if err != nil || mediaType != "application/json" ||
        (charset != "" && !strings.EqualFold(charset, "utf-8")) {
        problem(w, http.StatusUnsupportedMediaType, "UTF-8 JSON required")
        return
    }
    encoding := r.Header.Get("Content-Encoding")
    if encoding != "" && !strings.EqualFold(encoding, "identity") {
        problem(w, http.StatusUnsupportedMediaType, "content encoding unsupported")
        return
    }

    r.Body = http.MaxBytesReader(w, r.Body, maxBody)
    defer r.Body.Close()
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()
    var input *PreviewInput
    if err := decoder.Decode(&input); err != nil {
        decodeProblem(w, err)
        return
    }
    var extra any
    if err := decoder.Decode(&extra); err != io.EOF {
        if err == nil {
            problem(w, http.StatusBadRequest, "one JSON value required")
        } else {
            decodeProblem(w, err)
        }
        return
    }
    if input == nil {
        problem(w, http.StatusBadRequest, "JSON object required")
        return
    }
    input.Title = strings.TrimSpace(input.Title)
    length := utf8.RuneCountInString(input.Title)
    if length < 1 || length > 80 || input.Copies == nil ||
        *input.Copies < 0 || *input.Copies > 100 {
        problem(w, http.StatusBadRequest, "invalid title or copies")
        return
    }
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    if err := json.NewEncoder(w).Encode(input); err != nil {
        log.Printf("write response: %v", err)
    }
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/preview", preview)
    server := &http.Server{
        Addr: "127.0.0.1:8088",
        Handler: mux,
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout: 10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

大小限制不能代替超时配置。服务同时设置读取头、整段读取和写出时限,避免只限制体积却允许客户端无限期拖住连接。服务只绑定 loopback,避免教程启动的演示接口直接暴露到其他机器。

第二次 Decode 不是可有可无的多读一次

第一次解出对象后,第二次读取必须得到 io.EOF。尾部只有空白时,解码器消耗空白后会返回 EOF,符合单文档协议;尾部还有另一个合法值,会成功解出 extra,应该拒绝;尾部是乱字符,则产生解码错误,也应拒绝。

这一步还会继续触发大小限制。合法对象之后塞入大量空白,不能仅因为业务字段很短就跳过剩余正文检查。512 字节约束的是实际读取的请求体,不是解码后结构体占用多少内存。

MaxBytesReader 超限返回可识别的 *http.MaxBytesError,便于映射到 413。仅用 io.LimitReader 把正文截断,再把 EOF 当正常结束,会混淆“输入本来结束了”和“读取被你截断了”。而 Content-Length 也不能代替受限 reader:长度可能未知,服务仍需在读取路径控制预算。

遇到明确的结构错误时,例子会提前返回 400,不强求把余下正文读完以后再争一个 413。状态优先级由实际发现的错误决定;业务约定不需要承诺所有畸形超大输入都得到同一个错误码。

把误收的请求写进测试

保存 main_test.go:

package main

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestPreviewBoundaries(t *testing.T) {
    cases := []struct {
        name string
        contentType string
        body string
        want int
    }{
        {"zero is valid", "application/json",
            `{"title":"  note  ","copies":0}`, 200},
        {"unknown field", "application/json",
            `{"title":"note","copies":1,"copise":2}`, 400},
        {"missing copies", "application/json", `{"title":"note"}`, 400},
        {"null root", "application/json", `null`, 400},
        {"second object", "application/json",
            `{"title":"note","copies":1}{"title":"other","copies":2}`, 400},
        {"trailing junk", "application/json",
            `{"title":"note","copies":1}!`, 400},
        {"too large", "application/json",
            `{"title":"` + strings.Repeat("x", 600) + `","copies":1}`, 413},
        {"large whitespace tail", "application/json",
            `{"title":"note","copies":1}` + strings.Repeat(" ", 600), 413},
        {"wrong media", "text/plain", `{"title":"note","copies":1}`, 415},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            request := httptest.NewRequest(http.MethodPost, "/preview",
                strings.NewReader(tc.body))
            request.Header.Set("Content-Type", tc.contentType)
            recorder := httptest.NewRecorder()
            preview(recorder, request)
            if recorder.Code != tc.want {
                t.Fatalf("status=%d want=%d body=%s", recorder.Code,
                    tc.want, recorder.Body.String())
            }
            if tc.want == 200 {
                var result PreviewInput
                if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
                    t.Fatal(err)
                }
                if result.Title != "note" || result.Copies == nil || *result.Copies != 0 {
                    t.Fatalf("unexpected result: %+v", result)
                }
            }
        })
    }
}

运行:

go version
go test -v ./...
go run .

测试应全部通过。服务运行后,从另一个终端提交:

curl -i http://127.0.0.1:8088/preview \
  -H 'Content-Type: application/json' \
  --data '{"title":"  入口校验  ","copies":0}'

响应为 200,标题两边的空白被去掉,copies 保留为 0。把字段名故意拼成 copise 应得到 400;追加第二个对象也应被拒绝。接口没有真正创建记录,因此没有返回 201 或编造业务编号。

字节预算和字段预算,解决的是不同问题

请求体上限包含字段名、引号、空白和 UTF-8 编码后的字节。标题限制则在解码并去除两端空白后计算码点,两个数值没有一对一换算关系。把 512 字节改大,不应自动放宽业务允许的标题长度;把标题缩短,也不会防住携带巨量无用空白的正文。

数值字段同样先经过解码类型约束,再经过业务范围约束。copies 为字符串或小数时,不能解进这里的整数指针;为整数但超过 100 时,则由后面的范围判断拒绝。错误属于哪一层,会影响日志定位,但对外不必原样回显整段解析错误。解析器错误可能夹带字段内容,示例使用固定短消息,避免把输入直接带进响应和日志。

对成功返回也有一个清楚的时点:规范化和验证全部结束以后,才开始写响应。编码输出失败时,响应可能已经提交,不能再追加一段错误 JSON 假装替换刚才的结果。因此这里仅记录写出错误,不再调用 problem()。换成真正创建业务记录的接口以后,还要分别处理写入成功、客户端断开以及重试确认,这些结果不能用一次网络写出成功证明。

这些检查仍然没有定义整个 JSON 世界

DisallowUnknownFields 针对不能匹配结构体字段的键,不是“拒绝所有可疑 JSON”的总开关。Go 这套解码器会接受某些大小写不同的字段名;重复键也没有在这里被拒绝,后面的值可能覆盖前面的值。若签名、权限或审计协议要求键名严格一致且禁止重复,应选择能检查 token 的解析流程,并写清冲突规则,而不是在这个方法名上继续加想象。

此外,encoding/json 对字符串中的无效 UTF-8 会进行替换处理。本文并未实现原始字节级的 UTF-8 严格拒绝,也不把标题长度定义为用户眼中的字形数量:码点数量与组合字符数量并不相同。输入合同需要哪种精度,就要补哪种校验。

512 字节只是这个小接口的示例预算,大文件接口应重新确定大小、读取时限与存储策略。压缩正文也被明确拒绝,避免把压缩前大小与解压后大小混成一个上限。将来支持压缩时,解压后的数据同样需要独立预算。

入口校验最好保持这样的可读性:每一个限制对应一个代码位置,每一种失败对应可运行样本。只要还能指出“字段已经解出来,但是后面尚有未经检查的数据”,一次成功的 Decode 就不该被当成处理完成。

正文到此结束
评论插件初始化中...
Loading...