From 5551f752513da80d83a08a6ea711a08ed005d2a9 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Fri, 2 Oct 2026 13:40:00 +0200 Subject: [PATCH] Say at the receiver route where its 1 MB body cap lives (closes #173) The receiver route /h/{uuid} has no MaxBodySize middleware, unlike the page route groups; its 1 MB cap is enforced in the handler, which owns the response senders see. Nothing at the route said so, so a reader could take the receiver for uncapped or remove the only bound on the one unauthenticated endpoint. The route registration and the handler's body-reading function now say where the cap lives and why. A routing test sends a body of exactly 1 MB and one a byte over through the production router and pins that only the second is refused, with the handler's 413 and message. Model: opus-5-5 --- internal/handlers/webhook.go | 4 ++- internal/server/routes.go | 6 +++++ internal/server/routes_test.go | 47 ++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) 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 6624438..24c63d0 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -324,6 +324,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 b384582..1d12f11 100644 --- a/internal/server/routes_test.go +++ b/internal/server/routes_test.go @@ -1428,6 +1428,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(