Skip to content
Draft
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
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
265 changes: 265 additions & 0 deletions pkg/appsec/api_validation/api_validation.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading