diff --git a/api/api.go b/api/api.go index e0dc4888..88f3bf2a 100644 --- a/api/api.go +++ b/api/api.go @@ -89,6 +89,7 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati r := newRouter() r.UseBypass(middleware.RealIP) + r.UseBypass(limitBodySize(defaultMaxBodySize)) // Inject base context values into each request (replaces chi.ServerBaseContext from chi v4) r.UseBypass(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { diff --git a/api/middleware.go b/api/middleware.go index 55d53be6..7573cfbd 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -18,8 +18,27 @@ import ( const ( jwsSignatureHeaderName = "x-nf-sign" + + // defaultMaxBodySize caps the number of bytes the server will read from a + // request body. It is enforced by limitBodySize and exists to prevent a + // client from holding memory open with a multi-gigabyte upload. + defaultMaxBodySize int64 = 1 << 20 // 1 MiB ) +// limitBodySize wraps each request body in http.MaxBytesReader so that any +// reader (json.NewDecoder, io.ReadAll, etc.) returns an error once the limit is +// reached instead of buffering the entire body in memory. +func limitBodySize(maxSize int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil && r.Body != http.NoBody { + r.Body = http.MaxBytesReader(w, r.Body, maxSize) + } + next.ServeHTTP(w, r) + }) + } +} + type FunctionHooks map[string][]string type NetlifyMicroserviceClaims struct { diff --git a/api/middleware_bodysize_test.go b/api/middleware_bodysize_test.go new file mode 100644 index 00000000..e58751cd --- /dev/null +++ b/api/middleware_bodysize_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "bytes" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLimitBodySize(t *testing.T) { + const limit int64 = 16 + + tests := []struct { + name string + body string + wantErr bool + wantRead string + }{ + {"under limit reads fully", "small body", false, "small body"}, + {"at limit reads fully", strings.Repeat("a", int(limit)), false, strings.Repeat("a", int(limit))}, + {"over limit errors", strings.Repeat("a", int(limit)+1), true, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + readErr error + readBody []byte + ) + + handler := limitBodySize(limit)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + readBody, readErr = io.ReadAll(r.Body) + })) + + req := httptest.NewRequest("POST", "/", bytes.NewBufferString(tt.body)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + + if tt.wantErr { + require.Error(t, readErr) + var mbe *http.MaxBytesError + assert.True(t, errors.As(readErr, &mbe), "expected *http.MaxBytesError, got %T", readErr) + return + } + require.NoError(t, readErr) + assert.Equal(t, tt.wantRead, string(readBody)) + }) + } +} + +func TestLimitBodySizeNilBodyIsSafe(t *testing.T) { + handler := limitBodySize(1024)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + assert.Equal(t, http.StatusNoContent, w.Code) +}