From ad91108a5bedc6a5ee810dbda7387aa1143f542c Mon Sep 17 00:00:00 2001 From: Maximilian Pfeffer Date: Mon, 27 Jul 2026 22:16:33 +0200 Subject: [PATCH 1/2] #4759 introducing a general body limit to avoid big heap allocations --- context.go | 15 +++++++++++++++ gin.go | 17 +++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/context.go b/context.go index 1dc730e3..3c5f6c04 100644 --- a/context.go +++ b/context.go @@ -940,6 +940,7 @@ func (c *Context) ShouldBindUri(obj any) error { // ShouldBindWith binds the passed struct pointer using the specified binding engine. // See the binding package. func (c *Context) ShouldBindWith(obj any, b binding.Binding) error { + c.limitRequestBody() return b.Bind(c.Request, obj) } @@ -949,6 +950,7 @@ func (c *Context) ShouldBindWith(obj any, b binding.Binding) error { // NOTE: This method reads the body before binding. So you should use // ShouldBindWith for better performance if you need to call only once. func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error) { + c.limitRequestBody() var body []byte if cb, ok := c.Get(BodyBytesKey); ok { if cbb, ok := cb.([]byte); ok { @@ -965,6 +967,19 @@ func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error return bb.BindBody(body, obj) } +// limitRequestBody wraps c.Request.Body in place so that reading more than +// c.engine.MaxRequestBodyBytes from it fails with *http.MaxBytesError, which +// MustBindWith already maps to a 413 response. +func (c *Context) limitRequestBody() { + if c.engine == nil || c.engine.MaxRequestBodyBytes <= 0 { + return + } + if c.Request == nil || c.Request.Body == nil { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, c.engine.MaxRequestBodyBytes) +} + // ShouldBindBodyWithJSON is a shortcut for c.ShouldBindBodyWith(obj, binding.JSON). func (c *Context) ShouldBindBodyWithJSON(obj any) error { return c.ShouldBindBodyWith(obj, binding.JSON) diff --git a/gin.go b/gin.go index 2e033bf3..b0922b9a 100644 --- a/gin.go +++ b/gin.go @@ -23,10 +23,11 @@ import ( ) const ( - defaultMultipartMemory = 32 << 20 // 32 MB - escapedColon = "\\:" - colon = ":" - backslash = "\\" + defaultMultipartMemory = 32 << 20 // 32 MB + defaultMaxRequestBodyBytes = 128 << 20 // 128 MiB + escapedColon = "\\:" + colon = ":" + backslash = "\\" ) var ( @@ -166,6 +167,13 @@ type Engine struct { // method call. MaxMultipartMemory int64 + // MaxRequestBodyBytes limits how many bytes Context's Bind*/ShouldBind*/ + // ShouldBindBodyWith* methods will read from a request body before failing + // with a 413. It protects against a client sending an oversized or + // malformed body that would otherwise be buffered into memory in full + // before being rejected. Set to <= 0 to disable and read bodies unbounded. + MaxRequestBodyBytes int64 + // UseH2C enable h2c support. UseH2C bool @@ -219,6 +227,7 @@ func New(opts ...OptionFunc) *Engine { RemoveExtraSlash: false, UnescapePathValues: true, MaxMultipartMemory: defaultMultipartMemory, + MaxRequestBodyBytes: defaultMaxRequestBodyBytes, trees: make(methodTrees, 0, 9), delims: render.Delims{Left: "{{", Right: "}}"}, secureJSONPrefix: "while(1);", From 613059e6ab97114f5add447b7ae0b377cc9d79f3 Mon Sep 17 00:00:00 2001 From: Maximilian Pfeffer Date: Mon, 27 Jul 2026 23:31:27 +0200 Subject: [PATCH 2/2] #4759 added test cases. --- body_limit_benchmark_test.go | 122 +++++++++++++++ body_limit_test.go | 279 +++++++++++++++++++++++++++++++++++ gin_test.go | 1 + 3 files changed, 402 insertions(+) create mode 100644 body_limit_benchmark_test.go create mode 100644 body_limit_test.go diff --git a/body_limit_benchmark_test.go b/body_limit_benchmark_test.go new file mode 100644 index 00000000..850fec4d --- /dev/null +++ b/body_limit_benchmark_test.go @@ -0,0 +1,122 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package gin + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin/binding" +) + +// zeroReader is an unbounded source of zero bytes. It models an attacker +// streaming an arbitrarily large request body without needing to +// materialize a large byte slice in the benchmark itself. +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +} + +func BenchmarkBindJSONSmallBody_LimitEnabled(b *testing.B) { + router := New() + router.MaxRequestBodyBytes = defaultMaxRequestBodyBytes + body := `{"foo":"bar","bar":"foo"}` + c := CreateTestContextOnly(httptest.NewRecorder(), router) + + b.ReportAllocs() + for b.Loop() { + c.reset() + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + var obj struct { + Foo string `json:"foo"` + Bar string `json:"bar"` + } + if err := c.BindJSON(&obj); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkBindJSONSmallBody_LimitDisabled(b *testing.B) { + router := New() + router.MaxRequestBodyBytes = 0 + body := `{"foo":"bar","bar":"foo"}` + c := CreateTestContextOnly(httptest.NewRecorder(), router) + + b.ReportAllocs() + for b.Loop() { + c.reset() + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + var obj struct { + Foo string `json:"foo"` + Bar string `json:"bar"` + } + if err := c.BindJSON(&obj); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkBindJSONLargeBody_LimitEnabled(b *testing.B) { + router := New() + router.MaxRequestBodyBytes = defaultMaxRequestBodyBytes + body := `{"foo":"` + strings.Repeat("a", 1<<20) + `"}` // ~1 MiB value, under the limit + c := CreateTestContextOnly(httptest.NewRecorder(), router) + + b.ReportAllocs() + for b.Loop() { + c.reset() + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + var obj struct { + Foo string `json:"foo"` + } + if err := c.BindJSON(&obj); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkBindRejectOversizedBody is the key regression benchmark: it +// proves rejection cost (and allocations) stay bounded by MaxRequestBodyBytes +// regardless of how much more data the client could have sent, instead of +// scaling with attacker-supplied body size as it did before this fix. +func BenchmarkBindRejectOversizedBody(b *testing.B) { + router := New() + router.MaxRequestBodyBytes = 4 << 10 // 4 KiB + c := CreateTestContextOnly(httptest.NewRecorder(), router) + + b.ReportAllocs() + for b.Loop() { + c.reset() + c.Request, _ = http.NewRequest(http.MethodPost, "/", io.NopCloser(zeroReader{})) + var s string + if err := c.ShouldBindWith(&s, binding.Plain); err == nil { + b.Fatal("expected the oversized body to be rejected") + } + } +} + +// BenchmarkLimitRequestBody isolates the per-request cost of the wrapper +// itself (no read), added to the top of every ShouldBindWith/ShouldBindBodyWith +// call regardless of body size. +func BenchmarkLimitRequestBody(b *testing.B) { + router := New() + router.MaxRequestBodyBytes = defaultMaxRequestBodyBytes + c := CreateTestContextOnly(httptest.NewRecorder(), router) + + b.ReportAllocs() + for b.Loop() { + c.reset() + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("x")) + c.limitRequestBody() + } +} diff --git a/body_limit_test.go b/body_limit_test.go new file mode 100644 index 00000000..ea0d2589 --- /dev/null +++ b/body_limit_test.go @@ -0,0 +1,279 @@ +// Copyright 2026 Gin Core Team. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package gin + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin/binding" + "github.com/gin-gonic/gin/codec/json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// expectedTooLargeStatus mirrors the go-json caveat documented in +// TestContextBindRequestTooLarge: go-json does not propagate +// *http.MaxBytesError, so the response falls back to a generic 400. +func expectedTooLargeStatus() int { + if json.Package == "github.com/goccy/go-json" { + return http.StatusBadRequest + } + return http.StatusRequestEntityTooLarge +} + +// --- unit-level: limitRequestBody branch coverage --- + +func TestLimitRequestBodyNilEngine(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader("hello")) + c := &Context{Request: req} + body := c.Request.Body + + assert.NotPanics(t, func() { c.limitRequestBody() }) + assert.Equal(t, body, c.Request.Body, "body must be untouched when c.engine is nil") +} + +func TestLimitRequestBodyDisabled(t *testing.T) { + for _, limit := range []int64{0, -1, -100} { + t.Run("", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = limit + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("hello")) + body := c.Request.Body + + c.limitRequestBody() + + assert.Equal(t, body, c.Request.Body, "body must be untouched when the limit is disabled") + }) + } +} + +func TestLimitRequestBodyNilRequest(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request = nil + + assert.NotPanics(t, func() { c.limitRequestBody() }) +} + +func TestLimitRequestBodyNilBody(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.Request, _ = http.NewRequest(http.MethodPost, "/", nil) + require.Nil(t, c.Request.Body) + + assert.NotPanics(t, func() { c.limitRequestBody() }) + assert.Nil(t, c.Request.Body) +} + +func TestLimitRequestBodyWraps(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 4 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader("hello world")) + + c.limitRequestBody() + + _, err := io.ReadAll(c.Request.Body) + var maxBytesErr *http.MaxBytesError + require.ErrorAs(t, err, &maxBytesErr) + assert.Equal(t, int64(4), maxBytesErr.Limit) +} + +// --- integration: ShouldBindWith / Bind / MustBindWith --- + +func TestBindWithinBodyLimit(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 1024 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar"}`)) + + var obj struct { + Foo string `json:"foo"` + } + require.NoError(t, c.BindJSON(&obj)) + assert.Equal(t, "bar", obj.Foo) + assert.False(t, c.IsAborted()) +} + +func TestBindExceedsBodyLimit(t *testing.T) { + w := httptest.NewRecorder() + c, _ := CreateTestContext(w) + c.engine.MaxRequestBodyBytes = 10 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar", "bar":"foo"}`)) + + var obj struct { + Foo string `json:"foo"` + Bar string `json:"bar"` + } + err := c.BindJSON(&obj) + require.Error(t, err) + c.Writer.WriteHeaderNow() + + if json.Package != "github.com/goccy/go-json" { + var maxBytesErr *http.MaxBytesError + require.ErrorAs(t, err, &maxBytesErr) + } + assert.Empty(t, obj.Foo) + assert.Empty(t, obj.Bar) + assert.Equal(t, expectedTooLargeStatus(), w.Code) + assert.True(t, c.IsAborted()) +} + +func TestBindBodyLimitBoundary(t *testing.T) { + tests := []struct { + name string + bodyLen int + limit int64 + wantErr bool + }{ + {"at limit succeeds", 10, 10, false}, + {"over limit by one fails", 11, 10, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = tt.limit + body := strings.Repeat("a", tt.bodyLen) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + + var s string + err := c.ShouldBindWith(&s, binding.Plain) + + if tt.wantErr { + require.Error(t, err) + var maxBytesErr *http.MaxBytesError + require.ErrorAs(t, err, &maxBytesErr) + } else { + require.NoError(t, err) + assert.Equal(t, body, s) + } + }) + } +} + +func TestBindBodyLimitDisabled(t *testing.T) { + for _, limit := range []int64{0, -1} { + t.Run("", func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = limit + body := strings.Repeat("a", 5000) // larger than any limit used elsewhere in this file + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + + var s string + require.NoError(t, c.ShouldBindWith(&s, binding.Plain)) + assert.Equal(t, body, s) + }) + } +} + +func TestBindBodyLimitAcrossFormats(t *testing.T) { + type obj struct { + Foo string `json:"foo" xml:"foo" bson:"foo"` + } + + oversizedJSON := `{"foo":"` + strings.Repeat("x", 100) + `"}` + oversizedXML := `` + strings.Repeat("x", 100) + `` + bsonBody, err := bson.Marshal(&obj{Foo: strings.Repeat("x", 100)}) + require.NoError(t, err) + oversizedPlain := strings.Repeat("x", 100) + + tests := []struct { + name string + b binding.Binding + body string + target func() any + }{ + {"json", binding.JSON, oversizedJSON, func() any { return &obj{} }}, + {"xml", binding.XML, oversizedXML, func() any { return &obj{} }}, + {"bson", binding.BSON, string(bsonBody), func() any { return &obj{} }}, + {"plain", binding.Plain, oversizedPlain, func() any { var s string; return &s }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 10 // well under every body above + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(tt.body)) + + err := c.ShouldBindWith(tt.target(), tt.b) + require.Error(t, err) + var maxBytesErr *http.MaxBytesError + require.ErrorAs(t, err, &maxBytesErr) + }) + } +} + +func TestBindNonBodyBindingsUnaffectedByLimit(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 1 // tiny enough to reject almost any body + c.Request, _ = http.NewRequest(http.MethodPost, "/?foo=bar", nil) + c.Request.Header.Add("rate", "8000") + + var q struct { + Foo string `form:"foo"` + } + require.NoError(t, c.BindQuery(&q)) + assert.Equal(t, "bar", q.Foo) + + var h struct { + Rate int `header:"rate"` + } + require.NoError(t, c.ShouldBindHeader(&h)) + assert.Equal(t, 8000, h.Rate) +} + +// --- integration: ShouldBindBodyWith --- + +func TestShouldBindBodyWithExceedsBodyLimit(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 5 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar"}`)) + + var obj struct { + Foo string `json:"foo"` + } + err := c.ShouldBindBodyWith(&obj, binding.JSON) + require.Error(t, err) + var maxBytesErr *http.MaxBytesError + require.ErrorAs(t, err, &maxBytesErr) +} + +func TestShouldBindBodyWithWithinBodyLimit(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 1024 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar"}`)) + + var obj struct { + Foo string `json:"foo"` + } + require.NoError(t, c.ShouldBindBodyWith(&obj, binding.JSON)) + assert.Equal(t, "bar", obj.Foo) + + cached, ok := c.Get(BodyBytesKey) + require.True(t, ok) + assert.Equal(t, []byte(`{"foo":"bar"}`), cached) +} + +func TestShouldBindBodyWithCachedReadBypassesReWrap(t *testing.T) { + c, _ := CreateTestContext(httptest.NewRecorder()) + c.engine.MaxRequestBodyBytes = 1024 + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"foo":"bar"}`)) + + var obj1 struct { + Foo string `json:"foo"` + } + require.NoError(t, c.ShouldBindBodyWith(&obj1, binding.JSON)) + assert.Equal(t, "bar", obj1.Foo) + + // Second call must succeed from the BodyBytesKey cache even though + // c.Request.Body has already been drained and re-wrapped by the first + // call's limitRequestBody(). + var obj2 struct { + Foo string `json:"foo"` + } + require.NoError(t, c.ShouldBindBodyWith(&obj2, binding.JSON)) + assert.Equal(t, "bar", obj2.Foo) +} diff --git a/gin_test.go b/gin_test.go index a9cf1755..7942a0e8 100644 --- a/gin_test.go +++ b/gin_test.go @@ -216,6 +216,7 @@ func TestCreateEngine(t *testing.T) { assert.Equal(t, "/", router.basePath) assert.Equal(t, router.engine, router) assert.Empty(t, router.Handlers) + assert.Equal(t, int64(defaultMaxRequestBodyBytes), router.MaxRequestBodyBytes) } func TestLoadHTMLFilesTestMode(t *testing.T) {