From 1238bf0f378340d101c90295990b514131b10fe7 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 3 Aug 2026 22:57:41 +0200 Subject: [PATCH] fix: explicitly require JSON for request bodies --- backend/internal/httpserver/binding.go | 38 +++++++++++++++++++++ backend/internal/httpserver/binding_test.go | 34 ++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/backend/internal/httpserver/binding.go b/backend/internal/httpserver/binding.go index 08d7040a..177d5d6e 100644 --- a/backend/internal/httpserver/binding.go +++ b/backend/internal/httpserver/binding.go @@ -3,8 +3,10 @@ package httpserver import ( "errors" "io" + "mime" "mime/multipart" "net/http" + "strings" "github.com/gin-gonic/gin" "github.com/gin-gonic/gin/binding" @@ -16,6 +18,10 @@ import ( // BindJSON binds and normalizes a JSON request while distinguishing invalid input from internal failures func BindJSON(c *gin.Context, value any) error { + if err := requireJSONContentType(c); err != nil { + return err + } + err := classifyBindingError(c.ShouldBindJSON(value)) if err != nil { return err @@ -27,6 +33,13 @@ func BindJSON(c *gin.Context, value any) error { // BindOptionalJSON accepts an empty body while normalizing valid input and classifying malformed JSON as invalid input func BindOptionalJSON(c *gin.Context, value any) error { + if c.Request.ContentLength == 0 { + return nil + } + if err := requireJSONContentType(c); err != nil { + return err + } + err := c.ShouldBindJSON(value) if errors.Is(err, io.EOF) { return nil @@ -52,6 +65,31 @@ func FormFile(c *gin.Context, field string) (*multipart.FileHeader, error) { return file, nil } +func requireJSONContentType(c *gin.Context) error { + mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil { + return apperror.InvalidRequestBody(err) + } + if !isJSONMediaType(mediaType) { + return apperror.InvalidRequestBody(errors.New("request Content-Type is not JSON")) + } + + return nil +} + +func isJSONMediaType(mediaType string) bool { + topLevelType, subtype, ok := strings.Cut(mediaType, "/") + if !ok || topLevelType != "application" { + return false + } + if subtype == "json" { + return true + } + + baseSubtype, ok := strings.CutSuffix(subtype, "+json") + return ok && baseSubtype != "" +} + func classifyBindingError(err error) error { if err == nil { return nil diff --git a/backend/internal/httpserver/binding_test.go b/backend/internal/httpserver/binding_test.go index 784cc38d..b5ceed24 100644 --- a/backend/internal/httpserver/binding_test.go +++ b/backend/internal/httpserver/binding_test.go @@ -63,6 +63,40 @@ func TestBindJSONNormalizesTaggedFieldsRecursively(t *testing.T) { require.Equal(t, norm.NFC.String("Résumé"), input.Items[0].Label) } +func TestBindJSONRejectsFormCompatibleContentTypes(t *testing.T) { + for _, contentType := range []string{"text/plain", "application/x-www-form-urlencoded", "multipart/form-data; boundary=test"} { + t.Run(contentType, func(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"name":"admin"}`)) + c.Request.Header.Set("Content-Type", contentType) + + var input struct { + Name string `json:"name"` + } + err := BindJSON(c, &input) + + require.True(t, apperror.IsCode(err, apperror.CodeInvalidRequestBody)) + require.Empty(t, input.Name) + }) + } +} + +func TestBindJSONAcceptsStructuredJSONContentType(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", strings.NewReader(`{"name":"admin"}`)) + c.Request.Header.Set("Content-Type", "application/scim+json") + + var input struct { + Name string `json:"name"` + } + err := BindJSON(c, &input) + + require.NoError(t, err) + require.Equal(t, "admin", input.Name) +} + func TestFormFileClassifiesMissingField(t *testing.T) { gin.SetMode(gin.TestMode) c, _ := gin.CreateTestContext(httptest.NewRecorder())