From 3be535f1631d9a5f757f05da1e201baf7342663a Mon Sep 17 00:00:00 2001 From: slessard Date: Sat, 17 Dec 2022 11:26:13 -0800 Subject: [PATCH] openapi3filter: validate non-string headers (#712) Co-authored-by: Steve Lessard --- openapi3/schema.go | 4 + openapi3filter/issue201_test.go | 8 +- openapi3filter/req_resp_decoder.go | 4 +- openapi3filter/validate_response.go | 62 +++++-- openapi3filter/validate_response_test.go | 215 +++++++++++++++++++++++ 5 files changed, 271 insertions(+), 22 deletions(-) create mode 100644 openapi3filter/validate_response_test.go diff --git a/openapi3/schema.go b/openapi3/schema.go index 41ccaafef..bbce46c7b 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -1050,6 +1050,10 @@ func (schema *Schema) visitSetOperations(settings *schemaValidationSettings, val return } +// The value is not considered in visitJSONNull because according to the spec +// "null is not supported as a type" unless `nullable` is also set to true +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#data-types +// https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#schema-object func (schema *Schema) visitJSONNull(settings *schemaValidationSettings) (err error) { if schema.Nullable { return diff --git a/openapi3filter/issue201_test.go b/openapi3filter/issue201_test.go index 8b2b99d0e..7e2eaabe1 100644 --- a/openapi3filter/issue201_test.go +++ b/openapi3filter/issue201_test.go @@ -17,7 +17,7 @@ func TestIssue201(t *testing.T) { loader := openapi3.NewLoader() ctx := loader.Context spec := ` -openapi: '3' +openapi: '3.0.3' info: version: 1.0.0 title: Sample API @@ -37,20 +37,24 @@ paths: description: '' required: true schema: + type: string pattern: '^blip$' x-blop: description: '' schema: + type: string pattern: '^blop$' X-Blap: description: '' required: true schema: + type: string pattern: '^blap$' X-Blup: description: '' required: true schema: + type: string pattern: '^blup$' `[1:] @@ -94,7 +98,7 @@ paths: }, "invalid required header": { - err: `response header "X-Blup" doesn't match the schema: string "bluuuuuup" doesn't match the regular expression "^blup$"`, + err: `response header "X-Blup" doesn't match schema: string "bluuuuuup" doesn't match the regular expression "^blup$"`, headers: map[string]string{ "X-Blip": "blip", "x-blop": "blop", diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 870651ce6..4791b4ad4 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -339,7 +339,7 @@ func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMetho } _, found = vDecoder.values[param] case *headerParamDecoder: - _, found = vDecoder.header[param] + _, found = vDecoder.header[http.CanonicalHeaderKey(param)] case *cookieParamDecoder: _, err := vDecoder.req.Cookie(param) found = err != http.ErrNoCookie @@ -888,7 +888,7 @@ func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]interface{}, err // parsePrimitive returns a value that is created by parsing a source string to a primitive type // that is specified by a schema. The function returns nil when the source string is empty. -// The function panics when a schema has a non primitive type. +// The function panics when a schema has a non-primitive type. func parsePrimitive(raw string, schema *openapi3.SchemaRef) (interface{}, error) { if raw == "" { return nil, nil diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index 27bef82d3..c1be31928 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -78,24 +78,10 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error } } sort.Strings(headers) - for _, k := range headers { - s := response.Headers[k] - h := input.Header.Get(k) - if h == "" { - if s.Value.Required { - return &ResponseError{ - Input: input, - Reason: fmt.Sprintf("response header %q missing", k), - } - } - continue - } - if err := s.Value.Schema.Value.VisitJSON(h, opts...); err != nil { - return &ResponseError{ - Input: input, - Reason: fmt.Sprintf("response header %q doesn't match the schema", k), - Err: err, - } + for _, headerName := range headers { + headerRef := response.Headers[headerName] + if err := validateResponseHeader(headerName, headerRef, input, opts); err != nil { + return err } } @@ -171,6 +157,46 @@ func ValidateResponse(ctx context.Context, input *ResponseValidationInput) error return nil } +func validateResponseHeader(headerName string, headerRef *openapi3.HeaderRef, input *ResponseValidationInput, opts []openapi3.SchemaValidationOption) error { + var err error + var decodedValue interface{} + var found bool + var sm *openapi3.SerializationMethod + dec := &headerParamDecoder{header: input.Header} + + if sm, err = headerRef.Value.SerializationMethod(); err != nil { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("unable to get header %q serialization method", headerName), + Err: err, + } + } + + if decodedValue, found, err = decodeValue(dec, headerName, sm, headerRef.Value.Schema, headerRef.Value.Required); err != nil { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("unable to decode header %q value", headerName), + Err: err, + } + } + + if found { + if err = headerRef.Value.Schema.Value.VisitJSON(decodedValue, opts...); err != nil { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("response header %q doesn't match schema", headerName), + Err: err, + } + } + } else if headerRef.Value.Required { + return &ResponseError{ + Input: input, + Reason: fmt.Sprintf("response header %q missing", headerName), + } + } + return nil +} + // getSchemaIdentifier gets something by which a schema could be identified. // A schema by itself doesn't have a true identity field. This function makes // a best effort to get a value that can fill that void. diff --git a/openapi3filter/validate_response_test.go b/openapi3filter/validate_response_test.go new file mode 100644 index 000000000..5ce657b0b --- /dev/null +++ b/openapi3filter/validate_response_test.go @@ -0,0 +1,215 @@ +package openapi3filter + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func Test_validateResponseHeader(t *testing.T) { + type args struct { + headerName string + headerRef *openapi3.HeaderRef + } + tests := []struct { + name string + args args + isHeaderPresent bool + headerVals []string + wantErr bool + wantErrMsg string + }{ + { + name: "test required string header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required string header with single, empty string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{""}, + wantErr: true, + wantErrMsg: `response header "X-Blab" doesn't match schema: Value is not nullable`, + }, + { + name: "test optional string header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), false), + }, + isHeaderPresent: false, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required, but missing string header", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewStringSchema(), true), + }, + isHeaderPresent: false, + headerVals: nil, + wantErr: true, + wantErrMsg: `response header "X-Blab" missing`, + }, + { + name: "test integer header with single integer value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewIntegerSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test integer header with single string value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewIntegerSchema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `unable to decode header "X-Blab" value: value blab: an invalid integer: invalid syntax`, + }, + { + name: "test int64 header with single int64 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewInt64Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test int32 header with single int32 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewInt32Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88"}, + wantErr: false, + }, + { + name: "test float64 header with single float64 value", + args: args{ + headerName: "X-Blab", + headerRef: newHeaderRef(openapi3.NewFloat64Schema(), true), + }, + isHeaderPresent: true, + headerVals: []string{"88.87"}, + wantErr: false, + }, + { + name: "test integer header with multiple csv integer values", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true), + }, + isHeaderPresent: true, + headerVals: []string{"87,88"}, + wantErr: false, + }, + { + name: "test integer header with multiple integer values", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(newArraySchema(openapi3.NewIntegerSchema()), true), + }, + isHeaderPresent: true, + headerVals: []string{"87", "88"}, + wantErr: false, + }, + { + name: "test non-typed, nullable header with single string value", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: true}, true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: false, + }, + { + name: "test required non-typed, nullable header not present", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: true}, true), + }, + isHeaderPresent: false, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `response header "X-blab" missing`, + }, + { + name: "test non-typed, non-nullable header with single string value", + args: args{ + headerName: "X-blab", + headerRef: newHeaderRef(&openapi3.Schema{Nullable: false}, true), + }, + isHeaderPresent: true, + headerVals: []string{"blab"}, + wantErr: true, + wantErrMsg: `response header "X-blab" doesn't match schema: Value is not nullable`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + input := newInputDefault() + opts := []openapi3.SchemaValidationOption(nil) + if tt.isHeaderPresent { + input.Header = map[string][]string{http.CanonicalHeaderKey(tt.args.headerName): tt.headerVals} + } + + err := validateResponseHeader(tt.args.headerName, tt.args.headerRef, input, opts) + if tt.wantErr { + require.NotEmpty(t, tt.wantErrMsg, "wanted error message is not populated") + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func newInputDefault() *ResponseValidationInput { + return &ResponseValidationInput{ + RequestValidationInput: &RequestValidationInput{ + Request: nil, + PathParams: nil, + Route: nil, + }, + Status: 200, + Header: nil, + Body: io.NopCloser(strings.NewReader(`{}`)), + } +} + +func newHeaderRef(schema *openapi3.Schema, required bool) *openapi3.HeaderRef { + return &openapi3.HeaderRef{Value: &openapi3.Header{Parameter: openapi3.Parameter{Schema: &openapi3.SchemaRef{Value: schema}, Required: required}}} +} + +func newArraySchema(schema *openapi3.Schema) *openapi3.Schema { + arraySchema := openapi3.NewArraySchema() + arraySchema.Items = openapi3.NewSchemaRef("", schema) + + return arraySchema +}