diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go index 4822013..7bf7660 100644 --- a/internal/handlers/webhook.go +++ b/internal/handlers/webhook.go @@ -150,7 +150,9 @@ func (h *Handlers) lookupEntrypoint( return entrypoint, true } -// readWebhookBody reads and validates the request body size. +// readWebhookBody reads and validates the request body size. This is +// the receiver's only body cap: /h/{uuid} has no MaxBodySize +// middleware (see Server.setupWebhookRoutes). func (h *Handlers) readWebhookBody( w http.ResponseWriter, r *http.Request, diff --git a/internal/server/routes.go b/internal/server/routes.go index 85dbb59..1b5c722 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -304,6 +304,12 @@ func (s *Server) setupSourceRoutes() { } func (s *Server) setupWebhookRoutes() { + // No MaxBodySize here, unlike the page groups. The receiver's 1 MB + // body cap is in Handlers.readWebhookBody, because the handler + // owns the response a sender gets for an oversized body and + // MaxBodySize would change it. That cap is the only bound on this + // unauthenticated endpoint's body; TestReceiver_OversizeBodyRefused + // pins it. s.router.With(s.mw.ReceiverRateLimit()).HandleFunc( "/h/{uuid}", s.h.HandleWebhook(), diff --git a/internal/server/routes_test.go b/internal/server/routes_test.go index f25eb86..a640307 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1422,6 +1422,53 @@ func TestReceiver_EntrypointURLIsRateLimited(t *testing.T) { ) } +// TestReceiver_OversizeBodyRefused pins the receiver's 1 MB body +// cap, which lives in the handler rather than in a MaxBodySize +// middleware. A body exactly at the cap is accepted; one byte over is +// refused with the handler's own 413. +func TestReceiver_OversizeBodyRefused(t *testing.T) { + t.Parallel() + + const bodyCap = 1 << 20 // 1 MB + + env := newTestEnvWithConfig(t, &config.Config{ + DataDir: t.TempDir(), + Environment: config.EnvironmentDev, + ReceiverRateLimit: 10, + }) + + userID, _ := env.seedUser(t, "receiver", "somepassword") + wh := env.seedWebhook(t, userID) + require.NoError(t, env.db.DB().Omit(clause.Associations).Create( + &database.Entrypoint{ + WebhookID: wh.ID, + Path: "0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35", + Active: true, + }, + ).Error) + + send := func(size int) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, + "/h/0b7c3e5a-2d9f-4a61-8e4b-7c1d6f2a9e35", + strings.NewReader(strings.Repeat("a", size)), + ) + w := httptest.NewRecorder() + env.router.ServeHTTP(w, req) + + return w + } + + assert.Equal( + t, http.StatusOK, send(bodyCap).Code, + "a body exactly at the cap must be accepted", + ) + + w := send(bodyCap + 1) + assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code) + assert.Equal(t, "Request body too large\n", w.Body.String()) +} + // metricsConfig is a Config differing from the routing default only // in the two /metrics credentials. func metricsConfig(