Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 17 additions & 29 deletions middleware/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,29 @@ type validation struct {
bound map[string]any
}

// validateContentType validates a request's Content-Type against the route's
// declared consumes list, applying the parameter-aware matching rule from
// [mediatype.MediaType.Matches]:
// validateContentType maps [mediatype.MatchFirst] to the runtime's
// validation errors:
//
// - bare types must agree (with "*/*" and "type/*" wildcards on the
// allowed side);
// - an allowed entry without parameters accepts any client parameters;
// - an allowed entry with parameters constrains the client — every
// parameter the client sends must be present on the allowed entry
// with the same value (case-insensitive). The allowed entry may
// carry additional parameters the client omits.
// - actual fails to parse → HTTP 400 ([errors.NewParseError]).
// - actual is well-formed but
// no allowed entry accepts it → HTTP 415 ([errors.InvalidContentType]).
//
// In the standard runtime flow, malformed Content-Type headers are
// already caught upstream by [runtime.ContentType] (which itself returns
// a 400 [errors.ParseError]). This function therefore only sees the
// malformed case when invoked directly by callers that have bypassed
// that step.
func validateContentType(allowed []string, actual string) error {
if len(allowed) == 0 {
return nil
}
actualMT, err := mediatype.Parse(actual)
if err != nil {
return errors.InvalidContentType(actual, allowed)
_, ok, err := mediatype.MatchFirst(allowed, actual)
if ok {
return nil
}
for _, a := range allowed {
allowedMT, perr := mediatype.Parse(a)
if perr != nil {
// Configured value isn't a valid media type — fall back to
// a case-insensitive bare comparison, preserving the
// pre-mediatype behaviour.
if strings.EqualFold(a, actual) {
return nil
}
continue
}
if allowedMT.Matches(actualMT) {
return nil
}
if err != nil {
return errors.NewParseError(runtime.HeaderContentType, "header", actual, err)
}

return errors.InvalidContentType(actual, allowed)
}

Expand Down Expand Up @@ -124,7 +112,7 @@ func (v *validation) contentType() {
}

func (v *validation) responseFormat() {
// if the route provides values for Produces and no format could be identify then return an error.
// if the route provides values for Produces and no format could be identified then return an error.
// if the route does not specify values for Produces then treat request as valid since the API designer
// choose not to specify the format for responses.
if str, rCtx := v.context.ResponseFormat(v.request, v.route.Produces); str == "" && len(v.route.Produces) > 0 {
Expand Down
80 changes: 31 additions & 49 deletions middleware/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,54 +136,36 @@ func TestResponseFormatValidation(t *testing.T) {
assert.EqualT(t, http.StatusNotAcceptable, recorder.Code)
}

// TestValidateContentType is a smoke test confirming the wrapper maps
// no-match to 415 (errors.InvalidContentType) and malformed-actual to 400
// (errors.NewParseError). The matching matrix lives in
// server-middleware/mediatype/match_test.go.
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
err *errors.Validation
}{
{"application/json", []string{"application/json"}, nil},
{"application/json", []string{"application/x-yaml", "text/html"}, errors.InvalidContentType("application/json", []string{"application/x-yaml", "text/html"})},
{"text/html; charset=utf-8", []string{"text/html"}, nil},
{"text/html;charset=utf-8", []string{"text/html"}, nil},
{"", []string{"application/json"}, errors.InvalidContentType("", []string{"application/json"})},
{"text/html; charset=utf-8", []string{"application/json"}, errors.InvalidContentType("text/html; charset=utf-8", []string{"application/json"})},
{"application(", []string{"application/json"}, errors.InvalidContentType("application(", []string{"application/json"})},
{"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 {
err := validateContentType(v.allowed, v.hdr)
if v.err == nil {
require.NoError(t, err, "input: %q", v.hdr)
} else {
require.Error(t, err, "input: %q", v.hdr)
assert.IsTypef(t, &errors.Validation{}, err, "input: %q", v.hdr)
require.EqualErrorf(t, err, v.err.Error(), "input: %q", v.hdr)
assert.EqualValues(t, http.StatusUnsupportedMediaType, err.(*errors.Validation).Code())
}
}
const json = "application/json"

t.Run("nil allowed accepts anything", func(t *testing.T) {
require.NoError(t, validateContentType(nil, json))
})

t.Run("match returns nil", func(t *testing.T) {
require.NoError(t, validateContentType([]string{json}, json))
})

t.Run("no match returns 415", func(t *testing.T) {
err := validateContentType([]string{json}, "text/html")
require.Error(t, err)
var v *errors.Validation
require.ErrorAs(t, err, &v)
assert.EqualT(t, http.StatusUnsupportedMediaType, int(v.Code()))
})

t.Run("malformed actual returns 400", func(t *testing.T) {
// In the normal runtime flow this case is caught upstream by
// runtime.ContentType. The smoke test exercises the direct path.
err := validateContentType([]string{json}, "application(")
require.Error(t, err)
var p *errors.ParseError
require.ErrorAs(t, err, &p)
assert.EqualT(t, http.StatusBadRequest, int(p.Code()))
})
}
48 changes: 48 additions & 0 deletions server-middleware/mediatype/match.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package mediatype

// MatchFirst reports whether actual matches any entry in allowed, using the
// param-aware rule from [MediaType.Matches].
//
// MatchFirst short-circuits on the first allowed entry that accepts actual:
// the returned [MediaType] may not be the most specific match. Callers that
// need ranked matching should use [Set.BestMatch].
//
// Return values:
//
// - (matched, true, nil) — the first allowed entry that matches.
// - (zero, false, nil) — actual is well-formed but no allowed
// entry accepts it.
// Maps to an HTTP 415 outcome.
// - (zero, false, err) — actual fails to parse. err wraps
// [ErrMalformed], so callers can use [errors.Is] to distinguish this
// case.
// Maps to an HTTP 400 outcome.
//
// Allowed entries that themselves fail to parse are skipped (they cannot
// match any well-formed actual), and no error is surfaced for them.
//
// An empty allowed list returns (zero, false, nil). MatchFirst is the primitive;
// callers decide what no-constraints means in their context.
func MatchFirst(allowed []string, actual string) (MediaType, bool, error) {
if len(allowed) == 0 {
return MediaType{}, false, nil
}
actualMT, err := Parse(actual)
if err != nil {
return MediaType{}, false, err
}
for _, a := range allowed {
allowedMT, perr := Parse(a)
if perr != nil {
continue
}
if allowedMT.Matches(actualMT) {
return allowedMT, true, nil
}
}

return MediaType{}, false, nil
}
118 changes: 118 additions & 0 deletions server-middleware/mediatype/match_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package mediatype_test

import (
"testing"

"github.com/go-openapi/runtime/server-middleware/mediatype"
"github.com/go-openapi/testify/v2/assert"
"github.com/go-openapi/testify/v2/require"
)

const (
jsonMime = "application/json"
yamlMime = "application/x-yaml"
htmlMime = "text/html"
octetMime = "application/octet-stream"
jpegMime = "image/jpeg"
imagePNG = "image/png"
textPlain = "text/plain"
textPlainUTF8 = "text/plain;charset=utf-8"
textPlainParamSrv = "text/plain; charset=utf-8"
)

// TestMatch covers the matching primitive used by the server-side
// Content-Type validator. Rows ported from middleware/validation_test.go
// (TestValidateContentType) plus new rows for the (MediaType, bool, error)
// distinctions only the primitive surfaces.
func TestMatch(t *testing.T) {
t.Run("matches and rejections", func(t *testing.T) {
cases := []struct {
name string
actual string
allowed []string
wantOK bool
}{
{"exact bare match", jsonMime, []string{jsonMime}, true},
{"no match in list", jsonMime, []string{yamlMime, htmlMime}, false},
{"client param, allowed bare (with space)", "text/html; charset=utf-8", []string{htmlMime}, true},
{"client param, allowed bare (no space)", "text/html;charset=utf-8", []string{htmlMime}, true},
{"unrelated types", "text/html; charset=utf-8", []string{jsonMime}, false},
{"subtype wildcard on allowed", octetMime, []string{jpegMime, "application/*"}, true},
{"full wildcard on allowed", imagePNG, []string{"*/*", jsonMime}, true},

// Regression for https://github.com/go-openapi/runtime/issues/136 —
// allowed entries with MIME parameters must not block matching clients.
{"#136 client bare, allowed has params", textPlain, []string{textPlainParamSrv}, true},
{"#136 client param differs", "text/plain;blah=true", []string{textPlainParamSrv}, false},
{"#136 client params, allowed bare", textPlainUTF8, []string{textPlain}, true},
{"#136 exact param match", textPlainUTF8, []string{textPlainUTF8}, true},
{"#136 client param value case-insensitive", "text/plain;charset=UTF-8", []string{textPlainUTF8}, true},
{"#136 conflicting param values", textPlainUTF8, []string{"text/plain;charset=ascii"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, ok, err := mediatype.MatchFirst(c.allowed, c.actual)
require.NoError(t, err)
assert.EqualT(t, c.wantOK, ok)
})
}
})

t.Run("malformed actual surfaces ErrMalformed", func(t *testing.T) {
// These rows used to be in TestValidateContentType under the
// "*errors.Validation" expectation. Match exposes the cause so
// callers can distinguish 400 from 415 if they want to.
malformed := []struct {
name string
actual string
}{
{"empty", ""},
{"unparseable", "application("},
{"bad parameter", "application/json;char*"},
}
for _, c := range malformed {
t.Run(c.name, func(t *testing.T) {
_, ok, err := mediatype.MatchFirst([]string{jsonMime}, c.actual)
assert.False(t, ok)
require.Error(t, err)
assert.ErrorIs(t, err, mediatype.ErrMalformed)
})
}
})

t.Run("empty allowed: (_, false, nil)", func(t *testing.T) {
// Match is a primitive — empty constraints means no match. The
// "empty list = accept anything" policy lives in the caller.
_, ok, err := mediatype.MatchFirst(nil, jsonMime)
require.NoError(t, err)
assert.False(t, ok)
})

t.Run("malformed allowed entries are skipped", func(t *testing.T) {
// An entry in `allowed` that fails to parse cannot match any
// well-formed actual, so it is silently skipped. Other entries
// in the list still get a chance to match.
t.Run("skipped, fall through to next match", func(t *testing.T) {
_, ok, err := mediatype.MatchFirst([]string{"garbage(", jsonMime}, jsonMime)
require.NoError(t, err)
assert.True(t, ok)
})
t.Run("only malformed entries: no match, no error", func(t *testing.T) {
_, ok, err := mediatype.MatchFirst([]string{"garbage(", "also-bad"}, jsonMime)
require.NoError(t, err)
assert.False(t, ok)
})
})

t.Run("matched entry returned as parsed MediaType", func(t *testing.T) {
got, ok, err := mediatype.MatchFirst([]string{textPlainParamSrv}, textPlain)
require.NoError(t, err)
require.True(t, ok)
assert.EqualT(t, "text", got.Type)
assert.EqualT(t, "plain", got.Subtype)
assert.EqualT(t, "utf-8", got.Params["charset"])
})
}
21 changes: 17 additions & 4 deletions server-middleware/mediatype/mediatype.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package mediatype

import (
"errors"
"fmt"
"mime"
"strconv"
Expand All @@ -22,6 +21,20 @@ const (
SpecificityExactWithParams // "type/subtype;k=v"
)

type mediaTypeError string

func (e mediaTypeError) Error() string {
return string(e)
}

// ErrMalformed is the sentinel returned (wrapped) by [Parse] when its input
// cannot be parsed as an RFC 7231 media type.
//
// Callers can test for it with [errors.Is] to distinguish a client-side
// malformed Content-Type header (an HTTP 400 outcome) from a well-formed
// value that simply matches no allowed entry (an HTTP 415 outcome).
const ErrMalformed mediaTypeError = "mediatype: malformed"

// MediaType is a parsed RFC 7231 media type with optional parameters and
// an optional q-value (used by Accept negotiation).
//
Expand All @@ -43,15 +56,15 @@ type MediaType struct {
func Parse(s string) (MediaType, error) {
s = strings.TrimSpace(s)
if s == "" {
return MediaType{}, errors.New("mediatype: empty value")
return MediaType{}, fmt.Errorf("%w: empty value", ErrMalformed)
}
full, params, err := mime.ParseMediaType(s)
if err != nil {
return MediaType{}, fmt.Errorf("mediatype: %w", err)
return MediaType{}, fmt.Errorf("%w: %w", ErrMalformed, err)
}
slash := strings.IndexByte(full, '/')
if slash <= 0 || slash == len(full)-1 {
return MediaType{}, fmt.Errorf("mediatype: %q has no subtype", s)
return MediaType{}, fmt.Errorf("%w: %q has no subtype", ErrMalformed, s)
}
mt := MediaType{
Type: full[:slash],
Expand Down
3 changes: 2 additions & 1 deletion server-middleware/mediatype/mediatype_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ func TestParse(t *testing.T) {
for _, s := range invalid {
t.Run(s, func(t *testing.T) {
_, err := Parse(s)
assert.Error(t, err)
require.Error(t, err)
assert.ErrorIs(t, err, ErrMalformed)
})
}
})
Expand Down
Loading