Skip to content

✨ add custom selfhosted captcha #154 #259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Copy link
Collaborator

@mathieuHa mathieuHa Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use an exemple with whicketkeeper maybe

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm waiting for @a-ve to release a new version to use the "official" image instead of the image from my fork of wicketkeeper....

- 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
Expand Down
5 changes: 5 additions & 0 deletions bouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
)
config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey")
config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey")

err = bouncer.captchaClient.New(
log,
bouncer.cacheClient,
Expand All @@ -240,6 +241,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
config.CaptchaProvider,
config.CaptchaCustomJsURL,
config.CaptchaCustomKey,
config.CaptchaCustomResponse,
config.CaptchaCustomValidateURL,
config.CaptchaSiteKey,
config.CaptchaSecretKey,
config.RemediationHeadersCustomName,
Expand Down
65 changes: 37 additions & 28 deletions pkg/captcha/captcha.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
// Client Captcha client.
type Client struct {
Valid bool
provider string
siteKey string
secretKey string
remediationCustomHeader string
Expand All @@ -26,44 +25,54 @@ 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",
validate: "https://api.hcaptcha.com/siteverify",
},
configuration.RecaptchaProvider: {
js: "https://www.google.com/recaptcha/api.js",
key: "g-recaptcha",
validate: "https://www.google.com/recaptcha/api/siteverify",
},
configuration.TurnstileProvider: {
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
key: "cf-turnstile",
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
},
}
)
//nolint:gochecknoglobals
var infoProviders = 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",
},
}

// 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, 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
c.provider = provider
c.remediationCustomHeader = remediationCustomHeader
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
c.captchaTemplate = html
Expand Down Expand Up @@ -95,8 +104,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())
Expand All @@ -121,15 +130,15 @@ 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(c.infoProvider.response)
if response == "" {
c.log.Debug("captcha:Validate no captcha response found in request")
return false, nil
}
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
}
Expand Down
40 changes: 34 additions & 6 deletions pkg/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile"
CustomProvider = "custom"
)

// Config the plugin configuration.
Expand Down Expand Up @@ -84,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"`
Expand Down Expand Up @@ -125,6 +130,10 @@ func New() *Config {
RemediationStatusCode: http.StatusForbidden,
HTTPTimeoutSeconds: 10,
CaptchaProvider: "",
CaptchaCustomJsURL: "",
CaptchaCustomValidateURL: "",
CaptchaCustomKey: "",
CaptchaCustomResponse: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaGracePeriodSeconds: 1800,
Expand Down Expand Up @@ -196,6 +205,10 @@ func ValidateParams(config *Config) error {
return err
}

if err := validateCaptcha(config); err != nil {
return err
}

if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
return err
}
Expand Down Expand Up @@ -331,6 +344,24 @@ 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,
)
}
}
return nil
}

func validateParamsRequired(config *Config) error {
requiredStrings := map[string]string{
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
Expand All @@ -339,7 +370,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{
Expand All @@ -348,7 +379,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{
Expand All @@ -359,7 +390,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 {
Expand All @@ -378,9 +409,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}, config.CaptchaProvider) {
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
}
return nil
}

Expand Down
Loading