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
+91 -33
View File
@@ -16,6 +16,7 @@ import (
"time"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
captcha "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/captcha"
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
@@ -75,6 +76,7 @@ type Bouncer struct {
serverPoolStrategy *ip.PoolStrategy
httpClient *http.Client
cacheClient *cache.Client
captchaClient *captcha.Client
log *logger.Log
}
@@ -153,19 +155,38 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n
},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
cacheClient: &cache.Client{},
cacheClient: &cache.Client{},
captchaClient: &captcha.Client{},
}
if config.CrowdsecMode == configuration.AppsecMode {
return bouncer, nil
}
config.RedisCachePassword, _ = configuration.GetVariable(config, "RedisCachePassword")
bouncer.cacheClient.Init(
bouncer.cacheClient.New(
log,
config.RedisCacheEnabled,
config.RedisCacheHost,
config.RedisCachePassword,
config.RedisCacheDatabase,
)
config.CaptchaSiteKey, _ = configuration.GetVariable(config, "CaptchaSiteKey")
config.CaptchaSecretKey, _ = configuration.GetVariable(config, "CaptchaSecretKey")
err = bouncer.captchaClient.New(
log,
bouncer.cacheClient,
&http.Client{
Transport: &http.Transport{MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second},
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
},
config.CaptchaProvider,
config.CaptchaSiteKey,
config.CaptchaSecretKey,
config.CaptchaHTMLFilePath,
config.CaptchaGracePeriodSeconds,
)
if err != nil {
return nil, err
}
if (config.CrowdsecMode == configuration.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil {
if config.CrowdsecMode == configuration.AloneMode {
@@ -221,21 +242,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// TODO This should be simplified
if bouncer.crowdsecMode != configuration.NoneMode {
isBanned, cacheErr := bouncer.cacheClient.GetDecision(remoteIP)
value, cacheErr := bouncer.cacheClient.Get(remoteIP)
if cacheErr != nil {
errString := cacheErr.Error()
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:getDecision ip:%s isBanned:false %s", remoteIP, errString))
if errString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getDecision ip:%s %s", remoteIP, errString))
cacheErrString := cacheErr.Error()
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
if cacheErrString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
rw.WriteHeader(http.StatusForbidden)
return
}
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, isBanned))
if isBanned {
rw.WriteHeader(http.StatusForbidden)
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
handleErrorServeHTTP(bouncer, remoteIP, value, rw, req)
}
return
}
@@ -250,12 +271,12 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden)
}
} else {
err = handleNoStreamCache(bouncer, remoteIP)
if err != nil {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
} else {
value, err := handleNoStreamCache(bouncer, remoteIP)
if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
handleErrorServeHTTP(bouncer, remoteIP, value, rw, req)
}
}
}
@@ -288,10 +309,22 @@ type Login struct {
Expire string `json:"expire"`
}
func handleErrorServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
bouncer.log.Debug(fmt.Sprintf("handleErrorServeHTTP ip:%s remediation:%s", remoteIP, remediation))
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
if bouncer.captchaClient.Check(remoteIP) {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
return
}
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
return
}
rw.WriteHeader(http.StatusForbidden)
}
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
if bouncer.appsecEnabled {
err := appsecQuery(bouncer, remoteIP, req)
if err != nil {
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
return
@@ -327,7 +360,7 @@ func startTicker(config *configuration.Config, log *logger.Log, work func()) cha
}
// We are now in none or live mode.
func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error {
func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
isLiveMode := bouncer.crowdsecMode == configuration.LiveMode
routeURL := url.URL{
Scheme: bouncer.crowdsecScheme,
@@ -337,39 +370,55 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error {
}
body, err := crowdsecQuery(bouncer, routeURL.String(), false)
if err != nil {
return err
return cache.BannedValue, err
}
if bytes.Equal(body, []byte("null")) {
if isLiveMode {
bouncer.cacheClient.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout)
bouncer.cacheClient.Set(remoteIP, cache.NoBannedValue, bouncer.defaultDecisionTimeout)
}
return nil
return cache.NoBannedValue, nil
}
var decisions []Decision
err = json.Unmarshal(body, &decisions)
if err != nil {
return fmt.Errorf("handleNoStreamCache:parseBody %w", err)
return cache.BannedValue, fmt.Errorf("handleNoStreamCache:parseBody %w", err)
}
if len(decisions) == 0 {
if isLiveMode {
bouncer.cacheClient.SetDecision(remoteIP, false, bouncer.defaultDecisionTimeout)
bouncer.cacheClient.Set(remoteIP, cache.NoBannedValue, bouncer.defaultDecisionTimeout)
}
return nil
return cache.NoBannedValue, nil
}
duration, err := time.ParseDuration(decisions[0].Duration)
var decision Decision
for _, d := range decisions {
decision = d
if decision.Type == "ban" {
break
}
}
duration, err := time.ParseDuration(decision.Duration)
if err != nil {
return fmt.Errorf("handleNoStreamCache:parseDuration %w", err)
return cache.BannedValue, fmt.Errorf("handleNoStreamCache:parseDuration %w", err)
}
var value string
switch decision.Type {
case "ban":
value = cache.BannedValue
case "captcha":
value = cache.CaptchaValue
default:
bouncer.log.Debug(fmt.Sprintf("handleStreamCache:unknownType %s", decision.Type))
}
if isLiveMode {
durationSecond := int64(duration.Seconds())
if bouncer.defaultDecisionTimeout < durationSecond {
durationSecond = bouncer.defaultDecisionTimeout
}
bouncer.cacheClient.SetDecision(remoteIP, true, durationSecond)
bouncer.cacheClient.Set(remoteIP, value, durationSecond)
}
return fmt.Errorf("handleNoStreamCache:banned")
return value, fmt.Errorf("handleNoStreamCache:banned")
}
func getToken(bouncer *Bouncer) error {
@@ -401,7 +450,7 @@ func handleStreamCache(bouncer *Bouncer) error {
// Instead of blocking the goroutine interval for all the secondary node,
// if the master service is shut down, other goroutine can take the lead
// because updated routine information is in the cache
_, err := bouncer.cacheClient.GetDecision(cacheTimeoutKey)
_, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated")
return nil
@@ -409,7 +458,7 @@ func handleStreamCache(bouncer *Bouncer) error {
if err.Error() != cache.CacheMiss {
return err
}
bouncer.cacheClient.SetDecision(cacheTimeoutKey, false, bouncer.updateInterval-1)
bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, bouncer.updateInterval-1)
streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
@@ -428,11 +477,20 @@ func handleStreamCache(bouncer *Bouncer) error {
for _, decision := range stream.New {
duration, err := time.ParseDuration(decision.Duration)
if err == nil {
bouncer.cacheClient.SetDecision(decision.Value, true, int64(duration.Seconds()))
var value string
switch decision.Type {
case "ban":
value = cache.BannedValue
case "captcha":
value = cache.CaptchaValue
default:
bouncer.log.Debug(fmt.Sprintf("handleStreamCache:unknownType %s", decision.Type))
}
bouncer.cacheClient.Set(decision.Value, value, int64(duration.Seconds()))
}
}
for _, decision := range stream.Deleted {
bouncer.cacheClient.DeleteDecision(decision.Value)
bouncer.cacheClient.Delete(decision.Value)
}
bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamHealthy = true