From da097397c0224d5f7bded62152b64db01d178bf8 Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Mon, 30 Jun 2025 17:08:11 +0200 Subject: [PATCH 01/18] =?UTF-8?q?=E2=9C=A8=20Add=20wicketkeeper=20captcha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bouncer.go | 8 +++++++- captcha.html | 7 ++++++- pkg/captcha/captcha.go | 12 +++++++++++- pkg/configuration/configuration.go | 5 +++-- 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/bouncer.go b/bouncer.go index 2170c06..7089280 100644 --- a/bouncer.go +++ b/bouncer.go @@ -205,11 +205,17 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam ) config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey") config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") + tlsConfig2 := new(tls.Config) + tlsConfig2.InsecureSkipVerify = true err = bouncer.captchaClient.New( log, bouncer.cacheClient, &http.Client{ - Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, + Transport: &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + TLSClientConfig: tlsConfig2, + }, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, config.CaptchaProvider, diff --git a/captcha.html b/captcha.html index bf7a8af..b7df9b1 100644 --- a/captcha.html +++ b/captcha.html @@ -294,7 +294,12 @@

CrowdSec Captcha

-
+
diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 3698340..5ec3adf 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -31,6 +31,7 @@ type Client struct { type infoProvider struct { js string key string + response string validate string } @@ -40,18 +41,27 @@ var ( configuration.HcaptchaProvider: { js: "https://hcaptcha.com/1/api.js", key: "h-captcha", + response: "h-captcha-response", validate: "https://api.hcaptcha.com/siteverify", }, configuration.RecaptchaProvider: { js: "https://www.google.com/recaptcha/api.js", key: "g-recaptcha", + response: "g-recaptcha-response", validate: "https://www.google.com/recaptcha/api/siteverify", }, configuration.TurnstileProvider: { js: "https://challenges.cloudflare.com/turnstile/v0/api.js", key: "cf-turnstile", + response: "cf-turnstile-response", validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", }, + configuration.WicketkeeperProvider: { + js: "https://captcha.max.lan/fast.js", + key: "wicketkeeper", + response: "wicketkeeper_solution", + validate: "https://captcha.max.lan/v0/siteverify", + }, } ) @@ -121,7 +131,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) { c.log.Debug("captcha:Validate invalid method: " + r.Method) return false, nil } - var response = r.FormValue(captcha[c.provider].key + "-response") + var response = r.FormValue(captcha[c.provider].response) if response == "" { c.log.Debug("captcha:Validate no captcha response found in request") return false, nil diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 483e8a2..b1f50d6 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -34,6 +34,7 @@ const ( HcaptchaProvider = "hcaptcha" RecaptchaProvider = "recaptcha" TurnstileProvider = "turnstile" + WicketkeeperProvider = "wicketkeeper" ) // Config the plugin configuration. @@ -362,8 +363,8 @@ func validateParamsRequired(config *Config) error { if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'") } - if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) { - return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'") + if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, WicketkeeperProvider}, config.CaptchaProvider) { + return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha', 'turnstile' or 'wicketkeeper'") } return nil } From 7adc57941ed0f6529b5ac706888e854534d163fb Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Mon, 4 Aug 2025 20:25:06 +0200 Subject: [PATCH 02/18] :sparkles: Anom config --- README.md | 14 +++++- bouncer.go | 12 +++++ pkg/captcha/captcha.go | 70 ++++++++++++++---------------- pkg/configuration/configuration.go | 40 ++++++++++++++--- 4 files changed, 91 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index da74f1a..fe5d758 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,19 @@ make run - Used only in `alone` mode, scenarios for Crowdsec CAPI - CaptchaProvider - string - - Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` + - Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom` +- CaptchaCustomJsURL + - string + - If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`) +- CaptchaCustomValidateURL + - string + - If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`) +- CaptchaCustomKey + - string + - If CaptchaProvider is `custom`, used to set classname of the div used by captcha provider (in case of hcaptcha: `h-captcha`) +- CaptchaCustomResponse + - string + - If CaptchaProvider is `custom`, used to set the field in the validate URL body (in case of hcaptcha: `h-captcha-response`) - CaptchaSiteKey - string - Site key for the captcha provider diff --git a/bouncer.go b/bouncer.go index 6771723..22c4d4c 100644 --- a/bouncer.go +++ b/bouncer.go @@ -234,6 +234,17 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") tlsConfig2 := new(tls.Config) tlsConfig2.InsecureSkipVerify = true + + var infoProvider *captcha.InfoProvider + if config.CaptchaProvider == configuration.CustomProvider { + infoProvider = &captcha.InfoProvider{ + js: config.CaptchaCustomJsURL, + validate: config.CaptchaCustomValidateURL, + key: config.CaptchaCustomKey, + response: config.CaptchaCustomResponse, + } + } + err = bouncer.captchaClient.New( log, bouncer.cacheClient, @@ -245,6 +256,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam }, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, + infoProvider, config.CaptchaProvider, config.CaptchaSiteKey, config.CaptchaSecretKey, diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 5ec3adf..0c56739 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -17,7 +17,6 @@ import ( // Client Captcha client. type Client struct { Valid bool - provider string siteKey string secretKey string remediationCustomHeader string @@ -26,54 +25,51 @@ type Client struct { cacheClient *cache.Client httpClient *http.Client log *logger.Log + infoProvider *InfoProvider } -type infoProvider struct { +// InfoProvider Information for self-hosted provider. +type InfoProvider struct { js string key string response string validate string } -var ( - //nolint:gochecknoglobals - captcha = map[string]infoProvider{ - configuration.HcaptchaProvider: { - js: "https://hcaptcha.com/1/api.js", - key: "h-captcha", - response: "h-captcha-response", - validate: "https://api.hcaptcha.com/siteverify", - }, - configuration.RecaptchaProvider: { - js: "https://www.google.com/recaptcha/api.js", - key: "g-recaptcha", - response: "g-recaptcha-response", - validate: "https://www.google.com/recaptcha/api/siteverify", - }, - configuration.TurnstileProvider: { - js: "https://challenges.cloudflare.com/turnstile/v0/api.js", - key: "cf-turnstile", - response: "cf-turnstile-response", - validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", - }, - configuration.WicketkeeperProvider: { - js: "https://captcha.max.lan/fast.js", - key: "wicketkeeper", - response: "wicketkeeper_solution", - validate: "https://captcha.max.lan/v0/siteverify", - }, - } -) +//nolint:gochecknoglobals +var infoProviders = map[string]*InfoProvider{ + configuration.HcaptchaProvider: &InfoProvider{ + js: "https://hcaptcha.com/1/api.js", + key: "h-captcha", + response: "h-captcha-response", + validate: "https://api.hcaptcha.com/siteverify", + }, + configuration.RecaptchaProvider: &InfoProvider{ + js: "https://www.google.com/recaptcha/api.js", + key: "g-recaptcha", + response: "g-recaptcha-response", + validate: "https://www.google.com/recaptcha/api/siteverify", + }, + configuration.TurnstileProvider: &InfoProvider{ + js: "https://challenges.cloudflare.com/turnstile/v0/api.js", + key: "cf-turnstile", + response: "cf-turnstile-response", + validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + }, +} // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } + c.infoProvider = infoProvider + if c.infoProvider == nil { + c.infoProvider = infoProviders[provider] + } c.siteKey = siteKey c.secretKey = secretKey - c.provider = provider c.remediationCustomHeader = remediationCustomHeader html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) c.captchaTemplate = html @@ -105,8 +101,8 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str rw.WriteHeader(http.StatusOK) err = c.captchaTemplate.Execute(rw, map[string]string{ "SiteKey": c.siteKey, - "FrontendJS": captcha[c.provider].js, - "FrontendKey": captcha[c.provider].key, + "FrontendJS": c.infoProvider.js, + "FrontendKey": c.infoProvider.key, }) if err != nil { c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error()) @@ -131,7 +127,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) { c.log.Debug("captcha:Validate invalid method: " + r.Method) return false, nil } - var response = r.FormValue(captcha[c.provider].response) + var response = r.FormValue(c.infoProvider.response) if response == "" { c.log.Debug("captcha:Validate no captcha response found in request") return false, nil @@ -139,7 +135,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) { var body = url.Values{} body.Add("secret", c.secretKey) body.Add("response", response) - res, err := c.httpClient.PostForm(captcha[c.provider].validate, body) + res, err := c.httpClient.PostForm(c.infoProvider.validate, body) if err != nil { return false, err } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index e764b49..3da9164 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -34,7 +34,7 @@ const ( HcaptchaProvider = "hcaptcha" RecaptchaProvider = "recaptcha" TurnstileProvider = "turnstile" - WicketkeeperProvider = "wicketkeeper" + CustomProvider = "custom" ) // Config the plugin configuration. @@ -85,6 +85,10 @@ type Config struct { BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` + CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` + CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` + CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` + CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"` CaptchaSecretKey string `json:"captchaSecretKey,omitempty"` @@ -126,6 +130,10 @@ func New() *Config { RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10, CaptchaProvider: "", + CaptchaCustomJsURL: "", + CaptchaCustomValidateURL: "", + CaptchaCustomKey: "", + CaptchaCustomResponse: "", CaptchaSiteKey: "", CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1800, @@ -196,6 +204,10 @@ func ValidateParams(config *Config) error { if err := validateParamsRequired(config); err != nil { return err } + + if err := validateCaptcha(config); err != nil { + return err + } if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil { return err @@ -332,6 +344,23 @@ func validateParamsIPs(listIP []string, key string) error { return nil } +func validateCaptcha(config *Config) error { + if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) { + return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider) + } + if config.CaptchaProvider == CustomProvider { + if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" { + return fmt.Errorf( + "CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s", + config.CaptchaCustomKey , + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, + config.CaptchaCustomJsURL, + ) + } + } +} + func validateParamsRequired(config *Config) error { requiredStrings := map[string]string{ "CrowdsecLapiScheme": config.CrowdsecLapiScheme, @@ -340,7 +369,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredStrings { if len(val) == 0 { - return fmt.Errorf("%v: cannot be empty", key) + return errors.New(key + ": cannot be empty") } } requiredInt0 := map[string]int64{ @@ -349,7 +378,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt0 { if val < 0 { - return fmt.Errorf("%v: cannot be less than 0", key) + return errors.New(key + ": cannot be less than 0", ) } } requiredInt1 := map[string]int64{ @@ -360,7 +389,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt1 { if val < 1 { - return fmt.Errorf("%v: cannot be less than 1", key) + return errors.New(key + ": cannot be less than 1", ) } } if config.UpdateMaxFailure < -1 { @@ -379,9 +408,6 @@ func validateParamsRequired(config *Config) error { if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'") } - if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, WicketkeeperProvider}, config.CaptchaProvider) { - return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha', 'turnstile' or 'wicketkeeper'") - } return nil } From 854dbc831b8a43d1f6f12ba4bb4f16ad075e3cb4 Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Mon, 4 Aug 2025 20:27:45 +0200 Subject: [PATCH 03/18] :bento: fix readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fe5d758..2238bf9 100644 --- a/README.md +++ b/README.md @@ -469,13 +469,13 @@ make run - CaptchaCustomJsURL - string - If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`) -- CaptchaCustomValidateURL +- CaptchaCustomValidateURL - string - If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`) -- CaptchaCustomKey +- CaptchaCustomKey - string - If CaptchaProvider is `custom`, used to set classname of the div used by captcha provider (in case of hcaptcha: `h-captcha`) -- CaptchaCustomResponse +- CaptchaCustomResponse - string - If CaptchaProvider is `custom`, used to set the field in the validate URL body (in case of hcaptcha: `h-captcha-response`) - CaptchaSiteKey From 16a5ea05be596f6163e0e632185883b37b8a963b Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 08:46:45 +0200 Subject: [PATCH 04/18] :bento: fix lint --- bouncer.go | 14 ++++---------- pkg/configuration/configuration.go | 12 ++++++------ 2 files changed, 10 insertions(+), 16 deletions(-) diff --git a/bouncer.go b/bouncer.go index 22c4d4c..8f65b97 100644 --- a/bouncer.go +++ b/bouncer.go @@ -232,16 +232,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam ) config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey") config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") - tlsConfig2 := new(tls.Config) - tlsConfig2.InsecureSkipVerify = true var infoProvider *captcha.InfoProvider if config.CaptchaProvider == configuration.CustomProvider { infoProvider = &captcha.InfoProvider{ - js: config.CaptchaCustomJsURL, - validate: config.CaptchaCustomValidateURL, - key: config.CaptchaCustomKey, + js: config.CaptchaCustomJsURL, + key: config.CaptchaCustomKey, response: config.CaptchaCustomResponse, + validate: config.CaptchaCustomValidateURL, } } @@ -249,11 +247,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam log, bouncer.cacheClient, &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, - TLSClientConfig: tlsConfig2, - }, + Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, infoProvider, diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 3da9164..66e38ba 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -85,7 +85,7 @@ type Config struct { BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` - CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` + CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` @@ -130,7 +130,7 @@ func New() *Config { RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10, CaptchaProvider: "", - CaptchaCustomJsURL: "", + CaptchaCustomJsURL: "", CaptchaCustomValidateURL: "", CaptchaCustomKey: "", CaptchaCustomResponse: "", @@ -204,7 +204,7 @@ func ValidateParams(config *Config) error { if err := validateParamsRequired(config); err != nil { return err } - + if err := validateCaptcha(config); err != nil { return err } @@ -352,7 +352,7 @@ func validateCaptcha(config *Config) error { if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" { return fmt.Errorf( "CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s", - config.CaptchaCustomKey , + config.CaptchaCustomKey, config.CaptchaCustomResponse, config.CaptchaCustomValidateURL, config.CaptchaCustomJsURL, @@ -378,7 +378,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt0 { if val < 0 { - return errors.New(key + ": cannot be less than 0", ) + return errors.New(key + ": cannot be less than 0") } } requiredInt1 := map[string]int64{ @@ -389,7 +389,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt1 { if val < 1 { - return errors.New(key + ": cannot be less than 1", ) + return errors.New(key + ": cannot be less than 1") } } if config.UpdateMaxFailure < -1 { From cca600d32db5005e176924df8a69dab85a9f660d Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 08:57:08 +0200 Subject: [PATCH 05/18] :bento: fix lint --- bouncer.go | 18 +++++++----------- captcha.html | 7 +------ pkg/captcha/captcha.go | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 21 deletions(-) diff --git a/bouncer.go b/bouncer.go index 8f65b97..9a4a72a 100644 --- a/bouncer.go +++ b/bouncer.go @@ -233,16 +233,6 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey") config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") - var infoProvider *captcha.InfoProvider - if config.CaptchaProvider == configuration.CustomProvider { - infoProvider = &captcha.InfoProvider{ - js: config.CaptchaCustomJsURL, - key: config.CaptchaCustomKey, - response: config.CaptchaCustomResponse, - validate: config.CaptchaCustomValidateURL, - } - } - err = bouncer.captchaClient.New( log, bouncer.cacheClient, @@ -250,7 +240,13 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, - infoProvider, + captcha.GetInfoProvider( + config.CaptchaProvider, + config.CaptchaCustomJsURL, + config.CaptchaCustomKey, + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, + ), config.CaptchaProvider, config.CaptchaSiteKey, config.CaptchaSecretKey, diff --git a/captcha.html b/captcha.html index b7df9b1..bf7a8af 100644 --- a/captcha.html +++ b/captcha.html @@ -294,12 +294,7 @@

CrowdSec Captcha

-
+
diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 0c56739..82b935c 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -58,16 +58,24 @@ var infoProviders = map[string]*InfoProvider{ }, } +// GetInfoProvider Get InfoProvider. +func GetInfoProvider(provider, js, key, response, validate string) *InfoProvider { + var infoProvider InfoProvider + if provider == configuration.CustomProvider { + infoProvider = &captcha.InfoProvider{js, key, response, validate} + } else { + infoProvider = infoProviders[provider] + } + return infoProvider +} + // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } c.infoProvider = infoProvider - if c.infoProvider == nil { - c.infoProvider = infoProviders[provider] - } c.siteKey = siteKey c.secretKey = secretKey c.remediationCustomHeader = remediationCustomHeader From 529964164e3fb718c2216ac851385db9a08bf8bd Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 09:31:48 +0200 Subject: [PATCH 06/18] :bento: normalize --- bouncer.go | 11 ++++------- pkg/captcha/captcha.go | 19 +++++++------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/bouncer.go b/bouncer.go index 9a4a72a..8200096 100644 --- a/bouncer.go +++ b/bouncer.go @@ -240,14 +240,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, - captcha.GetInfoProvider( - config.CaptchaProvider, - config.CaptchaCustomJsURL, - config.CaptchaCustomKey, - config.CaptchaCustomResponse, - config.CaptchaCustomValidateURL, - ), config.CaptchaProvider, + config.CaptchaCustomJsURL, + config.CaptchaCustomKey, + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, config.CaptchaSiteKey, config.CaptchaSecretKey, config.RemediationHeadersCustomName, diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 82b935c..b79991a 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -58,23 +58,18 @@ var infoProviders = map[string]*InfoProvider{ }, } -// GetInfoProvider Get InfoProvider. -func GetInfoProvider(provider, js, key, response, validate string) *InfoProvider { - var infoProvider InfoProvider - if provider == configuration.CustomProvider { - infoProvider = &captcha.InfoProvider{js, key, response, validate} - } else { - infoProvider = infoProviders[provider] - } - return infoProvider -} - // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } + var infoProvider *InfoProvider + if provider == configuration.CustomProvider { + infoProvider = &InfoProvider{js: js, key: key, response: response, validate: validate} + } else { + infoProvider = infoProviders[provider] + } c.infoProvider = infoProvider c.siteKey = siteKey c.secretKey = secretKey From f886720ec91a07b145a315f6bd673bc79913142b Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 09:53:35 +0200 Subject: [PATCH 07/18] :bento: fix lint --- pkg/configuration/configuration.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 66e38ba..a33a884 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -359,6 +359,7 @@ func validateCaptcha(config *Config) error { ) } } + return nil } func validateParamsRequired(config *Config) error { From 1d1d88d576daba53389c9bfb04c74fc8e8db1b9d Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 10:05:31 +0200 Subject: [PATCH 08/18] :bento: fix lint --- pkg/captcha/captcha.go | 6 +++--- pkg/configuration/configuration.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index b79991a..4961a43 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -38,19 +38,19 @@ type InfoProvider struct { //nolint:gochecknoglobals var infoProviders = map[string]*InfoProvider{ - configuration.HcaptchaProvider: &InfoProvider{ + configuration.HcaptchaProvider: { js: "https://hcaptcha.com/1/api.js", key: "h-captcha", response: "h-captcha-response", validate: "https://api.hcaptcha.com/siteverify", }, - configuration.RecaptchaProvider: &InfoProvider{ + configuration.RecaptchaProvider: { js: "https://www.google.com/recaptcha/api.js", key: "g-recaptcha", response: "g-recaptcha-response", validate: "https://www.google.com/recaptcha/api/siteverify", }, - configuration.TurnstileProvider: &InfoProvider{ + configuration.TurnstileProvider: { js: "https://challenges.cloudflare.com/turnstile/v0/api.js", key: "cf-turnstile", response: "cf-turnstile-response", diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index a33a884..0ad4432 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -86,7 +86,7 @@ type Config struct { CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` - CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` + CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` From 75ec932b4960584100807fcfdbc71f064eb50a7b Mon Sep 17 00:00:00 2001 From: maxlerebourg Date: Tue, 1 Jul 2025 21:59:12 +0200 Subject: [PATCH 09/18] =?UTF-8?q?=E2=9C=A8=20Add=20env=20for=20Remediation?= =?UTF-8?q?StatusCode=20(#250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ Add env for defaultStatusCode * 📝 doc * ✨change name of the parameter * 🔧 Add config check * fix lint --- README.md | 11 ++++++----- bouncer.go | 6 ++++-- pkg/configuration/configuration.go | 5 +++++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e4e5e69..7e7b0df 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,10 @@ _By careful when you upgrade to >1.4.x_ - int64 - default: 60 - Used only in `live` mode, maximum decision duration +- RemediationStatusCode + - int + - default: 403 + - HTTP status code for banned user (not captcha) - CrowdsecCapiMachineId - string - Used only in `alone` mode, login for Crowdsec CAPI @@ -518,6 +522,7 @@ http: updateIntervalSeconds: 60 updateMaxFailure: 0 defaultDecisionSeconds: 60 + remediationStatusCode: 403 httpTimeoutSeconds: 10 crowdsecMode: live crowdsecAppsecEnabled: false @@ -527,7 +532,6 @@ http: crowdsecAppsecUnreachableBlock: true crowdsecAppsecBodyLimit: 10485760 crowdsecLapiKey: privateKey-foo - crowdsecLapiKeyFile: /etc/traefik/cs-privateKey-foo crowdsecLapiScheme: http crowdsecLapiHost: crowdsec:8080 crowdsecLapiPath: "/" @@ -556,7 +560,6 @@ http: ... Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT -----END CERTIFICATE----- - crowdsecLapiTLSCertificateAuthorityFile: /etc/traefik/crowdsec-certs/ca.pem crowdsecLapiTLSCertificateBouncer: |- -----BEGIN CERTIFICATE----- MIIEHjCCAwagAwIBAgIUOBTs1eqkaAUcPplztUr2xRapvNAwDQYJKoZIhvcNAQEL @@ -564,14 +567,12 @@ http: RaXAnYYUVRblS1jmePemh388hFxbmrpG2pITx8B5FMULqHoj11o2Rl0gSV6tHIHz N2U= -----END CERTIFICATE----- - crowdsecLapiTLSCertificateBouncerFile: /etc/traefik/crowdsec-certs/bouncer.pem crowdsecLapiTLSCertificateBouncerKey: |- -----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEAtYQnbJqifH+ZymePylDxGGLIuxzcAUU4/ajNj+qRAdI/Ux3d ... ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA= -----END RSA PRIVATE KEY----- - crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem captchaProvider: hcaptcha captchaSiteKey: FIXME captchaSecretKey: FIXME @@ -582,7 +583,7 @@ http: #### Fill variable with value of file -`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` can be provided with the content as raw or through a file path that Traefik can read. +`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read. The file variable will be used as preference if both content and file are provided for the same variable. Format is: diff --git a/bouncer.go b/bouncer.go index 7089280..6f2261b 100644 --- a/bouncer.go +++ b/bouncer.go @@ -77,6 +77,7 @@ type Bouncer struct { updateInterval int64 updateMaxFailure int defaultDecisionTimeout int64 + remediationStatusCode int remediationCustomHeader string forwardedCustomHeader string crowdsecStreamRoute string @@ -170,6 +171,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam remediationCustomHeader: config.RemediationHeadersCustomName, forwardedCustomHeader: config.ForwardedHeadersCustomName, defaultDecisionTimeout: config.DefaultDecisionSeconds, + remediationStatusCode: config.RemediationStatusCode, redisUnreachableBlock: config.RedisCacheUnreachableBlock, banTemplateString: banTemplateString, crowdsecStreamRoute: crowdsecStreamRoute, @@ -361,11 +363,11 @@ func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) { rw.Header().Set(bouncer.remediationCustomHeader, "ban") } if bouncer.banTemplateString == "" { - rw.WriteHeader(http.StatusForbidden) + rw.WriteHeader(bouncer.remediationStatusCode) return } rw.Header().Set("Content-Type", "text/html; charset=utf-8") - rw.WriteHeader(http.StatusForbidden) + rw.WriteHeader(bouncer.remediationStatusCode) _, err := fmt.Fprint(rw, bouncer.banTemplateString) if err != nil { bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter") diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index b1f50d6..465976a 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -69,6 +69,7 @@ type Config struct { UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` UpdateMaxFailure int `json:"updateMaxFailure,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` + RemediationStatusCode int `json:"remediationStatusCode,omitempty"` HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"` ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"` @@ -120,6 +121,7 @@ func New() *Config { UpdateIntervalSeconds: 60, UpdateMaxFailure: 0, DefaultDecisionSeconds: 60, + RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10, CaptchaProvider: "", CaptchaSiteKey: "", @@ -356,6 +358,9 @@ func validateParamsRequired(config *Config) error { if config.CrowdsecAppsecBodyLimit < 0 { return errors.New("CrowdsecAppsecBodyLimit: cannot be less than 0") } + if config.RemediationStatusCode < 100 || config.RemediationStatusCode >= 600 { + return errors.New("RemediationStatusCode: cannot be less than 100 and more than 600") + } if !contains([]string{NoneMode, LiveMode, StreamMode, AloneMode, AppsecMode}, config.CrowdsecMode) { return errors.New("CrowdsecMode: must be one of 'none', 'live', 'stream', 'alone' or 'appsec'") From 3b0f1237063f6d89133f3a652d96879dbece4416 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 2 Jul 2025 11:36:09 +0200 Subject: [PATCH 10/18] =?UTF-8?q?=F0=9F=93=88=20Report=20traffic=20dropped?= =?UTF-8?q?=20metrics=20to=20LAPI=20(#223)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial implementation * fix * fixes * Fixes * xx * progress * xx * xx * xx * fix linter * Progress * Fixes * xx * xx * Remove trace logger * Last fix * fix lint * fix lint * fix lint --------- Co-authored-by: Max Lerebourg --- README.md | 26 +++-- bouncer.go | 158 ++++++++++++++++++++++++----- bouncer_test.go | 4 +- docker-compose.local.yml | 31 +++++- docker-compose.yml | 2 +- pkg/configuration/configuration.go | 17 +++- pkg/logger/logger.go | 40 +++++--- 7 files changed, 226 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 7e7b0df..da74f1a 100644 --- a/README.md +++ b/README.md @@ -308,12 +308,18 @@ make run ### Note -**/!\ Cache is shared by all services** -_This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP_ -Only one instance of the plugin is _possible_. - -**/!\ Appsec maximum body limit is defaulted to 10MB** -_By careful when you upgrade to >1.4.x_ +> [!IMPORTANT] +> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings. +> +> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP +> +> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream. +> +> Overall, this middleware is designed in such a way that **only one instance of the plugin is *possible*.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.) + +> [!WARNING] +> **Appsec maximum body limit is defaulted to 10MB** +> *Be careful when you upgrade to >1.4.x* ### Variables @@ -324,11 +330,16 @@ _By careful when you upgrade to >1.4.x_ - LogLevel - string - default: `INFO`, expected values are: `INFO`, `DEBUG`, `ERROR` - - Log are written to `stdout` / `stderr` of file if LogFilePath is provided + - Log are written to `stdout` / `stderr` or file if LogFilePath is provided - LogFilePath - string - default: "" - File Path to write logs, must be writable by Traefik, Log rotation may require a restart of traefik +- MetricsUpdateIntervalSeconds + - int64 + - default: 600 + - Interval in seconds between metrics updates to Crowdsec + - If set to zero or less, metrics collection is disabled - CrowdsecMode - string - default: `live`, expected values are: `none`, `live`, `stream`, `alone`, `appsec` @@ -579,6 +590,7 @@ http: captchaGracePeriodSeconds: 1800 captchaHTMLFilePath: /captcha.html banHTMLFilePath: /ban.html + metricsUpdateIntervalSeconds: 600 ``` #### Fill variable with value of file diff --git a/bouncer.go b/bouncer.go index 6f2261b..6771723 100644 --- a/bouncer.go +++ b/bouncer.go @@ -12,7 +12,9 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" + "sync/atomic" "text/template" "time" @@ -33,6 +35,7 @@ const ( crowdsecLapiHeader = "X-Api-Key" crowdsecLapiRoute = "v1/decisions" crowdsecLapiStreamRoute = "v1/decisions/stream" + crowdsecLapiMetricsRoute = "v1/usage-metrics" crowdsecCapiHost = "api.crowdsec.net" crowdsecCapiHeader = "Authorization" crowdsecCapiLoginRoute = "v2/watchers/login" @@ -40,12 +43,32 @@ const ( cacheTimeoutKey = "updated" ) +// ############################################################## +// Important: traefik creates an instance of the bouncer per route. +// We rely on globals (both here and in the memory cache) to share info between +// routes. This means that some of the plugins parameters will only work "once" +// and will take the values of the first middleware that was instantiated even +// if you have different middlewares with different parameters. This design +// makes it impossible to have multiple crowdsec implementations per cluster (unless you have multiple traefik deployments in it) +// - updateInterval +// - updateMaxFailure +// - defaultDecisionTimeout +// - redisUnreachableBlock +// - appsecEnabled +// - appsecHost +// - metricsUpdateIntervalSeconds +// - others... +// ################################### + //nolint:gochecknoglobals var ( isStartup = true isCrowdsecStreamHealthy = true - updateFailure = 0 - ticker chan bool + updateFailure int64 + streamTicker chan bool + metricsTicker chan bool + lastMetricsPush time.Time + blockedRequests int64 ) // CreateConfig creates the default plugin configuration. @@ -75,7 +98,7 @@ type Bouncer struct { crowdsecPassword string crowdsecScenarios []string updateInterval int64 - updateMaxFailure int + updateMaxFailure int64 defaultDecisionTimeout int64 remediationStatusCode int remediationCustomHeader string @@ -93,6 +116,8 @@ type Bouncer struct { } // New creates the crowdsec bouncer plugin. +// +//nolint:gocyclo func New(_ context.Context, next http.Handler, config *configuration.Config, name string) (http.Handler, error) { config.LogLevel = strings.ToUpper(config.LogLevel) log := logger.New(config.LogLevel, config.LogFilePath) @@ -231,7 +256,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam return nil, err } - if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil { + if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && streamTicker == nil { if config.CrowdsecMode == configuration.AloneMode { if err := getToken(bouncer); err != nil { bouncer.log.Error("New:getToken " + err.Error()) @@ -240,10 +265,20 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam } handleStreamTicker(bouncer) isStartup = false - ticker = startTicker(config, log, func() { + streamTicker = startTicker("stream", config.UpdateIntervalSeconds, log, func() { handleStreamTicker(bouncer) }) } + + // Start metrics ticker if not already running + if metricsTicker == nil && config.MetricsUpdateIntervalSeconds > 0 { + lastMetricsPush = time.Now() // Initialize lastMetricsPush when starting the metrics ticker + handleMetricsTicker(bouncer) + metricsTicker = startTicker("metrics", config.MetricsUpdateIntervalSeconds, log, func() { + handleMetricsTicker(bouncer) + }) + } + bouncer.log.Debug("New initialized mode:" + config.CrowdsecMode) return bouncer, nil @@ -359,6 +394,8 @@ type Login struct { // To append Headers we need to call rw.WriteHeader after set any header. func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) { + atomic.AddInt64(&blockedRequests, 1) + if bouncer.remediationCustomHeader != "" { rw.Header().Set(bouncer.remediationCustomHeader, "ban") } @@ -381,6 +418,7 @@ func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, handleNextServeHTTP(bouncer, remoteIP, rw, req) return } + atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request. bouncer.captchaClient.ServeHTTP(rw, req, remoteIP) return } @@ -412,11 +450,17 @@ func handleStreamTicker(bouncer *Bouncer) { } } -func startTicker(config *configuration.Config, log *logger.Log, work func()) chan bool { - ticker := time.NewTicker(time.Duration(config.UpdateIntervalSeconds) * time.Second) +func handleMetricsTicker(bouncer *Bouncer) { + if err := reportMetrics(bouncer); err != nil { + bouncer.log.Error("handleMetricsTicker:reportMetrics " + err.Error()) + } +} + +func startTicker(name string, updateInterval int64, log *logger.Log, work func()) chan bool { + ticker := time.NewTicker(time.Duration(updateInterval) * time.Second) stop := make(chan bool, 1) go func() { - defer log.Debug("ticker:stopped") + defer log.Debug(name + "_ticker:stopped") for { select { case <-ticker.C: @@ -438,7 +482,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) { Path: bouncer.crowdsecPath + crowdsecLapiRoute, RawQuery: fmt.Sprintf("ip=%v", remoteIP), } - body, err := crowdsecQuery(bouncer, routeURL.String(), false) + body, err := crowdsecQuery(bouncer, routeURL.String(), nil) if err != nil { return cache.BannedValue, err } @@ -497,7 +541,16 @@ func getToken(bouncer *Bouncer) error { Host: bouncer.crowdsecHost, Path: crowdsecCapiLoginRoute, } - body, err := crowdsecQuery(bouncer, loginURL.String(), true) + + // Move the login-specific payload here + loginData := []byte(fmt.Sprintf( + `{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`, + bouncer.crowdsecMachineID, + bouncer.crowdsecPassword, + strings.Join(bouncer.crowdsecScenarios, `","`), + )) + + body, err := crowdsecQuery(bouncer, loginURL.String(), loginData) if err != nil { return err } @@ -534,7 +587,7 @@ func handleStreamCache(bouncer *Bouncer) error { Path: bouncer.crowdsecPath + bouncer.crowdsecStreamRoute, RawQuery: fmt.Sprintf("startup=%t", !isCrowdsecStreamHealthy || isStartup), } - body, err := crowdsecQuery(bouncer, streamRouteURL.String(), false) + body, err := crowdsecQuery(bouncer, streamRouteURL.String(), nil) if err != nil { return err } @@ -565,15 +618,9 @@ func handleStreamCache(bouncer *Bouncer) error { return nil } -func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, error) { +func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, error) { var req *http.Request - if isPost { - data := []byte(fmt.Sprintf( - `{"machine_id": "%v","password": "%v","scenarios": ["%v"]}`, - bouncer.crowdsecMachineID, - bouncer.crowdsecPassword, - strings.Join(bouncer.crowdsecScenarios, `","`), - )) + if len(data) > 0 { req, _ = http.NewRequest(http.MethodPost, stringURL, bytes.NewBuffer(data)) } else { req, _ = http.NewRequest(http.MethodGet, stringURL, nil) @@ -594,13 +641,16 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err if errToken := getToken(bouncer); errToken != nil { return nil, fmt.Errorf("crowdsecQuery:renewToken url:%s %w", stringURL, errToken) } - return crowdsecQuery(bouncer, stringURL, false) + return crowdsecQuery(bouncer, stringURL, nil) } - if res.StatusCode != http.StatusOK { - return nil, fmt.Errorf("crowdsecQuery url:%s, statusCode:%d", stringURL, res.StatusCode) + + // Check if the status code starts with 2 + statusStr := strconv.Itoa(res.StatusCode) + if len(statusStr) < 1 || statusStr[0] != '2' { + return nil, fmt.Errorf("crowdsecQuery method:%s url:%s, statusCode:%d (expected: 2xx)", req.Method, stringURL, res.StatusCode) } - body, err := io.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { return nil, fmt.Errorf("crowdsecQuery:readBody %w", err) } @@ -670,3 +720,65 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error { } return nil } + +func reportMetrics(bouncer *Bouncer) error { + now := time.Now() + currentCount := atomic.LoadInt64(&blockedRequests) + windowSizeSeconds := int(now.Sub(lastMetricsPush).Seconds()) + + bouncer.log.Debug(fmt.Sprintf("reportMetrics: blocked_requests=%d window_size=%ds", currentCount, windowSizeSeconds)) + + metrics := map[string]interface{}{ + "remediation_components": []map[string]interface{}{ + { + "version": "1.X.X", + "type": "bouncer", + "name": "traefik_plugin", + "metrics": []map[string]interface{}{ + { + "items": []map[string]interface{}{ + { + "name": "dropped", + "value": currentCount, + "unit": "request", + "labels": map[string]string{ + "type": "traefik_plugin", + }, + }, + }, + "meta": map[string]interface{}{ + "window_size_seconds": windowSizeSeconds, + "utc_now_timestamp": now.Unix(), + }, + }, + }, + "utc_startup_timestamp": time.Now().Unix(), + "feature_flags": []string{}, + "os": map[string]string{ + "name": "unknown", + "version": "unknown", + }, + }, + }, + } + + data, err := json.Marshal(metrics) + if err != nil { + return fmt.Errorf("reportMetrics:marshal %w", err) + } + + metricsURL := url.URL{ + Scheme: bouncer.crowdsecScheme, + Host: bouncer.crowdsecHost, + Path: bouncer.crowdsecPath + crowdsecLapiMetricsRoute, + } + + _, err = crowdsecQuery(bouncer, metricsURL.String(), data) + if err != nil { + return fmt.Errorf("reportMetrics:query %w", err) + } + + atomic.StoreInt64(&blockedRequests, 0) + lastMetricsPush = now + return nil +} diff --git a/bouncer_test.go b/bouncer_test.go index 270a5f5..9499506 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -163,7 +163,7 @@ func Test_crowdsecQuery(t *testing.T) { type args struct { bouncer *Bouncer stringURL string - isPost bool + data []byte } tests := []struct { name string @@ -175,7 +175,7 @@ func Test_crowdsecQuery(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.isPost) + got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.data) if (err != nil) != tt.wantErr { t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6812bb0..040496a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -36,7 +36,7 @@ services: - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" - "traefik.http.services.service-foo.loadbalancer.server.port=80" - whoami2: + bar: image: traefik/whoami container_name: "simple-service-bar" restart: unless-stopped @@ -48,12 +48,38 @@ services: - "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.metricsupdateintervalseconds=15" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5=" + bar2: + image: traefik/whoami + container_name: "simple-service-bar2" + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.router-bar2.rule=PathPrefix(`/bar2`)" + - "traefik.http.routers.router-bar2.entrypoints=web" + - "traefik.http.routers.router-bar2.middlewares=crowdsec2@docker" + - "traefik.http.services.service-bar2.loadbalancer.server.port=80" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.loglevel=DEBUG" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdsecmode=stream" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.updateintervalseconds=10" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.updatemaxfailure=-1" + - "traefik.http.middlewares.crowdsec2.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5=" + bar3: + image: traefik/whoami + container_name: "simple-service-bar3" + restart: unless-stopped + labels: + - "traefik.enable=true" + - "traefik.http.routers.router-bar3.rule=PathPrefix(`/bar3`)" + - "traefik.http.routers.router-bar3.entrypoints=web" + - "traefik.http.routers.router-bar3.middlewares=crowdsec2@docker" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.6.8 container_name: "crowdsec" restart: unless-stopped environment: @@ -67,7 +93,6 @@ services: - crowdsec-config-local:/etc/crowdsec/ labels: - "traefik.enable=false" - volumes: logs-local: crowdsec-db-local: diff --git a/docker-compose.yml b/docker-compose.yml index 8c5d952..8db2de8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -59,7 +59,7 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5" crowdsec: - image: crowdsecurity/crowdsec:v1.6.1-2 + image: crowdsecurity/crowdsec:v1.6.8 container_name: "crowdsec" restart: unless-stopped environment: diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 465976a..e764b49 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -67,7 +67,8 @@ type Config struct { CrowdsecCapiPasswordFile string `json:"crowdsecCapiPasswordFile,omitempty"` CrowdsecCapiScenarios []string `json:"crowdsecCapiScenarios,omitempty"` UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"` - UpdateMaxFailure int `json:"updateMaxFailure,omitempty"` + MetricsUpdateIntervalSeconds int64 `json:"metricsUpdateIntervalSeconds,omitempty"` + UpdateMaxFailure int64 `json:"updateMaxFailure,omitempty"` DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"` RemediationStatusCode int `json:"remediationStatusCode,omitempty"` HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"` @@ -119,6 +120,7 @@ func New() *Config { CrowdsecLapiKey: "", CrowdsecLapiTLSInsecureVerify: false, UpdateIntervalSeconds: 60, + MetricsUpdateIntervalSeconds: 600, UpdateMaxFailure: 0, DefaultDecisionSeconds: 60, RemediationStatusCode: http.StatusForbidden, @@ -341,13 +343,22 @@ func validateParamsRequired(config *Config) error { return fmt.Errorf("%v: cannot be empty", key) } } - requiredInt := map[string]int64{ + requiredInt0 := map[string]int64{ + "CrowdsecAppsecBodyLimit": config.CrowdsecAppsecBodyLimit, + "MetricsUpdateIntervalSeconds": config.MetricsUpdateIntervalSeconds, + } + for key, val := range requiredInt0 { + if val < 0 { + return fmt.Errorf("%v: cannot be less than 0", key) + } + } + requiredInt1 := map[string]int64{ "UpdateIntervalSeconds": config.UpdateIntervalSeconds, "DefaultDecisionSeconds": config.DefaultDecisionSeconds, "HTTPTimeoutSeconds": config.HTTPTimeoutSeconds, "CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds, } - for key, val := range requiredInt { + for key, val := range requiredInt1 { if val < 1 { return fmt.Errorf("%v: cannot be less than 1", key) } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 6f74894..a66cd19 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,5 +1,5 @@ // Package logger implements utility routines to write to stdout and stderr. -// It supports debug, info and error level +// It supports trace, debug, info and error level package logger import ( @@ -19,29 +19,43 @@ type Log struct { // New Set Default log level to info in case log level to defined. func New(logLevel string, logFilePath string) *Log { + // Initialize loggers with discard output logError := log.New(io.Discard, "ERROR: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) logInfo := log.New(io.Discard, "INFO: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) logDebug := log.New(io.Discard, "DEBUG: CrowdsecBouncerTraefikPlugin: ", log.Ldate|log.Ltime) - logError.SetOutput(os.Stderr) - logInfo.SetOutput(os.Stdout) // we initialize logger to STDOUT/STDERR first so if the file logger cannot be initialized we can inform the user - if logLevel == "DEBUG" { - logDebug.SetOutput(os.Stdout) - } + output := os.Stdout + errorOutput := os.Stderr + + // prepare file logging if specified if logFilePath != "" { logFile, err := os.OpenFile(filepath.Clean(logFilePath), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - _ = fmt.Errorf("LogFilePath is not writable %w", err) + if err == nil { + output = logFile + errorOutput = logFile } else { - logInfo.SetOutput(logFile) - logError.SetOutput(logFile) - if logLevel == "DEBUG" { - logDebug.SetOutput(logFile) - } + _ = fmt.Errorf("LogFilePath is not writable %w", err) } } + // Set error logger output + logError.SetOutput(errorOutput) + + // Configure log levels + switch logLevel { + case "ERROR": + // Only error logging is enabled + case "INFO": + logInfo.SetOutput(output) + case "DEBUG": + logInfo.SetOutput(output) + logDebug.SetOutput(output) + default: + // Default to INFO level + logInfo.SetOutput(output) + } + return &Log{ logError: logError, logInfo: logInfo, From 007f8681803fdfa5df3935d36979107275661573 Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Mon, 4 Aug 2025 20:25:06 +0200 Subject: [PATCH 11/18] :sparkles: Anom config --- README.md | 14 +++++- bouncer.go | 12 +++++ pkg/captcha/captcha.go | 70 ++++++++++++++---------------- pkg/configuration/configuration.go | 40 ++++++++++++++--- 4 files changed, 91 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index da74f1a..fe5d758 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,19 @@ make run - Used only in `alone` mode, scenarios for Crowdsec CAPI - CaptchaProvider - string - - Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` + - Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom` +- CaptchaCustomJsURL + - string + - If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`) +- CaptchaCustomValidateURL + - string + - If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`) +- CaptchaCustomKey + - string + - If CaptchaProvider is `custom`, used to set classname of the div used by captcha provider (in case of hcaptcha: `h-captcha`) +- CaptchaCustomResponse + - string + - If CaptchaProvider is `custom`, used to set the field in the validate URL body (in case of hcaptcha: `h-captcha-response`) - CaptchaSiteKey - string - Site key for the captcha provider diff --git a/bouncer.go b/bouncer.go index 6771723..22c4d4c 100644 --- a/bouncer.go +++ b/bouncer.go @@ -234,6 +234,17 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") tlsConfig2 := new(tls.Config) tlsConfig2.InsecureSkipVerify = true + + var infoProvider *captcha.InfoProvider + if config.CaptchaProvider == configuration.CustomProvider { + infoProvider = &captcha.InfoProvider{ + js: config.CaptchaCustomJsURL, + validate: config.CaptchaCustomValidateURL, + key: config.CaptchaCustomKey, + response: config.CaptchaCustomResponse, + } + } + err = bouncer.captchaClient.New( log, bouncer.cacheClient, @@ -245,6 +256,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam }, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, + infoProvider, config.CaptchaProvider, config.CaptchaSiteKey, config.CaptchaSecretKey, diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 5ec3adf..0c56739 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -17,7 +17,6 @@ import ( // Client Captcha client. type Client struct { Valid bool - provider string siteKey string secretKey string remediationCustomHeader string @@ -26,54 +25,51 @@ type Client struct { cacheClient *cache.Client httpClient *http.Client log *logger.Log + infoProvider *InfoProvider } -type infoProvider struct { +// InfoProvider Information for self-hosted provider. +type InfoProvider struct { js string key string response string validate string } -var ( - //nolint:gochecknoglobals - captcha = map[string]infoProvider{ - configuration.HcaptchaProvider: { - js: "https://hcaptcha.com/1/api.js", - key: "h-captcha", - response: "h-captcha-response", - validate: "https://api.hcaptcha.com/siteverify", - }, - configuration.RecaptchaProvider: { - js: "https://www.google.com/recaptcha/api.js", - key: "g-recaptcha", - response: "g-recaptcha-response", - validate: "https://www.google.com/recaptcha/api/siteverify", - }, - configuration.TurnstileProvider: { - js: "https://challenges.cloudflare.com/turnstile/v0/api.js", - key: "cf-turnstile", - response: "cf-turnstile-response", - validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", - }, - configuration.WicketkeeperProvider: { - js: "https://captcha.max.lan/fast.js", - key: "wicketkeeper", - response: "wicketkeeper_solution", - validate: "https://captcha.max.lan/v0/siteverify", - }, - } -) +//nolint:gochecknoglobals +var infoProviders = map[string]*InfoProvider{ + configuration.HcaptchaProvider: &InfoProvider{ + js: "https://hcaptcha.com/1/api.js", + key: "h-captcha", + response: "h-captcha-response", + validate: "https://api.hcaptcha.com/siteverify", + }, + configuration.RecaptchaProvider: &InfoProvider{ + js: "https://www.google.com/recaptcha/api.js", + key: "g-recaptcha", + response: "g-recaptcha-response", + validate: "https://www.google.com/recaptcha/api/siteverify", + }, + configuration.TurnstileProvider: &InfoProvider{ + js: "https://challenges.cloudflare.com/turnstile/v0/api.js", + key: "cf-turnstile", + response: "cf-turnstile-response", + validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify", + }, +} // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } + c.infoProvider = infoProvider + if c.infoProvider == nil { + c.infoProvider = infoProviders[provider] + } c.siteKey = siteKey c.secretKey = secretKey - c.provider = provider c.remediationCustomHeader = remediationCustomHeader html, _ := configuration.GetHTMLTemplate(captchaTemplatePath) c.captchaTemplate = html @@ -105,8 +101,8 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str rw.WriteHeader(http.StatusOK) err = c.captchaTemplate.Execute(rw, map[string]string{ "SiteKey": c.siteKey, - "FrontendJS": captcha[c.provider].js, - "FrontendKey": captcha[c.provider].key, + "FrontendJS": c.infoProvider.js, + "FrontendKey": c.infoProvider.key, }) if err != nil { c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error()) @@ -131,7 +127,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) { c.log.Debug("captcha:Validate invalid method: " + r.Method) return false, nil } - var response = r.FormValue(captcha[c.provider].response) + var response = r.FormValue(c.infoProvider.response) if response == "" { c.log.Debug("captcha:Validate no captcha response found in request") return false, nil @@ -139,7 +135,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) { var body = url.Values{} body.Add("secret", c.secretKey) body.Add("response", response) - res, err := c.httpClient.PostForm(captcha[c.provider].validate, body) + res, err := c.httpClient.PostForm(c.infoProvider.validate, body) if err != nil { return false, err } diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index e764b49..3da9164 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -34,7 +34,7 @@ const ( HcaptchaProvider = "hcaptcha" RecaptchaProvider = "recaptcha" TurnstileProvider = "turnstile" - WicketkeeperProvider = "wicketkeeper" + CustomProvider = "custom" ) // Config the plugin configuration. @@ -85,6 +85,10 @@ type Config struct { BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` + CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` + CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` + CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` + CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"` CaptchaSecretKey string `json:"captchaSecretKey,omitempty"` @@ -126,6 +130,10 @@ func New() *Config { RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10, CaptchaProvider: "", + CaptchaCustomJsURL: "", + CaptchaCustomValidateURL: "", + CaptchaCustomKey: "", + CaptchaCustomResponse: "", CaptchaSiteKey: "", CaptchaSecretKey: "", CaptchaGracePeriodSeconds: 1800, @@ -196,6 +204,10 @@ func ValidateParams(config *Config) error { if err := validateParamsRequired(config); err != nil { return err } + + if err := validateCaptcha(config); err != nil { + return err + } if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil { return err @@ -332,6 +344,23 @@ func validateParamsIPs(listIP []string, key string) error { return nil } +func validateCaptcha(config *Config) error { + if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) { + return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider) + } + if config.CaptchaProvider == CustomProvider { + if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" { + return fmt.Errorf( + "CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s", + config.CaptchaCustomKey , + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, + config.CaptchaCustomJsURL, + ) + } + } +} + func validateParamsRequired(config *Config) error { requiredStrings := map[string]string{ "CrowdsecLapiScheme": config.CrowdsecLapiScheme, @@ -340,7 +369,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredStrings { if len(val) == 0 { - return fmt.Errorf("%v: cannot be empty", key) + return errors.New(key + ": cannot be empty") } } requiredInt0 := map[string]int64{ @@ -349,7 +378,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt0 { if val < 0 { - return fmt.Errorf("%v: cannot be less than 0", key) + return errors.New(key + ": cannot be less than 0", ) } } requiredInt1 := map[string]int64{ @@ -360,7 +389,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt1 { if val < 1 { - return fmt.Errorf("%v: cannot be less than 1", key) + return errors.New(key + ": cannot be less than 1", ) } } if config.UpdateMaxFailure < -1 { @@ -379,9 +408,6 @@ func validateParamsRequired(config *Config) error { if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'") } - if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, WicketkeeperProvider}, config.CaptchaProvider) { - return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha', 'turnstile' or 'wicketkeeper'") - } return nil } From 61c94686d422e257f8941611ade49366e13b5cab Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Mon, 4 Aug 2025 20:27:45 +0200 Subject: [PATCH 12/18] :bento: fix readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index fe5d758..2238bf9 100644 --- a/README.md +++ b/README.md @@ -469,13 +469,13 @@ make run - CaptchaCustomJsURL - string - If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`) -- CaptchaCustomValidateURL +- CaptchaCustomValidateURL - string - If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`) -- CaptchaCustomKey +- CaptchaCustomKey - string - If CaptchaProvider is `custom`, used to set classname of the div used by captcha provider (in case of hcaptcha: `h-captcha`) -- CaptchaCustomResponse +- CaptchaCustomResponse - string - If CaptchaProvider is `custom`, used to set the field in the validate URL body (in case of hcaptcha: `h-captcha-response`) - CaptchaSiteKey From 43c7d3d8ca83a04f4608e51f4b0857cdadc343ba Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 08:46:45 +0200 Subject: [PATCH 13/18] :bento: fix lint --- bouncer.go | 26 ++++++++------------------ captcha.html | 7 +------ pkg/captcha/captcha.go | 16 ++++++++++++---- pkg/configuration/configuration.go | 12 ++++++------ 4 files changed, 27 insertions(+), 34 deletions(-) diff --git a/bouncer.go b/bouncer.go index 22c4d4c..9a4a72a 100644 --- a/bouncer.go +++ b/bouncer.go @@ -232,31 +232,21 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam ) config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey") config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey") - tlsConfig2 := new(tls.Config) - tlsConfig2.InsecureSkipVerify = true - - var infoProvider *captcha.InfoProvider - if config.CaptchaProvider == configuration.CustomProvider { - infoProvider = &captcha.InfoProvider{ - js: config.CaptchaCustomJsURL, - validate: config.CaptchaCustomValidateURL, - key: config.CaptchaCustomKey, - response: config.CaptchaCustomResponse, - } - } err = bouncer.captchaClient.New( log, bouncer.cacheClient, &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, - TLSClientConfig: tlsConfig2, - }, + Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, - infoProvider, + captcha.GetInfoProvider( + config.CaptchaProvider, + config.CaptchaCustomJsURL, + config.CaptchaCustomKey, + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, + ), config.CaptchaProvider, config.CaptchaSiteKey, config.CaptchaSecretKey, diff --git a/captcha.html b/captcha.html index b7df9b1..bf7a8af 100644 --- a/captcha.html +++ b/captcha.html @@ -294,12 +294,7 @@

CrowdSec Captcha

-
+
diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 0c56739..82b935c 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -58,16 +58,24 @@ var infoProviders = map[string]*InfoProvider{ }, } +// GetInfoProvider Get InfoProvider. +func GetInfoProvider(provider, js, key, response, validate string) *InfoProvider { + var infoProvider InfoProvider + if provider == configuration.CustomProvider { + infoProvider = &captcha.InfoProvider{js, key, response, validate} + } else { + infoProvider = infoProviders[provider] + } + return infoProvider +} + // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } c.infoProvider = infoProvider - if c.infoProvider == nil { - c.infoProvider = infoProviders[provider] - } c.siteKey = siteKey c.secretKey = secretKey c.remediationCustomHeader = remediationCustomHeader diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 3da9164..66e38ba 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -85,7 +85,7 @@ type Config struct { BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"` CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` - CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` + CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` @@ -130,7 +130,7 @@ func New() *Config { RemediationStatusCode: http.StatusForbidden, HTTPTimeoutSeconds: 10, CaptchaProvider: "", - CaptchaCustomJsURL: "", + CaptchaCustomJsURL: "", CaptchaCustomValidateURL: "", CaptchaCustomKey: "", CaptchaCustomResponse: "", @@ -204,7 +204,7 @@ func ValidateParams(config *Config) error { if err := validateParamsRequired(config); err != nil { return err } - + if err := validateCaptcha(config); err != nil { return err } @@ -352,7 +352,7 @@ func validateCaptcha(config *Config) error { if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" { return fmt.Errorf( "CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s", - config.CaptchaCustomKey , + config.CaptchaCustomKey, config.CaptchaCustomResponse, config.CaptchaCustomValidateURL, config.CaptchaCustomJsURL, @@ -378,7 +378,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt0 { if val < 0 { - return errors.New(key + ": cannot be less than 0", ) + return errors.New(key + ": cannot be less than 0") } } requiredInt1 := map[string]int64{ @@ -389,7 +389,7 @@ func validateParamsRequired(config *Config) error { } for key, val := range requiredInt1 { if val < 1 { - return errors.New(key + ": cannot be less than 1", ) + return errors.New(key + ": cannot be less than 1") } } if config.UpdateMaxFailure < -1 { From 083d299754fd005ccadda5849915ec822edcc343 Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 09:31:48 +0200 Subject: [PATCH 14/18] :bento: normalize --- bouncer.go | 11 ++++------- pkg/captcha/captcha.go | 19 +++++++------------ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/bouncer.go b/bouncer.go index 9a4a72a..8200096 100644 --- a/bouncer.go +++ b/bouncer.go @@ -240,14 +240,11 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second}, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second, }, - captcha.GetInfoProvider( - config.CaptchaProvider, - config.CaptchaCustomJsURL, - config.CaptchaCustomKey, - config.CaptchaCustomResponse, - config.CaptchaCustomValidateURL, - ), config.CaptchaProvider, + config.CaptchaCustomJsURL, + config.CaptchaCustomKey, + config.CaptchaCustomResponse, + config.CaptchaCustomValidateURL, config.CaptchaSiteKey, config.CaptchaSecretKey, config.RemediationHeadersCustomName, diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index 82b935c..b79991a 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -58,23 +58,18 @@ var infoProviders = map[string]*InfoProvider{ }, } -// GetInfoProvider Get InfoProvider. -func GetInfoProvider(provider, js, key, response, validate string) *InfoProvider { - var infoProvider InfoProvider - if provider == configuration.CustomProvider { - infoProvider = &captcha.InfoProvider{js, key, response, validate} - } else { - infoProvider = infoProviders[provider] - } - return infoProvider -} - // New Initialize captcha client. -func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, infoProvider *InfoProvider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { +func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error { c.Valid = provider != "" if !c.Valid { return nil } + var infoProvider *InfoProvider + if provider == configuration.CustomProvider { + infoProvider = &InfoProvider{js: js, key: key, response: response, validate: validate} + } else { + infoProvider = infoProviders[provider] + } c.infoProvider = infoProvider c.siteKey = siteKey c.secretKey = secretKey From 416c395d976a4b6b66377cc4ba3d489381e62c0f Mon Sep 17 00:00:00 2001 From: Max Lerebourg Date: Wed, 6 Aug 2025 09:53:35 +0200 Subject: [PATCH 15/18] :bento: fix lint --- pkg/captcha/captcha.go | 6 +++--- pkg/configuration/configuration.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go index b79991a..4961a43 100644 --- a/pkg/captcha/captcha.go +++ b/pkg/captcha/captcha.go @@ -38,19 +38,19 @@ type InfoProvider struct { //nolint:gochecknoglobals var infoProviders = map[string]*InfoProvider{ - configuration.HcaptchaProvider: &InfoProvider{ + configuration.HcaptchaProvider: { js: "https://hcaptcha.com/1/api.js", key: "h-captcha", response: "h-captcha-response", validate: "https://api.hcaptcha.com/siteverify", }, - configuration.RecaptchaProvider: &InfoProvider{ + configuration.RecaptchaProvider: { js: "https://www.google.com/recaptcha/api.js", key: "g-recaptcha", response: "g-recaptcha-response", validate: "https://www.google.com/recaptcha/api/siteverify", }, - configuration.TurnstileProvider: &InfoProvider{ + configuration.TurnstileProvider: { js: "https://challenges.cloudflare.com/turnstile/v0/api.js", key: "cf-turnstile", response: "cf-turnstile-response", diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 66e38ba..0ad4432 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -86,7 +86,7 @@ type Config struct { CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"` CaptchaProvider string `json:"captchaProvider,omitempty"` CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"` - CaptchaCustomValidateURL string `json:"captchaCustomValidateURL,omitempty"` + CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"` CaptchaCustomKey string `json:"captchaCustomKey,omitempty"` CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"` CaptchaSiteKey string `json:"captchaSiteKey,omitempty"` @@ -359,6 +359,7 @@ func validateCaptcha(config *Config) error { ) } } + return nil } func validateParamsRequired(config *Config) error { From 00b3e3e6c14cc0eae3e6024908434051a4d99386 Mon Sep 17 00:00:00 2001 From: "max.lerebourg" Date: Sun, 10 Aug 2025 17:26:02 +0200 Subject: [PATCH 16/18] :memo: Add documentation --- Makefile | 9 +- examples/captcha/captcha.html | 2 +- examples/custom-captcha/README.md | 70 +++++ examples/custom-captcha/acquis.yaml | 4 + examples/custom-captcha/captcha.html | 338 +++++++++++++++++++++ examples/custom-captcha/docker-compose.yml | 103 +++++++ examples/custom-captcha/profiles.yaml | 17 ++ 7 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 examples/custom-captcha/README.md create mode 100644 examples/custom-captcha/acquis.yaml create mode 100644 examples/custom-captcha/captcha.html create mode 100644 examples/custom-captcha/docker-compose.yml create mode 100644 examples/custom-captcha/profiles.yaml diff --git a/Makefile b/Makefile index f266a6e..15c3f48 100644 --- a/Makefile +++ b/Makefile @@ -41,13 +41,16 @@ run_tlsauth: docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans run_appsec: - docker compose -f examples/appsec-enabled/docker-compose.yml up -d + docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans + +run_custom_captcha: + docker compose -f examples/custo-captcha/docker-compose.yml up -d --remove-orphans run_captcha: - docker compose -f examples/captcha/docker-compose.yml up -d + docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans run_custom_ban_page: - docker compose -f examples/custom-ban-page/docker-compose.yml up -d + docker compose -f examples/custom-ban-page/docker-compose.yml up -d --remove-orphans run: docker compose -f docker-compose.yml up -d --remove-orphans diff --git a/examples/captcha/captcha.html b/examples/captcha/captcha.html index 0fd982b..bf7a8af 100644 --- a/examples/captcha/captcha.html +++ b/examples/captcha/captcha.html @@ -293,7 +293,7 @@

CrowdSec Captcha

-
+
diff --git a/examples/custom-captcha/README.md b/examples/custom-captcha/README.md new file mode 100644 index 0000000..8593959 --- /dev/null +++ b/examples/custom-captcha/README.md @@ -0,0 +1,70 @@ +# Example + +Read the example captcha before this, to better understand what is done here. + +### Traefik configuration + +The minimal configuration is defined below to implement custom captcha. +This documentation use https://github.com/a-ve/wicketpeeker, a self-hosted captcha provider that have a similar API than big providers. + +Minimal API requirement: +- the JS file URL to load the captcha on the served `captcha.html` +- the HTML className to tell to the JS where to display the challenge +- the verify URL endpoint to send the response from the captcha +- the name of the field in the verify URL + +- the JS file need to respect the `data-callback` on the div that contains the captcha if you use our template, but you can customize it by your side + +```yaml + traefik: + ... + labels: + # Choose captcha provider + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom" + # Define captcha grade period seconds + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800" + # Define captcha HTML file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" + # + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://localhost:8080/fast.js" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://localhost:8080/v0/siteverify" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketpeeker" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=response" +``` + +```yaml + wicketkeeper: + image: ghcr.io/a-ve/wicketkeeper:latest + ports: + - "8080:8080" + environment: + - ROOT_URL=http://localhost:8080 + - LISTEN_PORT=8080 + - REDIS_ADDR=redis:6379 + - DIFFICULTY=4 + - ALLOWED_ORIGINS=* + - PRIVATE_KEY_PATH=/data/wicketkeeper.key + volumes: + - ./data:/data + depends_on: + - redis + redis: + image: redis/redis-stack-server:latest +``` + +## Exemple navigation +We can try to query normally the whoami server: +```bash +curl http://localhost:8000 +``` + +We can try to ban ourself and retry. + +```bash +docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 10m --type captcha +``` + +To play the demo environment run: +```bash +make run_custom_captcha +``` \ No newline at end of file diff --git a/examples/custom-captcha/acquis.yaml b/examples/custom-captcha/acquis.yaml new file mode 100644 index 0000000..5d52554 --- /dev/null +++ b/examples/custom-captcha/acquis.yaml @@ -0,0 +1,4 @@ +filenames: + - /var/log/traefik/access.log +labels: + type: traefik diff --git a/examples/custom-captcha/captcha.html b/examples/custom-captcha/captcha.html new file mode 100644 index 0000000..bf7a8af --- /dev/null +++ b/examples/custom-captcha/captcha.html @@ -0,0 +1,338 @@ + + + + + CrowdSec Captcha + + + + + + + +
+
+
+ +

CrowdSec Captcha

+
+
+
+
+
+
+

This security check has been powered by

+ + + + + + + + + + + + + + + + + + + + + CrowdSec + +
+
+
+ + + diff --git a/examples/custom-captcha/docker-compose.yml b/examples/custom-captcha/docker-compose.yml new file mode 100644 index 0000000..496dd5f --- /dev/null +++ b/examples/custom-captcha/docker-compose.yml @@ -0,0 +1,103 @@ +services: + traefik: + image: "traefik:v3.0.0" + container_name: "traefik" + restart: unless-stopped + command: + # - "--log.level=DEBUG" + - "--accesslog" + - "--accesslog.filepath=/var/log/traefik/access.log" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.web.address=:80" + + - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + - "--experimental.plugins.bouncer.version=v1.4.5" + # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - logs-captcha-enabled:/var/log/traefik + - './captcha.html:/captcha.html' + # - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin + ports: + - 8000:80 + - 8080:8080 + depends_on: + - crowdsec + + whoami: + image: traefik/whoami + container_name: "whoaami" + restart: unless-stopped + labels: + - "traefik.enable=true" + # Definition of the router + - "traefik.http.routers.router-foo.rule=Host(`localhost`)" + - "traefik.http.routers.router-foo.entrypoints=web" + - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" + # Definition of the service + - "traefik.http.services.service-foo.loadbalancer.server.port=80" + # Definition of the middleware + - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" + + # Choose captcha provider + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8080/fast.js" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://captcha.localhost:8080/v0/siteverify" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketpeeker" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=response" + # Define captcha grade period seconds + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20" + # Define captcha HTML file path + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" + + + crowdsec: + image: crowdsecurity/crowdsec:v1.6.1-2 + container_name: "crowdsec" + restart: unless-stopped + environment: + COLLECTIONS: crowdsecurity/traefik + CUSTOM_HOSTNAME: crowdsec + BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5 + volumes: + # For captcha and ban mixed decision + - './profiles.yaml:/etc/crowdsec/profiles.yaml:ro' + # For captcha only remediation + # - './profiles_captcha_only.yaml:/etc/crowdsec/profiles.yaml:ro' + - './acquis.yaml:/etc/crowdsec/acquis.yaml:ro' + - logs-captcha-enabled:/var/log/traefik:ro + - crowdsec-db-captcha-enabled:/var/lib/crowdsec/data/ + - crowdsec-config-captcha-enabled:/etc/crowdsec/ + labels: + - "traefik.enable=false" + + wicketkeeper: + image: ghcr.io/a-ve/wicketkeeper:latest + container_name: "wicketkeeper" + environment: + - ROOT_URL=http://localhost:8080 + - LISTEN_PORT=8080 + - REDIS_ADDR=redis:6379 + - DIFFICULTY=4 + - ALLOWED_ORIGINS=* + labels: + - "traefik.enable=true" + # Definition of the router + - "traefik.http.routers.router-wicketpeeker.rule=Host(`captcha.localhost`)" + - "traefik.http.routers.router-wicketpeeker.entrypoints=web" + # Definition of the service + - "traefik.http.services.service-foo.loadbalancer.server.port=8080" + depends_on: + - redis + + redis: + image: redis/redis-stack-server:latest + +volumes: + logs-captcha-enabled: + crowdsec-db-captcha-enabled: + crowdsec-config-captcha-enabled: diff --git a/examples/custom-captcha/profiles.yaml b/examples/custom-captcha/profiles.yaml new file mode 100644 index 0000000..b396c7c --- /dev/null +++ b/examples/custom-captcha/profiles.yaml @@ -0,0 +1,17 @@ +name: captcha_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" && GetDecisionsSinceCount(Alert.GetValue(), "24h") <= 3 +## Same as above but only 3 captcha decision per 24 hours before ban +decisions: + - type: captcha + duration: 4h +on_success: break +--- +name: default_ip_remediation +filters: + - Alert.Remediation == true && Alert.GetScope() == "Ip" +decisions: + - type: ban + duration: 4h +#duration_expr: "Sprintf('%dh', (GetDecisionsCount(Alert.GetValue()) + 1) * 4)" +on_success: break \ No newline at end of file From 5b217e272a696a90d04504722818f458885b8321 Mon Sep 17 00:00:00 2001 From: mhx Date: Mon, 11 Aug 2025 19:47:55 +0200 Subject: [PATCH 17/18] =?UTF-8?q?=F0=9F=93=9D=20Fix=20example=20and=20make?= =?UTF-8?q?file=20and=20doc=20for=20wicketkeeper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 5 +- README.md | 3 + bouncer.go | 1 + .../docker-compose.appsec-enabled.yml | 8 +-- examples/custom-captcha/README.md | 60 ++++++++++--------- examples/custom-captcha/docker-compose.yml | 55 +++++++++-------- 6 files changed, 73 insertions(+), 59 deletions(-) diff --git a/Makefile b/Makefile index 15c3f48..5894fde 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ run_appsec: docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans run_custom_captcha: - docker compose -f examples/custo-captcha/docker-compose.yml up -d --remove-orphans + docker compose -f examples/custom-captcha/docker-compose.yml up -d --remove-orphans run_captcha: docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans @@ -99,8 +99,9 @@ clean_all_docker: docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans - docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans + docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml down --remove-orphans docker compose -f examples/captcha/docker-compose.yml down --remove-orphans + docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans docker compose -f docker-compose.local.yml down --remove-orphans docker compose -f docker-compose.yml down --remove-orphans diff --git a/README.md b/README.md index 2238bf9..ac41e6f 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ The following captcha providers are supported now: - [hcaptcha](https://www.hcaptcha.com/) - [recaptcha](https://www.google.com/recaptcha/about/) - [turnstile](https://www.cloudflare.com/products/turnstile/) +- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper) There are 5 operating modes (CrowdsecMode) for this plugin: @@ -702,6 +703,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha #### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md) +#### 11. Using Traefik with Custom Captcha Whiketkeeper[examples/custom-captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-captcha/README.md) + ### Local Mode Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub. diff --git a/bouncer.go b/bouncer.go index 8200096..d73b9e8 100644 --- a/bouncer.go +++ b/bouncer.go @@ -252,6 +252,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CaptchaGracePeriodSeconds, ) if err != nil { + log.Error("CaptchaClient not valid " + err.Error()) return nil, err } diff --git a/examples/appsec-enabled/docker-compose.appsec-enabled.yml b/examples/appsec-enabled/docker-compose.appsec-enabled.yml index b2cc9ca..6ee6372 100644 --- a/examples/appsec-enabled/docker-compose.appsec-enabled.yml +++ b/examples/appsec-enabled/docker-compose.appsec-enabled.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: traefik: image: "traefik:v3.0.0" @@ -36,7 +34,7 @@ services: # Definition of the router - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" - "traefik.http.routers.router-foo.entrypoints=web" - - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" + - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" # Definition of the service - "traefik.http.services.service-foo.loadbalancer.server.port=80" # Definition of the middleware @@ -48,8 +46,6 @@ services: # Define AppSec host and port informations - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422" - - crowdsec: image: crowdsecurity/crowdsec:v1.6.1-2 container_name: "crowdsec" @@ -65,7 +61,7 @@ services: - crowdsec-config-appsec-enabled:/etc/crowdsec/ labels: - "traefik.enable=false" - + volumes: logs-appsec-enabled: crowdsec-db-appsec-enabled: diff --git a/examples/custom-captcha/README.md b/examples/custom-captcha/README.md index 8593959..3a88233 100644 --- a/examples/custom-captcha/README.md +++ b/examples/custom-captcha/README.md @@ -5,9 +5,10 @@ Read the example captcha before this, to better understand what is done here. ### Traefik configuration The minimal configuration is defined below to implement custom captcha. -This documentation use https://github.com/a-ve/wicketpeeker, a self-hosted captcha provider that have a similar API than big providers. +This documentation use https://github.com/a-ve/wicketpeeker, a self-hosted captcha provider that have a similar API than big providers. Minimal API requirement: + - the JS file URL to load the captcha on the served `captcha.html` - the HTML className to tell to the JS where to display the challenge - the verify URL endpoint to send the response from the captcha @@ -21,41 +22,45 @@ Minimal API requirement: labels: # Choose captcha provider - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom" - # Define captcha grade period seconds + # Define captcha grace period seconds - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js" + # Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost + # domain which would resolve traefik itself and the port for the dashboard + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution" # Define captcha HTML file path - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" - # - - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://localhost:8080/fast.js" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://localhost:8080/v0/siteverify" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketpeeker" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=response" ``` -```yaml - wicketkeeper: - image: ghcr.io/a-ve/wicketkeeper:latest - ports: - - "8080:8080" - environment: - - ROOT_URL=http://localhost:8080 - - LISTEN_PORT=8080 - - REDIS_ADDR=redis:6379 - - DIFFICULTY=4 - - ALLOWED_ORIGINS=* - - PRIVATE_KEY_PATH=/data/wicketkeeper.key - volumes: - - ./data:/data - depends_on: - - redis - redis: - image: redis/redis-stack-server:latest +```yaml +wicketkeeper: + image: ghcr.io/a-ve/wicketkeeper:latest + user: root + ports: + - "8080:8080" + environment: + - ROOT_URL=http://localhost:8080 + - LISTEN_PORT=8080 + - REDIS_ADDR=redis:6379 + - DIFFICULTY=4 + - ALLOWED_ORIGINS=* + - PRIVATE_KEY_PATH=/data/wicketkeeper.key + volumes: + - ./data:/data + depends_on: + - redis +redis: + image: redis/redis-stack-server:latest ``` ## Exemple navigation + We can try to query normally the whoami server: + ```bash -curl http://localhost:8000 +curl http://localhost:8000/foo ``` We can try to ban ourself and retry. @@ -65,6 +70,7 @@ docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 10m --type captcha ``` To play the demo environment run: + ```bash make run_custom_captcha -``` \ No newline at end of file +``` diff --git a/examples/custom-captcha/docker-compose.yml b/examples/custom-captcha/docker-compose.yml index 496dd5f..9d475fa 100644 --- a/examples/custom-captcha/docker-compose.yml +++ b/examples/custom-captcha/docker-compose.yml @@ -1,6 +1,6 @@ services: traefik: - image: "traefik:v3.0.0" + image: "traefik:v3.5.0" container_name: "traefik" restart: unless-stopped command: @@ -11,14 +11,15 @@ services: - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" + - "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24" - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" - "--experimental.plugins.bouncer.version=v1.4.5" # - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - - logs-captcha-enabled:/var/log/traefik - - './captcha.html:/captcha.html' + - logs-custom-captcha-enabled:/var/log/traefik + - "./captcha.html:/captcha.html" # - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin ports: - 8000:80 @@ -26,14 +27,14 @@ services: depends_on: - crowdsec - whoami: + whoami-foo: image: traefik/whoami - container_name: "whoaami" + container_name: "simple-service-custom-captcha-foo" restart: unless-stopped labels: - "traefik.enable=true" # Definition of the router - - "traefik.http.routers.router-foo.rule=Host(`localhost`)" + - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" - "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker" # Definition of the service @@ -42,19 +43,20 @@ services: - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - + # Choose captcha provider - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8080/fast.js" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://captcha.localhost:8080/v0/siteverify" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketpeeker" - - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=response" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js" + # Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost + # domain which would resolve traefik itself and the port for the dashboard + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper" + - "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution" # Define captcha grade period seconds - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20" # Define captcha HTML file path - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" - crowdsec: image: crowdsecurity/crowdsec:v1.6.1-2 container_name: "crowdsec" @@ -65,39 +67,44 @@ services: BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5 volumes: # For captcha and ban mixed decision - - './profiles.yaml:/etc/crowdsec/profiles.yaml:ro' + - "./profiles.yaml:/etc/crowdsec/profiles.yaml:ro" # For captcha only remediation # - './profiles_captcha_only.yaml:/etc/crowdsec/profiles.yaml:ro' - - './acquis.yaml:/etc/crowdsec/acquis.yaml:ro' - - logs-captcha-enabled:/var/log/traefik:ro - - crowdsec-db-captcha-enabled:/var/lib/crowdsec/data/ - - crowdsec-config-captcha-enabled:/etc/crowdsec/ + - "./acquis.yaml:/etc/crowdsec/acquis.yaml:ro" + - logs-custom-captcha-enabled:/var/log/traefik:ro + - crowdsec-db-custom-captcha-enabled:/var/lib/crowdsec/data/ + - crowdsec-config-custom-captcha-enabled:/etc/crowdsec/ labels: - "traefik.enable=false" wicketkeeper: - image: ghcr.io/a-ve/wicketkeeper:latest + image: ghcr.io/maxlerebourg/wicketkeeper:latest container_name: "wicketkeeper" environment: - - ROOT_URL=http://localhost:8080 + - ROOT_URL=http://captcha.localhost:8000 - LISTEN_PORT=8080 - REDIS_ADDR=redis:6379 - DIFFICULTY=4 - ALLOWED_ORIGINS=* + - PRIVATE_KEY_PATH=/data/wicketkeeper.key + volumes: + - wicketkeeper-custom-captcha-enabled:/data + user: root labels: - "traefik.enable=true" # Definition of the router - "traefik.http.routers.router-wicketpeeker.rule=Host(`captcha.localhost`)" - "traefik.http.routers.router-wicketpeeker.entrypoints=web" # Definition of the service - - "traefik.http.services.service-foo.loadbalancer.server.port=8080" + - "traefik.http.services.service-whitekeeper.loadbalancer.server.port=8080" depends_on: - redis redis: image: redis/redis-stack-server:latest - + volumes: - logs-captcha-enabled: - crowdsec-db-captcha-enabled: - crowdsec-config-captcha-enabled: + logs-custom-captcha-enabled: + wicketkeeper-custom-captcha-enabled: + crowdsec-db-custom-captcha-enabled: + crowdsec-config-custom-captcha-enabled: From b313dfebc0cd9c0230c76480fd039119ff8a1786 Mon Sep 17 00:00:00 2001 From: "max.lerebourg" Date: Wed, 13 Aug 2025 20:46:32 +0200 Subject: [PATCH 18/18] :bento: fix last things --- README.md | 4 ++-- examples/captcha/README.md | 2 +- examples/custom-captcha/README.md | 4 ++-- examples/custom-captcha/docker-compose.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ac41e6f..ce09359 100644 --- a/README.md +++ b/README.md @@ -475,10 +475,10 @@ make run - If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`) - CaptchaCustomKey - string - - If CaptchaProvider is `custom`, used to set classname of the div used by captcha provider (in case of hcaptcha: `h-captcha`) + - If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`) - CaptchaCustomResponse - string - - If CaptchaProvider is `custom`, used to set the field in the validate URL body (in case of hcaptcha: `h-captcha-response`) + - If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`) - CaptchaSiteKey - string - Site key for the captcha provider diff --git a/examples/captcha/README.md b/examples/captcha/README.md index 211e4d7..a370f84 100644 --- a/examples/captcha/README.md +++ b/examples/captcha/README.md @@ -20,7 +20,7 @@ For now 3 captcha providers are supported: - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME" # Define captcha secret key - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME" - # Define captcha grade period seconds + # Define captcha grace period seconds - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800" # Define captcha HTML file path - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html" diff --git a/examples/custom-captcha/README.md b/examples/custom-captcha/README.md index 3a88233..9bc1e2e 100644 --- a/examples/custom-captcha/README.md +++ b/examples/custom-captcha/README.md @@ -11,8 +11,8 @@ Minimal API requirement: - the JS file URL to load the captcha on the served `captcha.html` - the HTML className to tell to the JS where to display the challenge -- the verify URL endpoint to send the response from the captcha -- the name of the field in the verify URL +- the verify URL endpoint to send the field `response` from the captcha with `content-type: application/x-www-form-urlencoded` +- the name of the field when you POST the resolved captcha to Traefik - the JS file need to respect the `data-callback` on the div that contains the captcha if you use our template, but you can customize it by your side diff --git a/examples/custom-captcha/docker-compose.yml b/examples/custom-captcha/docker-compose.yml index 9d475fa..3152f2e 100644 --- a/examples/custom-captcha/docker-compose.yml +++ b/examples/custom-captcha/docker-compose.yml @@ -78,7 +78,7 @@ services: - "traefik.enable=false" wicketkeeper: - image: ghcr.io/maxlerebourg/wicketkeeper:latest + image: ghcr.io/a-ve/wicketkeeper:latest container_name: "wicketkeeper" environment: - ROOT_URL=http://captcha.localhost:8000 @@ -86,7 +86,7 @@ services: - REDIS_ADDR=redis:6379 - DIFFICULTY=4 - ALLOWED_ORIGINS=* - - PRIVATE_KEY_PATH=/data/wicketkeeper.key + - PRIVATE_KEY_PATH=/data/wicketkeeper.key # To override in production environment volumes: - wicketkeeper-custom-captcha-enabled:/data user: root