From 755e9ce6fa53b2981753a6bd668e1a4a7e1acbe0 Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 3 Nov 2026 07:47:35 +0000 Subject: [PATCH] Apply MAX_MESSAGE_SIZE to the whole request body (closes #250) Creating a session, logging in and posting a message stopped reading after the first JSON value, so the limit never saw the rest of the body and trailing bytes were ignored. All three now read the whole limited body and decode it with json.Unmarshal. A body over the limit, or anything but whitespace after the value, gets 400 invalid request body. Model: opus-5-5 --- internal/handlers/api.go | 4 +- internal/handlers/api_test.go | 101 ++++++++++++++++++++++++++++++++++ internal/handlers/auth.go | 3 +- internal/handlers/handlers.go | 13 +++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 3ad7068..0843b31 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -188,7 +188,7 @@ func (hdlr *Handlers) handleCreateSession( var payload createRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, @@ -925,7 +925,7 @@ func (hdlr *Handlers) HandleSendCommand() http.HandlerFunc { var payload commandRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 7ed1aac..305be3f 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -797,6 +797,107 @@ func TestCreateSessionMalformed(t *testing.T) { } } +func (tserver *testServer) postBody( + path, token, body string, +) (int, map[string]any) { + tserver.t.Helper() + + resp, err := doRequestAuth( + tserver.t, http.MethodPost, tserver.url(path), token, + strings.NewReader(body), + ) + if err != nil { + tserver.t.Fatalf("post %s: %v", path, err) + } + + defer func() { _ = resp.Body.Close() }() + + var result map[string]any + + _ = json.NewDecoder(resp.Body).Decode(&result) + + return resp.StatusCode, result +} + +// TestRequestBodyLimit sends each endpoint a small valid JSON value +// followed by other bytes. Only whitespace may follow the value, and +// the whole body counts towards MAX_MESSAGE_SIZE. +func TestRequestBodyLimit(t *testing.T) { + t.Parallel() + + const ( + maxMessageSize = 4096 // the default MAX_MESSAGE_SIZE + nick = "alice_bodylimit" + ) + + endpoints := []struct { + name, path, value string + acceptedStatus int + }{ + { + "create session", apiSession, + `{"nick":"bob_bodylimit"}`, + http.StatusCreated, + }, + { + "login", "/api/v1/login", + `{"nick":"` + nick + `","password":"` + testPassword + `"}`, + http.StatusOK, + }, + { + "post message", apiMessages, + `{"command":"PING"}`, + http.StatusOK, + }, + } + + for _, endpoint := range endpoints { + t.Run(endpoint.name, func(t *testing.T) { + t.Parallel() + + tserver := newTestServer(t) + token := tserver.createSession(nick) + + tserver.sendCommand(token, map[string]any{ + commandKey: irc.CmdPass, + bodyKey: []string{testPassword}, + }) + + bytesToLimit := maxMessageSize - len(endpoint.value) + + for _, trailing := range []string{ + strings.Repeat(" ", bytesToLimit+1), + strings.Repeat("x", bytesToLimit+1), + "x", + "{}", + } { + status, result := tserver.postBody( + endpoint.path, token, endpoint.value+trailing, + ) + if status != http.StatusBadRequest || + result["error"] != "invalid request body" { + t.Errorf( + "%d bytes of %.10q after the value: expected "+ + "400 invalid request body, got %d: %v", + len(trailing), trailing, status, result, + ) + } + } + + status, result := tserver.postBody( + endpoint.path, token, + endpoint.value+strings.Repeat(" ", bytesToLimit), + ) + if status != endpoint.acceptedStatus { + t.Errorf( + "body of exactly the limit: expected %d, got %d: %v", + endpoint.acceptedStatus, status, result, + ) + } + }) + } +} + func TestAuthNoCookie(t *testing.T) { t.Parallel() diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 21d9bc6..172a790 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "strings" @@ -55,7 +54,7 @@ func (hdlr *Handlers) handleLogin( var payload loginRequest - err := json.NewDecoder(request.Body).Decode(&payload) + err := decodeJSON(request, &payload) if err != nil { hdlr.respondError( writer, request, diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 2326fff..58ba9c5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "io" "log/slog" "net/http" "time" @@ -159,6 +160,18 @@ func (hdlr *Handlers) respondError( ) } +// decodeJSON reads the whole request body and decodes it into dest. +// It fails if the body is larger than the http.MaxBytesReader limit, +// or holds anything other than one JSON value and whitespace. +func decodeJSON(request *http.Request, dest any) error { + body, err := io.ReadAll(request.Body) + if err != nil { + return err + } + + return json.Unmarshal(body, dest) +} + func (hdlr *Handlers) idleTimeout() time.Duration { raw := hdlr.params.Config.SessionIdleTimeout if raw == "" {