Implement captcha protection (#139)

*  Implement captcha protection

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 📝 Update exemple doc

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Add doc for the captcha and update some exemples

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update doc readme with some arguments

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update doc

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 generic documentation in readme on catpcha feature

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update exemple captcha

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Fix rendering and typos

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 🍱 fix readme

* 📝 update doc ongoing

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Add doc on crowdsec config

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Add sequence diagram for captcha exemple

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* Fix rendering and typos

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 add mermaid basics graphs

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update first diagram

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update first seq diagram

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 🐛 Fix bug in diagram syntax

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 rework all diagrams

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 📝 Update a bit diagrams

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>

* 🌐 Fix lang fr

* 🚸 change advice on uniq lapi confusing for users

*  Fix test du to rework on cache interface

* 🚨 Fix lint

---------

Signed-off-by: Mathieu Hanotaux <mathieu@hanotaux.fr>
Co-authored-by: max.lerebourg <max.lerebourg@monisnap.com>
Co-authored-by: Mathieu Hanotaux <mathieu@hanotaux.fr>
This commit is contained in:
maxlerebourg
2024-04-01 11:41:28 +02:00
committed by GitHub
co-authored by max.lerebourg Mathieu Hanotaux
parent c3e0c2d4c3
commit 497d1a2928
24 changed files with 1413 additions and 184 deletions
+48 -49
View File
@@ -12,13 +12,18 @@ import (
)
const (
cacheBannedValue = "t"
cacheNoBannedValue = "f"
// BannedValue Banned string.
BannedValue = "t"
// NoBannedValue No banned string.
NoBannedValue = "f"
// CaptchaValue Need captcha string.
CaptchaValue = "c"
// CaptchaDoneValue Captcha done string.
CaptchaDoneValue = "d"
// CacheMiss error string when cache is miss.
CacheMiss = "cache:miss"
)
// CacheMiss error string when cache is miss.
const CacheMiss = "cache:miss"
//nolint:gochecknoglobals
var (
redis simpleredis.SimpleRedis
@@ -27,55 +32,55 @@ var (
type localCache struct{}
func (localCache) getDecision(clientIP string) (bool, error) {
banned, isCached := cache.Get(clientIP)
bannedString, isValid := banned.(string)
if isCached && isValid && len(bannedString) > 0 {
return bannedString == cacheBannedValue, nil
func (localCache) get(key string) (string, error) {
value, isCached := cache.Get(key)
valueString, isValid := value.(string)
if isCached && isValid && len(valueString) > 0 {
return valueString, nil
}
return false, fmt.Errorf(CacheMiss)
return "", fmt.Errorf(CacheMiss)
}
func (localCache) setDecision(clientIP string, value string, duration int64) {
cache.Set(clientIP, value, duration)
func (localCache) set(key, value string, duration int64) {
cache.Set(key, value, duration)
}
func (localCache) deleteDecision(clientIP string) {
cache.Del(clientIP)
func (localCache) delete(key string) {
cache.Del(key)
}
type redisCache struct {
log *logger.Log
}
func (redisCache) getDecision(clientIP string) (bool, error) {
banned, err := redis.Get(clientIP)
bannedString := string(banned)
if err == nil && len(bannedString) > 0 {
return bannedString == cacheBannedValue, nil
func (redisCache) get(key string) (string, error) {
value, err := redis.Get(key)
valueString := string(value)
if err == nil && len(valueString) > 0 {
return valueString, nil
}
if err.Error() == simpleredis.RedisMiss {
return false, fmt.Errorf(CacheMiss)
return "", fmt.Errorf(CacheMiss)
}
return false, err
return "", err
}
func (rc redisCache) setDecision(clientIP string, value string, duration int64) {
if err := redis.Set(clientIP, []byte(value), duration); err != nil {
func (rc redisCache) set(key, value string, duration int64) {
if err := redis.Set(key, []byte(value), duration); err != nil {
rc.log.Error(fmt.Sprintf("cache:setDecisionRedisCache %s", err.Error()))
}
}
func (rc redisCache) deleteDecision(clientIP string) {
if err := redis.Del(clientIP); err != nil {
func (rc redisCache) delete(key string) {
if err := redis.Del(key); err != nil {
rc.log.Error(fmt.Sprintf("cache:deleteDecisionRedisCache %s", err.Error()))
}
}
type cacheInterface interface {
setDecision(clientIP string, value string, duration int64)
getDecision(clientIP string) (bool, error)
deleteDecision(clientIP string)
set(key, value string, duration int64)
get(key string) (string, error)
delete(key string)
}
// Client Cache client.
@@ -84,8 +89,8 @@ type Client struct {
log *logger.Log
}
// Init Initialize cache client.
func (c *Client) Init(log *logger.Log, isRedis bool, host, pass, database string) {
// New Initialize cache client.
func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
c.log = log
if isRedis {
redis.Init(host, pass, database)
@@ -96,27 +101,21 @@ func (c *Client) Init(log *logger.Log, isRedis bool, host, pass, database string
c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
}
// DeleteDecision delete decision in cache.
func (c *Client) DeleteDecision(clientIP string) {
c.log.Debug(fmt.Sprintf("cache:DeleteDecision ip:%v", clientIP))
c.cache.deleteDecision(clientIP)
// Delete delete decision in cache.
func (c *Client) Delete(key string) {
c.log.Debug(fmt.Sprintf("cache:Delete key:%v", key))
c.cache.delete(key)
}
// GetDecision check in the cache if the IP has the banned / not banned value.
// Get check in the cache if the IP has the banned / not banned value.
// Otherwise return with an error to add the IP in cache if we are on.
func (c *Client) GetDecision(clientIP string) (bool, error) {
c.log.Debug(fmt.Sprintf("cache:GetDecision ip:%v", clientIP))
return c.cache.getDecision(clientIP)
func (c *Client) Get(key string) (string, error) {
c.log.Debug(fmt.Sprintf("cache:Get key:%v", key))
return c.cache.get(key)
}
// SetDecision update the cache with the IP as key and the value banned / not banned.
func (c *Client) SetDecision(clientIP string, isBanned bool, duration int64) {
c.log.Debug(fmt.Sprintf("cache:SetDecision ip:%v isBanned:%v duration:%vs", clientIP, isBanned, duration))
var value string
if isBanned {
value = cacheBannedValue
} else {
value = cacheNoBannedValue
}
c.cache.setDecision(clientIP, value, duration)
// Set update the cache with the IP as key and the value banned / not banned.
func (c *Client) Set(key string, value string, duration int64) {
c.log.Debug(fmt.Sprintf("cache:Set key:%v value:%v duration:%vs", key, value, duration))
c.cache.set(key, value, duration)
}
+54 -34
View File
@@ -8,97 +8,117 @@ import (
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
func Test_GetDecision(t *testing.T) {
func Test_Get(t *testing.T) {
IPInCache := "10.0.0.10"
IPNotInCache := "10.0.0.20"
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
client.SetDecision(IPInCache, true, 10)
client.Set(IPInCache, BannedValue, 10)
type args struct {
clientIP string
}
tests := []struct {
name string
args args
want bool
want string
wantErr bool
valueErr string
}{
{name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: true, wantErr: false, valueErr: ""},
{name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: false, wantErr: true, valueErr: "cache:miss"},
{name: "Fetch invalid value", args: args{clientIP: "test"}, want: false, wantErr: true, valueErr: "cache:miss"},
{name: "Fetch empty value", args: args{clientIP: ""}, want: false, wantErr: true, valueErr: "cache:miss"},
{name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: BannedValue, wantErr: false, valueErr: ""},
{name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Fetch invalid value", args: args{clientIP: "test"}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Fetch empty value", args: args{clientIP: ""}, want: "", wantErr: true, valueErr: CacheMiss},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := client.GetDecision(tt.args.clientIP)
got, err := client.Get(tt.args.clientIP)
if (err != nil) != tt.wantErr {
t.Errorf("GetDecision() error = %v, wantErr %v", err, tt.wantErr)
t.Errorf("Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("GetDecision() = %v, want %v", got, tt.want)
t.Errorf("Get() = %v, want %v", got, tt.want)
return
}
if tt.valueErr != "" && tt.valueErr != err.Error() {
t.Errorf("GetDecision() err = %v, want %v", err.Error(), tt.valueErr)
t.Errorf("Get() err = %v, want %v", err.Error(), tt.valueErr)
}
})
}
}
func Test_SetDecision(t *testing.T) {
func Test_Set(t *testing.T) {
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
IPInCache := "10.0.0.11"
type args struct {
clientIP string
value bool
value string
duration int64
}
tests := []struct {
name string
args args
want bool
name string
args args
want string
wantErr bool
valueErr string
}{
{name: "Set valid IP in local cache for 0 sec", args: args{clientIP: IPInCache, value: true, duration: 0}, want: false},
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: true, duration: 10}, want: true},
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: false, duration: 10}, want: false},
{name: "Set valid IP in local cache for 0 sec", args: args{clientIP: IPInCache, value: BannedValue, duration: 0}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: BannedValue, duration: 10}, want: BannedValue, wantErr: false, valueErr: ""},
{name: "Set valid IP in local cache for 10 sec", args: args{clientIP: IPInCache, value: NoBannedValue, duration: 10}, want: NoBannedValue, wantErr: false, valueErr: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client.SetDecision(tt.args.clientIP, tt.args.value, tt.args.duration)
got, _ := client.GetDecision(tt.args.clientIP)
if got != tt.want {
t.Errorf("SetDecision() = %v, want %v", got, tt.want)
client.Set(tt.args.clientIP, tt.args.value, tt.args.duration)
got, err := client.Get(tt.args.clientIP)
if (err != nil) != tt.wantErr {
t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Set() = %v, want %v", got, tt.want)
return
}
if tt.valueErr != "" && tt.valueErr != err.Error() {
t.Errorf("Set() err = %v, want %v", err.Error(), tt.valueErr)
}
})
}
}
func Test_DeleteDecision(t *testing.T) {
func Test_Delete(t *testing.T) {
IPInCache := "10.0.0.12"
IPNotInCache := "10.0.0.22"
client := &Client{cache: &localCache{}, log: logger.New("INFO")}
client.SetDecision(IPInCache, true, 10)
client.Set(IPInCache, BannedValue, 10)
type args struct {
clientIP string
}
tests := []struct {
name string
args args
want bool
name string
args args
want string
wantErr bool
valueErr string
}{
{name: "Delete Known valid IP", args: args{clientIP: IPInCache}, want: false},
{name: "Delete Unknown valid IP", args: args{clientIP: IPNotInCache}, want: false},
{name: "Delete Known valid IP", args: args{clientIP: IPInCache}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Delete Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client.DeleteDecision(tt.args.clientIP)
got, _ := client.GetDecision(tt.args.clientIP)
if got != tt.want {
t.Errorf("DeleteDecision() = %v, want %v", got, tt.want)
client.Delete(tt.args.clientIP)
got, err := client.Get(tt.args.clientIP)
if (err != nil) != tt.wantErr {
t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Delete() = %v, want %v", got, tt.want)
return
}
if tt.valueErr != "" && tt.valueErr != err.Error() {
t.Errorf("Delete() err = %v, want %v", err.Error(), tt.valueErr)
}
})
}
}
+165
View File
@@ -0,0 +1,165 @@
// Package captcha implements utility for captcha management.
package captcha
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
)
// Client Captcha client.
type Client struct {
Valid bool
provider string
siteKey string
secretKey string
gracePeriodSeconds int64
htmlPage *template.Template
cacheClient *cache.Client
httpClient *http.Client
log *logger.Log
}
type infoProvider struct {
js string
key 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-captcha",
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
},
}
)
func compileTemplate(path string) (*template.Template, error) {
var err error
if path == "" {
return nil, fmt.Errorf("no captcha template provided")
}
//nolint:gosec
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
html := string(b)
compiledTemplate, err := template.New("captcha").Parse(html)
if err != nil {
return nil, fmt.Errorf("impossible to compile captcha template: %w", err)
}
return compiledTemplate, nil
}
// New Initialize captcha client.
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, htmlPagePath string, gracePeriodSeconds int64) error {
c.Valid = provider != ""
if !c.Valid {
return nil
}
c.siteKey = siteKey
c.secretKey = secretKey
c.provider = provider
html, err := compileTemplate(htmlPagePath)
if err != nil {
return err
}
c.htmlPage = html
c.gracePeriodSeconds = gracePeriodSeconds
c.log = log
c.httpClient = httpClient
c.cacheClient = cacheClient
return nil
}
// ServeHTTP Handle captcha html page or validation.
func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP string) {
valid, err := c.Validate(r)
if err != nil {
c.log.Debug(fmt.Sprintf("captcha:ServeHTTP:validate %s", err.Error()))
rw.WriteHeader(http.StatusBadRequest)
return
}
if valid {
c.log.Debug("captcha:ServeHTTP captcha:valid")
c.cacheClient.Set(fmt.Sprintf("%s_captcha", remoteIP), cache.CaptchaDoneValue, c.gracePeriodSeconds)
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return
}
err = c.htmlPage.Execute(rw, map[string]string{
"SiteKey": c.siteKey,
"FrontendJS": captcha[c.provider].js,
"FrontendKey": captcha[c.provider].key,
})
if err != nil {
c.log.Info("captcha:ServeHTTP Can't serve HTML")
}
}
// Check Verify if the captcha is already done.
func (c *Client) Check(remoteIP string) bool {
value, _ := c.cacheClient.Get(fmt.Sprintf("%s_captcha", remoteIP))
passed := value == cache.CaptchaDoneValue
c.log.Debug(fmt.Sprintf("captcha:Check ip:%s pass:%v", remoteIP, passed))
return passed
}
type responseProvider struct {
Success bool `json:"success"`
}
// Validate Verify the captcha from provider API.
func (c *Client) Validate(r *http.Request) (bool, error) {
if r.Method != http.MethodPost {
c.log.Debug(fmt.Sprintf("captcha:Validate invalid method: %s", r.Method))
return false, nil
}
var response = r.FormValue(fmt.Sprintf("%s-response", captcha[c.provider].key))
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)
if err != nil {
return false, err
}
defer func() {
if err = res.Body.Close(); err != nil {
c.log.Error(fmt.Sprintf("captcha:Validate %s", err.Error()))
}
}()
if res.Header.Get("content-type") != "application/json" {
return false, nil
}
var captchaResponse responseProvider
err = json.NewDecoder(res.Body).Decode(&captchaResponse)
if err != nil {
return false, err
}
c.log.Debug(fmt.Sprintf("captcha:Validate success:%v", captchaResponse.Success))
return captchaResponse.Success, nil
}
+38 -10
View File
@@ -19,13 +19,16 @@ import (
// Enums for crowdsec mode.
const (
AloneMode = "alone"
StreamMode = "stream"
LiveMode = "live"
NoneMode = "none"
AppsecMode = "appsec"
HTTPS = "https"
HTTP = "http"
AloneMode = "alone"
StreamMode = "stream"
LiveMode = "live"
NoneMode = "none"
AppsecMode = "appsec"
HTTPS = "https"
HTTP = "http"
HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile"
)
// Config the plugin configuration.
@@ -63,6 +66,13 @@ type Config struct {
RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"`
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
CaptchaSecretKeyFile string `json:"captchaSecretKeyFile,omitempty"`
CaptchaGracePeriodSeconds int64 `json:"captchaGracePeriodSeconds,omitempty"`
}
func contains(source []string, target string) bool {
@@ -90,6 +100,11 @@ func New() *Config {
UpdateIntervalSeconds: 60,
DefaultDecisionSeconds: 60,
HTTPTimeoutSeconds: 10,
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaHTMLFilePath: "/captcha.html",
CaptchaGracePeriodSeconds: 1800,
ForwardedHeadersCustomName: "X-Forwarded-For",
ForwardedHeadersTrustedIPs: []string{},
ClientTrustedIPs: []string{},
@@ -156,6 +171,15 @@ func ValidateParams(config *Config) error {
return nil
}
if config.CaptchaProvider != "" {
if _, err := GetVariable(config, "CaptchaSiteKey"); err != nil {
return err
}
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
return err
}
}
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {
return err
}
@@ -254,9 +278,10 @@ func validateParamsRequired(config *Config) error {
}
}
requiredInt := map[string]int64{
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
"UpdateIntervalSeconds": config.UpdateIntervalSeconds,
"DefaultDecisionSeconds": config.DefaultDecisionSeconds,
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
}
for key, val := range requiredInt {
if val < 1 {
@@ -269,6 +294,9 @@ func validateParamsRequired(config *Config) error {
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
return fmt.Errorf("CrowdsecLapiScheme: must be one of 'http' or 'https'")
}
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) {
return fmt.Errorf("CrowdsecLapiScheme: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
}
return nil
}