From fbd40b09c460fa28a5b620fd9f507df0d8b84f7e Mon Sep 17 00:00:00 2001 From: Dave Shanley Date: Sat, 2 Dec 2023 13:52:26 -0500 Subject: [PATCH] Added security vallidation feature now the validator will check for HTTP and APIKey authentication on operations. Signed-off-by: Dave Shanley --- go.mod | 2 +- go.sum | 4 +- helpers/parameter_utilities.go | 41 ++++ parameters/parameters.go | 4 + parameters/validate_security.go | 132 +++++++++++ parameters/validate_security_test.go | 316 +++++++++++++++++++++++++++ requests/validate_body_test.go | 22 ++ requests/validate_request.go | 24 +- validator.go | 1 + validator_test.go | 3 + 10 files changed, 543 insertions(+), 6 deletions(-) create mode 100644 parameters/validate_security.go create mode 100644 parameters/validate_security_test.go diff --git a/go.mod b/go.mod index 581027f..389f7e5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/pb33f/libopenapi-validator go 1.21 require ( - github.com/pb33f/libopenapi v0.13.16 + github.com/pb33f/libopenapi v0.13.17 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/stretchr/testify v1.8.4 github.com/vmware-labs/yaml-jsonpath v0.3.2 diff --git a/go.sum b/go.sum index a9b5930..07e1ae8 100644 --- a/go.sum +++ b/go.sum @@ -47,8 +47,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/pb33f/libopenapi v0.13.16 h1:w1A2zJidQEISPHibDkYRCUhnxRg7R45ZW8hwD2b+wLo= -github.com/pb33f/libopenapi v0.13.16/go.mod h1:Lv2eEtsAtbRFlF8hjH82L8SIGoUNgemMVoKoB6A9THk= +github.com/pb33f/libopenapi v0.13.17 h1:FbY5Nx3xmALZf7TtdITXRSyeNZhB7U3COYbkAisX2eY= +github.com/pb33f/libopenapi v0.13.17/go.mod h1:Lv2eEtsAtbRFlF8hjH82L8SIGoUNgemMVoKoB6A9THk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= diff --git a/helpers/parameter_utilities.go b/helpers/parameter_utilities.go index 1f2d839..f7c50e5 100644 --- a/helpers/parameter_utilities.go +++ b/helpers/parameter_utilities.go @@ -5,6 +5,7 @@ package helpers import ( "fmt" + "github.com/pb33f/libopenapi/datamodel/high/base" v3 "github.com/pb33f/libopenapi/datamodel/high/v3" "net/http" "strconv" @@ -61,6 +62,46 @@ func ExtractParamsForOperation(request *http.Request, item *v3.PathItem) []*v3.P return params } +// ExtractSecurityForOperation will extract the security requirements for the operation based on the request method. +func ExtractSecurityForOperation(request *http.Request, item *v3.PathItem) []*base.SecurityRequirement { + var schemes []*base.SecurityRequirement + switch request.Method { + case http.MethodGet: + if item.Get != nil { + schemes = append(schemes, item.Get.Security...) + } + case http.MethodPost: + if item.Post != nil { + schemes = append(schemes, item.Post.Security...) + } + case http.MethodPut: + if item.Put != nil { + schemes = append(schemes, item.Put.Security...) + } + case http.MethodDelete: + if item.Delete != nil { + schemes = append(schemes, item.Delete.Security...) + } + case http.MethodOptions: + if item.Options != nil { + schemes = append(schemes, item.Options.Security...) + } + case http.MethodHead: + if item.Head != nil { + schemes = append(schemes, item.Head.Security...) + } + case http.MethodPatch: + if item.Patch != nil { + schemes = append(schemes, item.Patch.Security...) + } + case http.MethodTrace: + if item.Trace != nil { + schemes = append(schemes, item.Trace.Security...) + } + } + return schemes +} + func cast(v string) any { if v == "true" || v == "false" { diff --git a/parameters/parameters.go b/parameters/parameters.go index e44937a..2f49f82 100644 --- a/parameters/parameters.go +++ b/parameters/parameters.go @@ -41,6 +41,10 @@ type ParameterValidator interface { // ValidatePathParams validates the path parameters contained within *http.Request. It returns a boolean stating true // if validation passed (false for failed), and a slice of errors if validation failed. ValidatePathParams(request *http.Request) (bool, []*errors.ValidationError) + + // ValidateSecurity validates the security requirements for the operation. It returns a boolean stating true + // if validation passed (false for failed), and a slice of errors if validation failed. + ValidateSecurity(request *http.Request) (bool, []*errors.ValidationError) } func (v *paramValidator) SetPathItem(path *v3.PathItem, pathValue string) { diff --git a/parameters/validate_security.go b/parameters/validate_security.go new file mode 100644 index 0000000..50c13fc --- /dev/null +++ b/parameters/validate_security.go @@ -0,0 +1,132 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package parameters + +import ( + "fmt" + "github.com/pb33f/libopenapi-validator/errors" + "github.com/pb33f/libopenapi-validator/helpers" + "github.com/pb33f/libopenapi-validator/paths" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" + "net/http" + "strings" +) + +func (v *paramValidator) ValidateSecurity(request *http.Request) (bool, []*errors.ValidationError) { + + // find path + var pathItem *v3.PathItem + var errs []*errors.ValidationError + if v.pathItem == nil { + pathItem, errs, _ = paths.FindPath(request, v.document) + if pathItem == nil || errs != nil { + v.errors = errs + return false, errs + } + } else { + pathItem = v.pathItem + } + + // extract security for the operation + security := helpers.ExtractSecurityForOperation(request, pathItem) + + if security == nil { + return true, nil + } + + for _, sec := range security { + for secName, _ := range sec.Requirements { + + // look up security from components + secScheme := v.document.Components.SecuritySchemes[secName] + if secScheme != nil { + + switch strings.ToLower(secScheme.Type) { + case "http": + switch strings.ToLower(secScheme.Scheme) { + case "basic", "bearer", "digest": + // check for an authorization header + if request.Header.Get("Authorization") == "" { + return false, []*errors.ValidationError{ + { + Message: fmt.Sprintf("Authorization header for '%s' scheme", secScheme.Scheme), + Reason: "Authorization header was not found", + ValidationType: "security", + ValidationSubType: secScheme.Scheme, + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: "Add an 'Authorization' header to this request", + }, + } + } + } + + case "apikey": + // check if the api key is in the request + if secScheme.In == "header" { + if request.Header.Get(secScheme.Name) == "" { + return false, []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in header", secScheme.Name), + Reason: "API Key not found in http header for security scheme 'apiKey' with type 'header'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Add the API Key via '%s' as a header of the request", secScheme.Name), + }, + } + } + } + if secScheme.In == "query" { + if request.URL.Query().Get(secScheme.Name) == "" { + copyUrl := *request.URL + fixed := ©Url + q := fixed.Query() + q.Add(secScheme.Name, "your-api-key") + fixed.RawQuery = q.Encode() + + return false, []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in query", secScheme.Name), + Reason: "API Key not found in URL query for security scheme 'apiKey' with type 'query'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Add an API Key via '%s' to the query string "+ + "of the URL, for example '%s'", secScheme.Name, fixed.String()), + }, + } + } + } + if secScheme.In == "cookie" { + cookies := request.Cookies() + cookieFound := false + for _, cookie := range cookies { + if cookie.Name == secScheme.Name { + cookieFound = true + break + } + } + if !cookieFound { + return false, []*errors.ValidationError{ + { + Message: fmt.Sprintf("API Key %s not found in cookies", secScheme.Name), + Reason: "API Key not found in http request cookies for security scheme 'apiKey' with type 'cookie'", + ValidationType: "security", + ValidationSubType: "apiKey", + SpecLine: sec.GoLow().Requirements.ValueNode.Line, + SpecCol: sec.GoLow().Requirements.ValueNode.Column, + HowToFix: fmt.Sprintf("Submit an API Key '%s' as a cookie with the request", secScheme.Name), + }, + } + } + } + } + } + } + } + return true, nil +} diff --git a/parameters/validate_security_test.go b/parameters/validate_security_test.go new file mode 100644 index 0000000..775c2d6 --- /dev/null +++ b/parameters/validate_security_test.go @@ -0,0 +1,316 @@ +// Copyright 2023 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package parameters + +import ( + "github.com/pb33f/libopenapi" + "github.com/pb33f/libopenapi-validator/paths" + "github.com/stretchr/testify/assert" + "net/http" + "testing" +) + +func TestParamValidator_ValidateSecurity_APIKeyHeader_NotFound(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Equal(t, 1, len(errors)) + assert.Equal(t, "API Key X-API-Key not found in header", errors[0].Message) +} + +func TestParamValidator_ValidateSecurity_APIKeyHeader(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("X-API-Key", "1234") + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Equal(t, 0, len(errors)) +} + +func TestParamValidator_ValidateSecurity_APIKeyQuery_NotFound(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: query + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Equal(t, 1, len(errors)) + assert.Equal(t, "API Key X-API-Key not found in query", errors[0].Message) + assert.Equal(t, "Add an API Key via 'X-API-Key' to the query string of the URL, "+ + "for example 'https://things.com/products?X-API-Key=your-api-key'", errors[0].HowToFix) + +} + +func TestParamValidator_ValidateSecurity_APIKeyQuery(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: query + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products?X-API-Key=12345", nil) + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Equal(t, 0, len(errors)) +} + +func TestParamValidator_ValidateSecurity_APIKeyCookie_NotFound(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: cookie + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Equal(t, 1, len(errors)) + assert.Equal(t, "API Key X-API-Key not found in cookies", errors[0].Message) +} + +func TestParamValidator_ValidateSecurity_APIKeyCookie(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: cookie + name: X-API-Key +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + request.AddCookie(&http.Cookie{ + Name: "X-API-Key", + Value: "1234", + }) + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Equal(t, 0, len(errors)) +} + +func TestParamValidator_ValidateSecurity_Basic_NotFound(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Equal(t, 1, len(errors)) + assert.Equal(t, "Authorization header for 'basic' scheme", errors[0].Message) +} + +func TestParamValidator_ValidateSecurity_Basic(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + request.Header.Add("Authorization", "Basic 1234") + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Equal(t, 0, len(errors)) +} + +func TestParamValidator_ValidateSecurity_BadPath(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: + security: + - ApiKeyAuth: + - write:products +components: + securitySchemes: + ApiKeyAuth: + type: http + scheme: basic +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/blimpo", nil) + valid, errors := v.ValidateSecurity(request) + assert.False(t, valid) + assert.Equal(t, 1, len(errors)) +} + +func TestParamValidator_ValidateSecurity_PresetPath(t *testing.T) { + + spec := `openapi: 3.1.0 +paths: + /products: + post: +` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + + v := NewParameterValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/products", nil) + pathItem, errs, _ := paths.FindPath(request, &m.Model) + assert.Nil(t, errs) + v.(*paramValidator).pathItem = pathItem + + valid, errors := v.ValidateSecurity(request) + assert.True(t, valid) + assert.Equal(t, 0, len(errors)) +} diff --git a/requests/validate_body_test.go b/requests/validate_body_test.go index b5e74fd..0834868 100644 --- a/requests/validate_body_test.go +++ b/requests/validate_body_test.go @@ -923,6 +923,28 @@ components: valid, errors := v.ValidateRequestBody(request) + assert.False(t, valid) + assert.Len(t, errors, 1) + +} + +func TestValidateBody_NoBodyNoNothing(t *testing.T) { + spec := `openapi: 3.1.0 +paths: + /burgers/createBurger: + post:` + + doc, _ := libopenapi.NewDocument([]byte(spec)) + + m, _ := doc.BuildV3Model() + v := NewRequestBodyValidator(&m.Model) + + request, _ := http.NewRequest(http.MethodPost, "https://things.com/burgers/createBurger", + http.NoBody) + request.Header.Set("Content-Type", "application/json") + + valid, errors := v.ValidateRequestBody(request) + assert.True(t, valid) assert.Len(t, errors, 0) diff --git a/requests/validate_request.go b/requests/validate_request.go index ca600a6..4aac677 100644 --- a/requests/validate_request.go +++ b/requests/validate_request.go @@ -68,9 +68,27 @@ func ValidateRequestSchema( } } - // no request body? failed to decode anything? nothing to do here. - if requestBody == nil || decodedObj == nil { - return true, nil + // no request body? but we do have a schema? + if len(requestBody) <= 0 && len(jsonSchema) > 0 { + // cannot decode the request body, so it's not valid + violation := &errors.SchemaValidationFailure{ + Reason: "request body is empty, but there is a schema defined", + ReferenceSchema: string(renderedSchema), + ReferenceObject: string(requestBody), + } + validationErrors = append(validationErrors, &errors.ValidationError{ + ValidationType: helpers.RequestBodyValidation, + ValidationSubType: helpers.Schema, + Message: fmt.Sprintf("%s request body is empty for '%s'", + request.Method, request.URL.Path), + Reason: "The request body is empty but there is a schema defined", + SpecLine: schema.GoLow().Type.KeyNode.Line, + SpecCol: schema.GoLow().Type.KeyNode.Line, + SchemaValidationErrors: []*errors.SchemaValidationFailure{violation}, + HowToFix: errors.HowToFixInvalidSchema, + Context: string(renderedSchema), // attach the rendered schema to the error + }) + return false, validationErrors } compiler := jsonschema.NewCompiler() diff --git a/validator.go b/validator.go index db3b48a..da9495d 100644 --- a/validator.go +++ b/validator.go @@ -198,6 +198,7 @@ func (v *validator) ValidateHttpRequest(request *http.Request) (bool, []*errors. paramValidator.ValidateCookieParams, paramValidator.ValidateHeaderParams, paramValidator.ValidateQueryParams, + paramValidator.ValidateSecurity, } // listen for validation errors on parameters. everything will run async. diff --git a/validator_test.go b/validator_test.go index 9339479..c10269a 100644 --- a/validator_test.go +++ b/validator_test.go @@ -702,6 +702,7 @@ func TestNewValidator_PetStore_PetGet200_Valid(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "https://hyperspace-superherbs.com/pet/12345", nil) request.Header.Set("Content-Type", "application/json") + request.Header.Set("api_key", "12345") // simulate a request/response, in this case the contract returns a 200 with the pet we just created. res := httptest.NewRecorder() @@ -779,6 +780,7 @@ func TestNewValidator_PetStore_PetGet200(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "https://hyperspace-superherbs.com/pet/112233", nil) request.Header.Set("Content-Type", "application/json") + request.Header.Set("api_key", "12345") // simulate a request/response, in this case the contract returns a 200 with the pet we just created. res := httptest.NewRecorder() @@ -825,6 +827,7 @@ func TestNewValidator_PetStore_PetGet200_ServerBadMediaType(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "https://hyperspace-superherbs.com/pet/112233", nil) request.Header.Set("Content-Type", "application/json") + request.Header.Set("api_key", "12345") // simulate a request/response, in this case the contract returns a 200 with the pet we just created. res := httptest.NewRecorder()