fix: explicitly require JSON for request bodies

This commit is contained in:
Elias Schneider
2026-08-03 22:58:08 +02:00
parent 69d55ada6c
commit 1238bf0f37
2 changed files with 72 additions and 0 deletions
+38
View File
@@ -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
@@ -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())