mirror of
https://github.com/gin-gonic/gin.git
synced 2026-09-04 22:53:34 +08:00
#4759 introducing a general body limit to avoid big heap allocations
This commit is contained in:
parent
34dac209ff
commit
a5d8811fc8
122
body_limit_benchmark_test.go
Normal file
122
body_limit_benchmark_test.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
279
body_limit_test.go
Normal file
279
body_limit_test.go
Normal file
@ -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 := `<root><foo>` + strings.Repeat("x", 100) + `</foo></root>`
|
||||
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)
|
||||
}
|
||||
15
context.go
15
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)
|
||||
|
||||
17
gin.go
17
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);",
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user