From c72baba30466c7c601714383072452b8851b43d9 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Thu, 27 Nov 2025 22:01:35 +0100 Subject: [PATCH] wip api schema validation --- go.mod | 5 + go.sum | 10 + pkg/appsec/api_validation/api_validation.go | 265 +++++++++++++ .../api_validation/api_validation_test.go | 362 ++++++++++++++++++ .../api_validation/test_schemas/api_key.yaml | 22 ++ .../api_validation/test_schemas/basic.yaml | 15 + .../test_schemas/basic_auth.yaml | 21 + .../test_schemas/bearer_auth.yaml | 21 + .../api_validation/test_schemas/invalid.yaml | 1 + .../api_validation/test_schemas/jwks.yaml | 23 ++ pkg/appsec/appsec.go | 29 ++ pkg/appsec/waf_helpers.go | 4 + 12 files changed, 778 insertions(+) create mode 100644 pkg/appsec/api_validation/api_validation.go create mode 100644 pkg/appsec/api_validation/api_validation_test.go create mode 100644 pkg/appsec/api_validation/test_schemas/api_key.yaml create mode 100644 pkg/appsec/api_validation/test_schemas/basic.yaml create mode 100644 pkg/appsec/api_validation/test_schemas/basic_auth.yaml create mode 100644 pkg/appsec/api_validation/test_schemas/bearer_auth.yaml create mode 100644 pkg/appsec/api_validation/test_schemas/invalid.yaml create mode 100644 pkg/appsec/api_validation/test_schemas/jwks.yaml diff --git a/go.mod b/go.mod index 3bb0a9bfef4..56a187ac261 100644 --- a/go.mod +++ b/go.mod @@ -136,6 +136,7 @@ require ( github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -192,11 +193,14 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/oklog/run v1.0.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/pierrec/lz4/v4 v4.1.18 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -222,6 +226,7 @@ require ( github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/zclconf/go-cty v1.14.4 // indirect diff --git a/go.sum b/go.sum index 49775536f42..417d517530a 100644 --- a/go.sum +++ b/go.sum @@ -172,6 +172,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= github.com/gin-contrib/gzip v1.2.3 h1:dAhT722RuEG330ce2agAs75z7yB+NKvX/ZM1r8w0u2U= github.com/gin-contrib/gzip v1.2.3/go.mod h1:ad72i4Bzmaypk8M762gNXa2wkxxjbz0icRNnuLJ9a/c= github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= @@ -450,6 +452,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -464,6 +470,8 @@ github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq5 github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI= github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -588,6 +596,8 @@ github.com/wasilibs/go-re2 v1.10.0 h1:vQZEBYZOCA9jdBMmrO4+CvqyCj0x4OomXTJ4a5/urQ github.com/wasilibs/go-re2 v1.10.0/go.mod h1:k+5XqO2bCJS+QpGOnqugyfwC04nw0jaglmjrrkG8U6o= github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb h1:gQ+ZV4wJke/EBKYciZ2MshEouEHFuinB85dY3f5s1q8= github.com/wasilibs/wazero-helpers v0.0.0-20250123031827-cd30c44769bb/go.mod h1:jMeV4Vpbi8osrE/pKUxRZkVaA0EX7NZN0A9/oRzgpgY= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= diff --git a/pkg/appsec/api_validation/api_validation.go b/pkg/appsec/api_validation/api_validation.go new file mode 100644 index 00000000000..a6b09a7da2a --- /dev/null +++ b/pkg/appsec/api_validation/api_validation.go @@ -0,0 +1,265 @@ +package apivalidation + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + legacyrouter "github.com/getkin/kin-openapi/routers/legacy" + "github.com/golang-jwt/jwt/v4" // We use v4 because gin-jwt uses it + log "github.com/sirupsen/logrus" +) + +const ( + ExtensionJWKSURI = "x-crowdsec-jwks_uri" +) + +var ( + ErrInvalidSchemaName = errors.New("invalid schema name") +) + +type Foo struct { + Schema *openapi3.T + Router routers.Router +} + +type RequestValidator struct { + loaders map[string]*openapi3.Loader + openAPISchemas map[string]Foo + logger *log.Entry +} + +func NewRequestValidator(logger *log.Entry) *RequestValidator { + return &RequestValidator{ + loaders: make(map[string]*openapi3.Loader), + openAPISchemas: make(map[string]Foo), + logger: logger, + } +} + +func (rv *RequestValidator) validateJWTToken(token string, jwksURI string) error { + rv.logger.Debugf("validating JWT token with JWKS URI %s", jwksURI) + + _, err := jwt.Parse(token, nil) + if err != nil { + return fmt.Errorf("invalid JWT token: %v", err) + } + + return nil +} + +func (rv *RequestValidator) authFunc(ctx context.Context, input *openapi3filter.AuthenticationInput) error { + authTokenValue := "" + switch input.SecurityScheme.Type { + case "http": + switch input.SecurityScheme.Scheme { + case "basic": + values := input.RequestValidationInput.Request.Header["Authorization"] + if len(values) == 0 { + return fmt.Errorf("authorization header not found") + } + if len(values) > 1 { + return fmt.Errorf("multiple Authorization headers found") + } + if !strings.HasPrefix(values[0], "Basic ") { + return fmt.Errorf("authorization header does not start with 'Basic '") + } + authTokenValue = values[0][6:] + case "bearer": + values := input.RequestValidationInput.Request.Header["Authorization"] + if len(values) == 0 { + return fmt.Errorf("authorization header not found") + } + if len(values) > 1 { + return fmt.Errorf("multiple Authorization headers found") + } + if !strings.HasPrefix(values[0], "Bearer ") { + return fmt.Errorf("authorization header does not start with 'Bearer '") + } + authTokenValue = values[0][7:] + } + case "apiKey": + switch input.SecurityScheme.In { + case "query": + //FIXME: we probably want a more lax version + values := input.RequestValidationInput.Request.URL.Query()[input.SecurityScheme.Name] + if len(values) == 0 { + return fmt.Errorf("query parameter %s not found", input.SecurityScheme.Name) + } + if len(values) > 1 { + return fmt.Errorf("multiple query parameters with name %s found", input.SecurityScheme.Name) + } + authTokenValue = values[0] + case "header": + canonicalHeaderName := http.CanonicalHeaderKey(input.SecurityScheme.Name) + values := input.RequestValidationInput.Request.Header[canonicalHeaderName] + if len(values) == 0 { + return fmt.Errorf("header %s not found", input.SecurityScheme.Name) + } + if len(values) > 1 { + return fmt.Errorf("multiple headers with name %s found", input.SecurityScheme.Name) + } + authTokenValue = values[0] + case "cookie": + cookieValues := input.RequestValidationInput.Request.CookiesNamed(input.SecurityScheme.Name) + if len(cookieValues) == 0 { + return fmt.Errorf("cookie %s not found", input.SecurityScheme.Name) + } + if len(cookieValues) > 1 { + return fmt.Errorf("multiple cookies with name %s found", input.SecurityScheme.Name) + } + authTokenValue = cookieValues[0].Value + default: + return fmt.Errorf("unsupported apiKey location %s", input.SecurityScheme.In) + } + case "oauth2": + rv.logger.Warnf("oauth2 security scheme not supported") + case "openIdConnect": + rv.logger.Warnf("openIdConnect security scheme not supported") + default: + return fmt.Errorf("unsupported security scheme type %s", input.SecurityScheme.Type) + } + if authTokenValue == "" { + return fmt.Errorf("auth token is required but not provided") + } + + // If a JWKS URI is provided, attempt to validate the token + jwksURI := input.SecurityScheme.Extensions[ExtensionJWKSURI] + if jwksURI == nil { + // no JWKS URI, we can't validate the token + return nil + } + jwksURIStr, ok := jwksURI.(string) + if !ok { + return fmt.Errorf("invalid JWKS URI, expected string: %v", jwksURI) + } + + if err := rv.validateJWTToken(authTokenValue, jwksURIStr); err != nil { + return err + } + + return nil +} + +func (rv *RequestValidator) LoadSchema(ref string, schema string) error { + if ref == "" { + return fmt.Errorf("ref cannot be empty") + } + rv.logger.Debugf("loading schema for ref %s", ref) + + if _, exists := rv.loaders[ref]; exists { + return fmt.Errorf("attempting to load a new schema for existing ref %s", ref) + } + + loader := openapi3.NewLoader() + rv.loaders[ref] = loader + + doc, err := loader.LoadFromData([]byte(schema)) + if err != nil { + return err + } + + // Is it a valid OpenAPI schema? + // FIXME: look into opts + if err := doc.Validate(loader.Context, openapi3.DisableExamplesValidation()); err != nil { + return err + } + + router, err := legacyrouter.NewRouter(doc) + if err != nil { + return fmt.Errorf("failed to create router for schema ref %s: %w", ref, err) + } + + rv.openAPISchemas[ref] = Foo{ + Schema: doc, + Router: router, + } + + rv.logger.Infof("loaded schema for ref %s", ref) + return nil +} + +func (rv *RequestValidator) ValidateRequest(ref string, r *http.Request) error { + ctx := context.TODO() + + schemaData, exists := rv.openAPISchemas[ref] + if !exists { + return fmt.Errorf("%w: no schema loaded for ref %s", ErrInvalidSchemaName, ref) + } + + rv.logger.Debugf("validating request for ref %s", ref) + + route, pathParam, err := schemaData.Router.FindRoute(r) + if err != nil { + //FIXME: allow the user to configure the behavior if no matching route is found: + // - Ignore the error and return + // - Drop the request + + // From the kin-openapi package: + // // ErrPathNotFound is returned when no route match is found + // var ErrPathNotFound error = &RouteError{"no matching operation was found"} + + // ErrMethodNotAllowed is returned when no method of the matched route matches + //var ErrMethodNotAllowed error = &RouteError{"method not allowed"} + + return fmt.Errorf("failed to find route for request: %w (error type: %T)", err, err) + } + + input := &openapi3filter.RequestValidationInput{ + Request: r, + QueryParams: r.URL.Query(), //FIXME: we probably want a more lax version + Route: route, + PathParams: pathParam, + Options: &openapi3filter.Options{ + // If true, all validation errors are returned. Should we stop at the 1st one ? + // Having to deal with multiple error will make creating a user-friendly event harder + MultiError: false, + AuthenticationFunc: rv.authFunc, + }, + } + + //FIXME: this will automatically parse the request body + // The supported content types are: + //// RegisterBodyDecoder("application/json", JSONBodyDecoder) + //RegisterBodyDecoder("application/json-patch+json", JSONBodyDecoder) + //RegisterBodyDecoder("application/ld+json", JSONBodyDecoder) + //RegisterBodyDecoder("application/hal+json", JSONBodyDecoder) + //RegisterBodyDecoder("application/vnd.api+json", JSONBodyDecoder) + //RegisterBodyDecoder("application/octet-stream", FileBodyDecoder) + //RegisterBodyDecoder("application/problem+json", JSONBodyDecoder) + //RegisterBodyDecoder("application/x-www-form-urlencoded", urlencodedBodyDecoder) + //RegisterBodyDecoder("application/x-yaml", yamlBodyDecoder) + //RegisterBodyDecoder("application/yaml", yamlBodyDecoder) + //RegisterBodyDecoder("application/zip", zipFileBodyDecoder) + //RegisterBodyDecoder("multipart/form-data", multipartBodyDecoder) + //RegisterBodyDecoder("text/csv", csvBodyDecoder) + //RegisterBodyDecoder("text/plain", plainBodyDecoder) + + // THe zip decoder seems a bit dangerous, as it does not seem to have protection against zip bombs + // Let's just disable it for now, we'll see what we want to do when the vuln is fixed + openapi3filter.UnregisterBodyDecoder("application/zip") + + err = openapi3filter.ValidateRequest(ctx, input) + if err == nil { + return nil + } + requestError := &openapi3filter.RequestError{} + if errors.As(err, &requestError) { + // This is a validation error + // We can extract more information from it if needed + return err + } + securityRequirementError := &openapi3filter.SecurityRequirementsError{} + if errors.As(err, &securityRequirementError) { + return err + } + // Some other error + rv.logger.Debugf("request validation error: %s (type %T)", err.Error(), err) + + return err +} diff --git a/pkg/appsec/api_validation/api_validation_test.go b/pkg/appsec/api_validation/api_validation_test.go new file mode 100644 index 00000000000..e484043de83 --- /dev/null +++ b/pkg/appsec/api_validation/api_validation_test.go @@ -0,0 +1,362 @@ +package apivalidation + +import ( + "io" + "net/http" + "os" + "path/filepath" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) + +func TestLoadSchema(t *testing.T) { + tests := []struct { + name string + schemaName string + ref string + wantErr bool + }{ + { + name: "invalid schema", + schemaName: "invalid", + wantErr: true, + ref: "", + }, + { + name: "empty ref", + schemaName: "basic", + ref: "", + wantErr: true, + }, + { + name: "valid schema", + schemaName: "basic", + ref: "basic", + wantErr: false, + }, + } + + logger := log.New().WithField("test", "apivalidation") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv := NewRequestValidator(logger) + schemaFile, err := os.Open(filepath.Join(".", "test_schemas", tt.schemaName+".yaml")) + require.NoError(t, err) + defer schemaFile.Close() + schemaBytes, err := io.ReadAll(schemaFile) + require.NoError(t, err) + + err = rv.LoadSchema(tt.ref, string(schemaBytes)) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestValidateRequest(t *testing.T) { + + tests := []struct { + name string + schemaName string + ref string + wantErr bool + expectedErr string + request func() *http.Request + }{ + { + name: "invalid ref", + schemaName: "basic", + ref: "invalid", + wantErr: true, + expectedErr: "no matching operation was found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + return req + }, + }, + { + name: "valid request", + schemaName: "basic", + ref: "basic", + wantErr: false, + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/ping", nil) + return req + }, + }, + { + name: "basic auth - valid header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: false, + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.SetBasicAuth("foo", "bar") + return req + }, + }, + { + name: "basic auth - missing header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header not found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + return req + }, + }, + { + name: "basic auth - invalid header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header does not start with 'Basic '", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.Header.Set("Authorization", "asd") + return req + }, + }, + { + name: "basic auth - multiple headers", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: multiple Authorization headers found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.Header["Authorization"] = []string{"Basic foo", "Basic bar"} + return req + }, + }, + } + + logger := log.New().WithField("test", "apivalidation") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv := NewRequestValidator(logger) + schemaFile, err := os.Open(filepath.Join(".", "test_schemas", tt.schemaName+".yaml")) + require.NoError(t, err) + defer schemaFile.Close() + schemaBytes, err := io.ReadAll(schemaFile) + require.NoError(t, err) + + err = rv.LoadSchema(tt.ref, string(schemaBytes)) + require.NoError(t, err) + + err = rv.ValidateRequest(tt.ref, tt.request()) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestSecurityRequirements(t *testing.T) { + tests := []struct { + name string + schemaName string + ref string + wantErr bool + expectedErr string + request func() *http.Request + }{ + { + name: "basic auth - valid header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: false, + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.SetBasicAuth("foo", "bar") + return req + }, + }, + { + name: "basic auth - missing header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header not found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + return req + }, + }, + { + name: "basic auth - invalid header", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header does not start with 'Basic '", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.Header.Set("Authorization", "asd") + return req + }, + }, + { + name: "basic auth - multiple headers", + schemaName: "basic_auth", + ref: "basic_auth", + wantErr: true, + expectedErr: "security requirements failed: multiple Authorization headers found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/basic", nil) + req.Header["Authorization"] = []string{"Basic foo", "Basic bar"} + return req + }, + }, + { + name: "bearer token - valid header", + schemaName: "bearer_auth", + ref: "bearer_auth", + wantErr: false, + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/bearer", nil) + req.Header.Set("Authorization", "Bearer foo") + return req + }, + }, + { + name: "bearer token - missing header", + schemaName: "bearer_auth", + ref: "bearer_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header not found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/bearer", nil) + return req + }, + }, + { + name: "bearer token - invalid header", + schemaName: "bearer_auth", + ref: "bearer_auth", + wantErr: true, + expectedErr: "security requirements failed: authorization header does not start with 'Bearer '", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/bearer", nil) + req.Header.Set("Authorization", "asd") + return req + }, + }, + { + name: "bearer token - multiple headers", + schemaName: "bearer_auth", + ref: "bearer_auth", + wantErr: true, + expectedErr: "security requirements failed: multiple Authorization headers found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/bearer", nil) + req.Header["Authorization"] = []string{"Bearer foo", "Bearer bar"} + return req + }, + }, + { + name: "api key - valid header", + schemaName: "api_key", + ref: "api_key", + wantErr: false, + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/apikey", nil) + req.Header.Set("X-API-key", "foo") + return req + }, + }, + { + name: "api key - missing header", + schemaName: "api_key", + ref: "api_key", + wantErr: true, + expectedErr: "security requirements failed: header x-api-key not found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/apikey", nil) + return req + }, + }, + { + name: "api key - multiple headers", + schemaName: "api_key", + ref: "api_key", + wantErr: true, + expectedErr: "security requirements failed: multiple headers with name x-api-key found", + request: func() *http.Request { + req, _ := http.NewRequest(http.MethodGet, "http://example.com/apikey", nil) + req.Header.Add("X-API-Key", "foo") + req.Header.Add("X-api-Key", "bar") + return req + }, + }, + } + + logger := log.New().WithField("test", "apivalidation") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv := NewRequestValidator(logger) + schemaFile, err := os.Open(filepath.Join(".", "test_schemas", tt.schemaName+".yaml")) + require.NoError(t, err) + defer schemaFile.Close() + schemaBytes, err := io.ReadAll(schemaFile) + require.NoError(t, err) + + err = rv.LoadSchema(tt.ref, string(schemaBytes)) + require.NoError(t, err) + + err = rv.ValidateRequest(tt.ref, tt.request()) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestJWKSValidation(t *testing.T) { + tests := []struct { + name string + schemaName string + ref string + wantErr bool + expectedErr string + request func() *http.Request + }{} + + logger := log.New().WithField("test", "apivalidation") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rv := NewRequestValidator(logger) + schemaFile, err := os.Open(filepath.Join(".", "test_schemas", tt.schemaName+".yaml")) + require.NoError(t, err) + defer schemaFile.Close() + schemaBytes, err := io.ReadAll(schemaFile) + require.NoError(t, err) + + err = rv.LoadSchema(tt.ref, string(schemaBytes)) + require.NoError(t, err) + + err = rv.ValidateRequest(tt.ref, tt.request()) + if tt.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/pkg/appsec/api_validation/test_schemas/api_key.yaml b/pkg/appsec/api_validation/test_schemas/api_key.yaml new file mode 100644 index 00000000000..22c1b0097db --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/api_key.yaml @@ -0,0 +1,22 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: x-api-key + + +paths: + /apikey: + get: + summary: test API key auth + security: + - ApiKeyAuth: [] + responses: + "200": + description: OK \ No newline at end of file diff --git a/pkg/appsec/api_validation/test_schemas/basic.yaml b/pkg/appsec/api_validation/test_schemas/basic.yaml new file mode 100644 index 00000000000..32ea4ae8a4e --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/basic.yaml @@ -0,0 +1,15 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 + +paths: + /ping: + get: + summary: Checks if the server is running + responses: + "200": + description: Server is up and running + default: + description: Something is wrong \ No newline at end of file diff --git a/pkg/appsec/api_validation/test_schemas/basic_auth.yaml b/pkg/appsec/api_validation/test_schemas/basic_auth.yaml new file mode 100644 index 00000000000..d0eca306d25 --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/basic_auth.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic + + +paths: + /basic: + get: + summary: test basic auth + security: + - BasicAuth: [] + responses: + "200": + description: OK \ No newline at end of file diff --git a/pkg/appsec/api_validation/test_schemas/bearer_auth.yaml b/pkg/appsec/api_validation/test_schemas/bearer_auth.yaml new file mode 100644 index 00000000000..95113313b3b --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/bearer_auth.yaml @@ -0,0 +1,21 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + + +paths: + /bearer: + get: + summary: test bearer auth + security: + - BearerAuth: [] + responses: + "200": + description: OK \ No newline at end of file diff --git a/pkg/appsec/api_validation/test_schemas/invalid.yaml b/pkg/appsec/api_validation/test_schemas/invalid.yaml new file mode 100644 index 00000000000..09ab8a9ca16 --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/invalid.yaml @@ -0,0 +1 @@ +foo: \ No newline at end of file diff --git a/pkg/appsec/api_validation/test_schemas/jwks.yaml b/pkg/appsec/api_validation/test_schemas/jwks.yaml new file mode 100644 index 00000000000..4a213d07904 --- /dev/null +++ b/pkg/appsec/api_validation/test_schemas/jwks.yaml @@ -0,0 +1,23 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 +components: + securitySchemes: + JWKSCheck: + type: apiKey + in: header + name: x-api-key + x-crowdsec-jwks_uri: https://example.com/.well-known/jwks.json + + +paths: + /basic: + get: + summary: test basic auth + security: + - JWKSCheck: [] + responses: + "200": + description: OK \ No newline at end of file diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index 741b9893a2b..ff72feefe2b 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -9,6 +9,7 @@ import ( "strings" corazatypes "github.com/corazawaf/coraza/v3/types" + apivalidation "github.com/crowdsecurity/crowdsec/pkg/appsec/api_validation" "github.com/expr-lang/expr" "github.com/expr-lang/expr/vm" log "github.com/sirupsen/logrus" @@ -181,6 +182,8 @@ type AppsecRuntimeConfig struct { DisabledOutOfBandRuleIds []int DisabledOutOfBandRulesTags []string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME + + RequestValidator *apivalidation.RequestValidator } type AppsecConfig struct { @@ -372,6 +375,8 @@ func (*AppsecConfig) GetDataDir() string { func (wc *AppsecConfig) Build() (*AppsecRuntimeConfig, error) { ret := &AppsecRuntimeConfig{Logger: wc.Logger.WithField("component", "appsec_runtime_config")} + ret.RequestValidator = apivalidation.NewRequestValidator(wc.Logger.WithField("component", "api_validator")) + if wc.BouncerBlockedHTTPCode == 0 { wc.BouncerBlockedHTTPCode = http.StatusForbidden } @@ -878,3 +883,27 @@ func (w *AppsecRuntimeConfig) GenerateResponse(response AppsecTempResponse, logg return bouncerStatusCode, resp } + +func (w *AppsecRuntimeConfig) LoadAPISchemaWithName(ref string, schemaPath string) error { + //FIXME: should be relative to data dir + w.Logger.Debugf("loading schema %s for ref %s", schemaPath, ref) + f, err := os.Open(schemaPath) + if err != nil { + return fmt.Errorf("unable to open schema file %s : %s", schemaPath, err) + } + defer f.Close() + schema, err := os.ReadFile(schemaPath) + if err != nil { + return fmt.Errorf("unable to read schema file %s : %s", schemaPath, err) + } + return w.RequestValidator.LoadSchema(ref, string(schema)) +} + +func (w *AppsecRuntimeConfig) ValidateRequestWithSchema(state *AppsecRequestState, ref string, r *http.Request, parsedRequest *ParsedRequest) error { + err := w.RequestValidator.ValidateRequest(ref, r) + if err != nil { + w.Logger.Errorf("request validation failed: %s", err) + return w.DropRequest(state, parsedRequest, fmt.Sprintf("request validation failed: %s", err)) + } + return nil +} diff --git a/pkg/appsec/waf_helpers.go b/pkg/appsec/waf_helpers.go index 7da4eba2398..be79a720339 100644 --- a/pkg/appsec/waf_helpers.go +++ b/pkg/appsec/waf_helpers.go @@ -1,6 +1,8 @@ package appsec import ( + "net/http" + "github.com/crowdsecurity/crowdsec/pkg/pipeline" ) @@ -15,6 +17,7 @@ func GetOnLoadEnv(w *AppsecRuntimeConfig) map[string]interface{} { "SetRemediationByTag": w.SetActionByTag, "SetRemediationByID": w.SetActionByID, "SetRemediationByName": w.SetActionByName, + "LoadAPISchemaWithName": w.LoadAPISchemaWithName, } } @@ -41,6 +44,7 @@ func GetPreEvalEnv(w *AppsecRuntimeConfig, state *AppsecRequestState, request *P state.PendingHTTPCode = &code return nil }, + "ValidateRequestWithSchema": func(ref string, r *http.Request) error { return w.ValidateRequestWithSchema(state, ref, r, request) }, } }