diff --git a/config-sample.yaml b/config-sample.yaml index 71888d572..1cbf8a9e1 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -385,3 +385,7 @@ keys: # signal_message_size_limit: 2097152 # # same as above, but for agent worker WebSocket connections. defaults to 2 MiB, 0 for no limit # agent_signal_message_size_limit: 2097152 +# # max size (bytes) of an HTTP request body on the main API listener (Twirp room/egress/ingress/SIP +# # routes). requests larger than this are rejected before their body is decoded, so large messages +# # cannot exhaust memory. defaults to 10 MiB, 0 for no limit +# max_api_request_body_size: 10485760 diff --git a/pkg/config/config.go b/pkg/config/config.go index 71de31476..f4490f35c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -345,6 +345,12 @@ type LimitConfig struct { SignalMessageSizeLimit int64 `yaml:"signal_message_size_limit,omitempty"` // same as SignalMessageSizeLimit, but for agent worker WebSocket connections. AgentSignalMessageSizeLimit int64 `yaml:"agent_signal_message_size_limit,omitempty"` + + // maximum size (in bytes) of an HTTP request body accepted on the main API + // listener (Twirp room/egress/ingress/SIP routes, etc). Requests larger than + // this are rejected before their body is decoded, so large messages cannot + // exhaust memory. A value of 0 disables the limit (unbounded). + MaxAPIRequestBodySize int64 `yaml:"max_api_request_body_size,omitempty"` } func (l LimitConfig) CheckRoomNameLength(name string) bool { @@ -551,8 +557,9 @@ var DefaultConfig = Config{ MaxDataBlobKeyLength: 256, MaxDataBlobSize: 64000, MaxDataTrackCustomEncodingLength: 32, - SignalMessageSizeLimit: 2 << 20, // 2 MiB - AgentSignalMessageSizeLimit: 2 << 20, // 2 MiB + SignalMessageSizeLimit: 2 << 20, // 2 MiB + AgentSignalMessageSizeLimit: 2 << 20, // 2 MiB + MaxAPIRequestBodySize: 10 << 20, // 10 MiB }, Logging: LoggingConfig{ PionLevel: "error", diff --git a/pkg/service/requestlimit_test.go b/pkg/service/requestlimit_test.go new file mode 100644 index 000000000..7c7075301 --- /dev/null +++ b/pkg/service/requestlimit_test.go @@ -0,0 +1,135 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" +) + +// readAllHandler mimics the way a Twirp handler consumes the whole request body +// before doing anything else. It records how much it managed to read and whether +// the read failed (e.g. because the body limit was exceeded). +type readAllHandler struct { + bytesRead int + readErr error + called bool +} + +func (h *readAllHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.called = true + if r.Body == nil { + w.WriteHeader(http.StatusOK) + return + } + b, err := io.ReadAll(r.Body) + h.bytesRead = len(b) + h.readErr = err + if err != nil { + // a real decoder surfaces this as a 4xx/5xx; emulate that + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +func TestRequestBodyLimiter(t *testing.T) { + const limit = 1024 + + t.Run("rejects oversized declared Content-Length before decoding", func(t *testing.T) { + l := service.NewRequestBodyLimiter(limit) + handler := &readAllHandler{} + + body := strings.NewReader(strings.Repeat("a", limit*4)) + r := httptest.NewRequest(http.MethodPost, "/twirp/livekit.Egress/StartRoomCompositeEgress", body) + require.EqualValues(t, limit*4, r.ContentLength) + w := httptest.NewRecorder() + + l.ServeHTTP(w, r, handler.ServeHTTP) + + require.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + // the body must never be handed to the decoder + require.False(t, handler.called) + }) + + t.Run("bounds body when Content-Length is absent/dishonest", func(t *testing.T) { + l := service.NewRequestBodyLimiter(limit) + handler := &readAllHandler{} + + body := strings.NewReader(strings.Repeat("a", limit*4)) + r := httptest.NewRequest(http.MethodPost, "/twirp/livekit.Egress/StartRoomCompositeEgress", body) + // simulate chunked encoding / unknown length + r.ContentLength = -1 + w := httptest.NewRecorder() + + l.ServeHTTP(w, r, handler.ServeHTTP) + + // the decoder was invoked but could not read more than the limit + require.True(t, handler.called) + require.Error(t, handler.readErr) + require.LessOrEqual(t, handler.bytesRead, limit) + }) + + t.Run("allows request within limit", func(t *testing.T) { + l := service.NewRequestBodyLimiter(limit) + handler := &readAllHandler{} + + payload := strings.Repeat("a", limit/2) + r := httptest.NewRequest(http.MethodPost, "/twirp/livekit.Egress/StartRoomCompositeEgress", strings.NewReader(payload)) + w := httptest.NewRecorder() + + l.ServeHTTP(w, r, handler.ServeHTTP) + + require.Equal(t, http.StatusOK, w.Code) + require.True(t, handler.called) + require.NoError(t, handler.readErr) + require.Equal(t, len(payload), handler.bytesRead) + }) + + t.Run("disabled when limit is non-positive", func(t *testing.T) { + l := service.NewRequestBodyLimiter(0) + handler := &readAllHandler{} + + payload := strings.Repeat("a", limit*8) + r := httptest.NewRequest(http.MethodPost, "/twirp/livekit.Egress/StartRoomCompositeEgress", strings.NewReader(payload)) + w := httptest.NewRecorder() + + l.ServeHTTP(w, r, handler.ServeHTTP) + + require.Equal(t, http.StatusOK, w.Code) + require.NoError(t, handler.readErr) + require.Equal(t, len(payload), handler.bytesRead) + }) + + t.Run("passes through nil body", func(t *testing.T) { + l := service.NewRequestBodyLimiter(limit) + handler := &readAllHandler{} + + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Body = nil + w := httptest.NewRecorder() + + l.ServeHTTP(w, r, handler.ServeHTTP) + + require.True(t, handler.called) + }) +} diff --git a/pkg/service/server.go b/pkg/service/server.go index 1cf16e45f..e26569804 100644 --- a/pkg/service/server.go +++ b/pkg/service/server.go @@ -111,6 +111,8 @@ func NewLivekitServer(conf *config.Config, MaxAge: 86400, }), negroni.HandlerFunc(RemoveDoubleSlashes), + // limit request body size so large messages cannot exhaust memory + NewRequestBodyLimiter(conf.Limit.MaxAPIRequestBodySize), } if keyProvider != nil { middlewares = append(middlewares, NewAPIKeyAuthMiddleware(keyProvider)) diff --git a/pkg/service/utils.go b/pkg/service/utils.go index be7c0dc99..cc31be859 100644 --- a/pkg/service/utils.go +++ b/pkg/service/utils.go @@ -44,8 +44,9 @@ import ( ) var ( - ErrGzipReadFailed = errors.New("cannot read decompressed data") - ErrGzipTooLarge = errors.New("decompressed data too large") + ErrGzipReadFailed = errors.New("cannot read decompressed data") + ErrGzipTooLarge = errors.New("decompressed data too large") + ErrRequestBodyTooLarge = errors.New("request body too large") ) var gzipReaderPool = sync.Pool{ @@ -106,6 +107,39 @@ func RemoveDoubleSlashes(w http.ResponseWriter, r *http.Request, next http.Handl next(w, r) } +// RequestBodyLimiter bounds the size of an incoming HTTP request body so that +// large messages cannot exhaust memory. The Twirp handlers decode the whole +// body before any grant check runs, so the limit is applied here, up front. +// +// It does not decode the body itself: a request whose Content-Length exceeds +// the limit is rejected with 413, and the body is wrapped with +// http.MaxBytesReader so a missing or dishonest Content-Length is still caught +// by the downstream decoder. +type RequestBodyLimiter struct { + maxBytes int64 +} + +func NewRequestBodyLimiter(maxBytes int64) *RequestBodyLimiter { + return &RequestBodyLimiter{maxBytes: maxBytes} +} + +func (l *RequestBodyLimiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + if l.maxBytes <= 0 || r.Body == nil { + next(w, r) + return + } + + // reject early when the declared size already exceeds the limit + if r.ContentLength > l.maxBytes { + HandleError(w, r, http.StatusRequestEntityTooLarge, ErrRequestBodyTooLarge) + return + } + + // bound the read in case Content-Length is missing or wrong + r.Body = http.MaxBytesReader(w, r.Body, l.maxBytes) + next(w, r) +} + func IsValidDomain(domain string) bool { domainRegexp := regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`) return domainRegexp.MatchString(domain)