From 3215b9f77fe5c45cb0b7890dfeb5d285a32296c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20BIDON?= Date: Mon, 4 May 2026 22:21:41 +0200 Subject: [PATCH] fix(validation): match content-type with MIME parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `consumes` entries that carry MIME parameters (e.g. `text/plain;charset=utf-8`) were rejecting all client content types because validation compared the bare parsed media type against the raw allowed string. Parse both sides and accept when the bare types match and every client parameter is present on the allowed entry with the same value (allowed entry may carry extra parameters; an entry without parameters accepts any). Fixes #136 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Frédéric BIDON --- middleware/validation.go | 54 ++++++++++++++++++++++++++++------- middleware/validation_test.go | 21 ++++++++++++++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/middleware/validation.go b/middleware/validation.go index 8a564906..8dca105b 100644 --- a/middleware/validation.go +++ b/middleware/validation.go @@ -10,7 +10,6 @@ import ( "github.com/go-openapi/errors" "github.com/go-openapi/runtime" - "github.com/go-openapi/swag/stringutils" ) type validation struct { @@ -22,27 +21,62 @@ type validation struct { } // ContentType validates the content type of a request. +// +// An allowed entry may carry MIME type parameters (e.g. "text/plain;charset=utf-8"). +// In that case every parameter the client sends must be present on the allowed entry +// with the same value; the allowed entry may carry additional parameters the client +// omits. An allowed entry without parameters accepts any client parameters. +// "*/*" and "type/*" wildcards are matched on the bare type only. func validateContentType(allowed []string, actual string) error { if len(allowed) == 0 { return nil } - mt, _, err := mime.ParseMediaType(actual) + actualType, actualParams, err := mime.ParseMediaType(actual) if err != nil { return errors.InvalidContentType(actual, allowed) } - if stringutils.ContainsStringsCI(allowed, mt) { - return nil - } - if stringutils.ContainsStringsCI(allowed, "*/*") { - return nil + typeWildcard := "" + if slash := strings.IndexByte(actualType, '/'); slash > 0 { + typeWildcard = actualType[:slash] + "/*" } - parts := strings.Split(actual, "/") - if len(parts) == 2 && stringutils.ContainsStringsCI(allowed, parts[0]+"/*") { - return nil + for _, a := range allowed { + if strings.EqualFold(a, "*/*") { + return nil + } + if typeWildcard != "" && strings.EqualFold(a, typeWildcard) { + return nil + } + if mediaTypeMatches(a, actualType, actualParams) { + return nil + } } return errors.InvalidContentType(actual, allowed) } +// mediaTypeMatches reports whether the actual client media type satisfies the +// server-side allowed media type, with parameter-aware comparison. +func mediaTypeMatches(allowed, actualType string, actualParams map[string]string) bool { + allowedType, allowedParams, err := mime.ParseMediaType(allowed) + if err != nil { + // Fall back to a case-insensitive bare match if the configured value + // can't be parsed as a media type. + return strings.EqualFold(allowed, actualType) + } + if !strings.EqualFold(allowedType, actualType) { + return false + } + if len(allowedParams) == 0 { + return true + } + for k, v := range actualParams { + sv, ok := allowedParams[k] + if !ok || !strings.EqualFold(sv, v) { + return false + } + } + return true +} + func validateRequest(ctx *Context, request *http.Request, route *MatchedRoute) *validation { validate := &validation{ context: ctx, diff --git a/middleware/validation_test.go b/middleware/validation_test.go index eb32bd4f..851f6878 100644 --- a/middleware/validation_test.go +++ b/middleware/validation_test.go @@ -137,6 +137,11 @@ func TestResponseFormatValidation(t *testing.T) { } func TestValidateContentType(t *testing.T) { + const ( + textPlain = "text/plain" + textPlainUTF8 = "text/plain;charset=utf-8" + textPlainParamSrv = "text/plain; charset=utf-8" + ) data := []struct { hdr string allowed []string @@ -152,6 +157,22 @@ func TestValidateContentType(t *testing.T) { {"application/json;char*", []string{"application/json"}, errors.InvalidContentType("application/json;char*", []string{"application/json"})}, {"application/octet-stream", []string{"image/jpeg", "application/*"}, nil}, {"image/png", []string{"*/*", "application/json"}, nil}, + // regression for https://github.com/go-openapi/runtime/issues/136: + // allowed entries with MIME parameters should not block matching clients. + // (1) client sends bare type, server allows type with params -> accept + {textPlain, []string{textPlainParamSrv}, nil}, + // (2) client sends a different param than server -> reject + {"text/plain;blah=true", []string{textPlainParamSrv}, + errors.InvalidContentType("text/plain;blah=true", []string{textPlainParamSrv})}, + // (3) client sends params, server allows bare type -> accept + {textPlainUTF8, []string{textPlain}, nil}, + // (4) exact param match -> accept + {textPlainUTF8, []string{textPlainUTF8}, nil}, + // param value compare is case-insensitive (charset is case-insensitive) + {"text/plain;charset=UTF-8", []string{textPlainUTF8}, nil}, + // (5) conflicting param values -> reject + {textPlainUTF8, []string{"text/plain;charset=ascii"}, + errors.InvalidContentType(textPlainUTF8, []string{"text/plain;charset=ascii"})}, } for _, v := range data {