feat(01-02): add request body size limits (1MB) to webhook and tag handlers

- Add maxBodyBytes constant (1 << 20 = 1 MB)
- Add errors import to production file
- Apply http.MaxBytesReader + errors.As(err, *http.MaxBytesError) pattern in:
  WebhookHandler, TagsHandler POST, TagAssignmentHandler PUT and DELETE
- Return HTTP 413 RequestEntityTooLarge when body exceeds limit
- Fix oversized body test strategy: use JSON prefix so decoder reads past limit
  (Rule 1 deviation: all-x body fails at byte 1 before MaxBytesReader triggers)
This commit is contained in:
2026-03-23 21:20:52 +01:00
parent 311e91d3ff
commit 98dfd76e15
2 changed files with 54 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ import (
"crypto/subtle" "crypto/subtle"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -14,6 +15,8 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
const maxBodyBytes = 1 << 20 // 1 MB
type DiunEvent struct { type DiunEvent struct {
DiunVersion string `json:"diun_version"` DiunVersion string `json:"diun_version"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
@@ -199,9 +202,15 @@ func WebhookHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var event DiunEvent var event DiunEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil { if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
log.Printf("WebhookHandler: failed to decode request: %v", err) log.Printf("WebhookHandler: failed to decode request: %v", err)
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
@@ -296,10 +305,20 @@ func TagsHandler(w http.ResponseWriter, r *http.Request) {
} }
case http.MethodPost: case http.MethodPost:
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad request: name required", http.StatusBadRequest)
return
}
if req.Name == "" {
http.Error(w, "bad request: name required", http.StatusBadRequest) http.Error(w, "bad request: name required", http.StatusBadRequest)
return return
} }
@@ -358,11 +377,21 @@ func TagByIDHandler(w http.ResponseWriter, r *http.Request) {
func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) { func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodPut: case http.MethodPut:
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct { var req struct {
Image string `json:"image"` Image string `json:"image"`
TagID int `json:"tag_id"` TagID int `json:"tag_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.Image == "" {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
@@ -383,10 +412,20 @@ func TagAssignmentHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
case http.MethodDelete: case http.MethodDelete:
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
var req struct { var req struct {
Image string `json:"image"` Image string `json:"image"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Image == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
return
}
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.Image == "" {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }

View File

@@ -614,11 +614,12 @@ func TestGetUpdates_IncludesTag(t *testing.T) {
} }
func TestWebhookHandler_OversizedBody(t *testing.T) { func TestWebhookHandler_OversizedBody(t *testing.T) {
// Generate a body that exceeds 1 MB (maxBodyBytes = 1<<20 = 1,048,576 bytes) // Generate a body that exceeds 1 MB (maxBodyBytes = 1<<20 = 1,048,576 bytes).
oversized := make([]byte, 1<<20+1) // Use a valid JSON prefix so the decoder reads past the limit before failing,
for i := range oversized { // ensuring MaxBytesReader triggers a 413 rather than a JSON parse 400.
oversized[i] = 'x' prefix := []byte(`{"image":"`)
} padding := bytes.Repeat([]byte("x"), 1<<20+1)
oversized := append(prefix, padding...)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
diun.WebhookHandler(rec, req) diun.WebhookHandler(rec, req)
@@ -628,10 +629,9 @@ func TestWebhookHandler_OversizedBody(t *testing.T) {
} }
func TestTagsHandler_OversizedBody(t *testing.T) { func TestTagsHandler_OversizedBody(t *testing.T) {
oversized := make([]byte, 1<<20+1) prefix := []byte(`{"name":"`)
for i := range oversized { padding := bytes.Repeat([]byte("x"), 1<<20+1)
oversized[i] = 'x' oversized := append(prefix, padding...)
}
req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(oversized)) req := httptest.NewRequest(http.MethodPost, "/api/tags", bytes.NewReader(oversized))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
diun.TagsHandler(rec, req) diun.TagsHandler(rec, req)
@@ -641,10 +641,9 @@ func TestTagsHandler_OversizedBody(t *testing.T) {
} }
func TestTagAssignmentHandler_OversizedBody(t *testing.T) { func TestTagAssignmentHandler_OversizedBody(t *testing.T) {
oversized := make([]byte, 1<<20+1) prefix := []byte(`{"image":"`)
for i := range oversized { padding := bytes.Repeat([]byte("x"), 1<<20+1)
oversized[i] = 'x' oversized := append(prefix, padding...)
}
req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(oversized)) req := httptest.NewRequest(http.MethodPut, "/api/tag-assignments", bytes.NewReader(oversized))
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
diun.TagAssignmentHandler(rec, req) diun.TagAssignmentHandler(rec, req)