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
+3
View File
@@ -43,6 +43,9 @@ run_tlsauth:
run_appsec: run_appsec:
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml up -d docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.yml up -d
run_captcha:
docker compose -f examples/captcha/docker-compose.captcha.yml up -d
run: run:
docker compose -f docker-compose.yml up -d --remove-orphans docker compose -f docker-compose.yml up -d --remove-orphans
+43 -6
View File
@@ -28,6 +28,16 @@ The AppSec Component offers:
- Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection. - Combining classic WAF benefits with advanced CrowdSec features for otherwise difficult advanced behavior detection.
More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/). More information on appsec in the [Crowdsec Documentation](https://doc.crowdsec.net/docs/next/appsec/intro/).
Remediation offered by [Crowdsec](https://docs.crowdsec.net/u/bouncers/intro) and supported by the plugin can be either `ban` or `captcha`.
For the `ban` remediation the user will be blocked in Traefik (HTTP 403).
For the `captcha` remediation, the user will be redirected to a page to complete a captcha challenge.
On successfull completion, he will be cleaned for a specified period of time before a new resolution challenge is expected if Crowdsec still has a decision to verify the user behavior. See the example captcha for more informations and configuration intructions.
The following captcha providers are supported now:
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/fr-fr/products/turnstile/)
There are 4 operating modes (CrowdsecMode) for this plugin: There are 4 operating modes (CrowdsecMode) for this plugin:
@@ -92,7 +102,7 @@ Only one instance of the plugin is *possible*.
- CrowdsecLapiKey - CrowdsecLapiKey
- string - string
- default: "" - default: ""
- Crowdsec LAPI key for the bouncer : **must be unique by service**. - Crowdsec LAPI key for the bouncer.
- CrowdsecLapiTlsInsecureVerify - CrowdsecLapiTlsInsecureVerify
- bool - bool
- default: false - default: false
@@ -158,12 +168,32 @@ Only one instance of the plugin is *possible*.
- CrowdsecCapiScenarios - CrowdsecCapiScenarios
- []string - []string
- Used only in `alone` mode, scenarios for Crowdsec CAPI - Used only in `alone` mode, scenarios for Crowdsec CAPI
- CaptchaProvider
- string
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
- CaptchaSiteKey
- string
- Site key for the captcha provider
- CaptchaSecretKey
- string
- Site secret key for the captcha provider
- CaptchaHTMLFilePath
- string
- default: /captcha.html
- Path where the captcha template is stored
- CaptchaGracePeriodSeconds
- int64
- default: 1800 (= 30 minutes)
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
### Configuration ### Configuration
For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages). For each plugin, the Traefik static configuration must define the module name (as is usual for Go packages).
The following declaration (given here in YAML) defines a plugin: The following declaration (given here in YAML) defines a plugin:
> Note that you don't need to copy all thoses settings but only the ones you want to use.
> See the examples for advanced usage.
```yaml ```yaml
# Static configuration # Static configuration
@@ -250,6 +280,11 @@ http:
ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA= ic5cDRo6/VD3CS3MYzyBcibaGaV34nr0G/pI+KEqkYChzk/PZRA=
-----END RSA PRIVATE KEY----- -----END RSA PRIVATE KEY-----
crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem crowdsecLapiTLSCertificateBouncerKeyFile: /etc/traefik/crowdsec-certs/bouncer-key.pem
captchaProvider: hcaptcha
captchaSiteKey: FIXME
captchaSecretKey: FIXME
captchaGracePeriodSeconds: 1800
captchaHTMLFilePath: /captcha.html
``` ```
#### Fill variable with value of file #### Fill variable with value of file
@@ -271,7 +306,7 @@ You can generate a crowdsec API key for the LAPI.
You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt) You can follow the documentation here: [docs.crowdsec.net/docs/user_guides/lapi_mgmt](https://docs.crowdsec.net/docs/user_guides/lapi_mgmt)
```bash ```bash
docker-compose -f docker-compose-local.yml up -d crowdsec docker compose -f docker-compose-local.yml up -d crowdsec
docker exec crowdsec cscli bouncers add crowdsecBouncer docker exec crowdsec cscli bouncers add crowdsecBouncer
``` ```
@@ -295,7 +330,7 @@ Note:
You can then run all the containers: You can then run all the containers:
```bash ```bash
docker-compose up -d docker compose up -d
``` ```
#### Use certificates to authenticate with CrowdSec #### Use certificates to authenticate with CrowdSec
@@ -316,9 +351,11 @@ Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bounc
#### Manually add an IP to the blocklist (for testing purposes) #### Manually add an IP to the blocklist (for testing purposes)
```bash ```bash
docker-compose up -d crowdsec docker compose up -d crowdsec
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m # this will be effective 10min docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m # this will be effective 10min
docker exec crowdsec cscli decisions remove --ip 10.0.0.10 docker exec crowdsec cscli decisions remove --ip 10.0.0.10
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m -t captcha # this will return a captcha challenge
docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
``` ```
### Examples ### Examples
@@ -337,9 +374,9 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10
#### 7. Using Traefik in standalone mode without Crowdsec [examples/standalone-mode/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/standalone-mode/README.md) #### 7. Using Traefik in standalone mode without Crowdsec [examples/standalone-mode/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/standalone-mode/README.md)
#### 8. Using Traefik with AppSec feature enabled [examples/appsec-enabled/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/appsec-enabled/README.md) #### 8. Using Traefik with AppSec feature enabled [examples/appsec-enabled/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/appsec-enabled/README.md)
#### 9. Using Traefik with Captcha remediation feature enabled [examples/captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/captcha/README.md)
### Local Mode ### Local Mode
@@ -369,7 +406,7 @@ For local development, a `docker-compose.local.yml` is provided which reproduces
This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations. This works once you have generated and filled your *LAPI-KEY* (crowdsecLapiKey), if not read above for informations.
```bash ```bash
docker-compose -f docker-compose.local.yml up -d docker compose -f docker-compose.local.yml up -d
``` ```
Equivalent to Equivalent to
```bash ```bash
+91 -33
View File
@@ -16,6 +16,7 @@ import (
"time" "time"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache" 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" configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip" ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
@@ -75,6 +76,7 @@ type Bouncer struct {
serverPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy
httpClient *http.Client httpClient *http.Client
cacheClient *cache.Client cacheClient *cache.Client
captchaClient *captcha.Client
log *logger.Log 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, Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
}, },
cacheClient: &cache.Client{}, cacheClient: &cache.Client{},
captchaClient: &captcha.Client{},
} }
if config.CrowdsecMode == configuration.AppsecMode { if config.CrowdsecMode == configuration.AppsecMode {
return bouncer, nil return bouncer, nil
} }
config.RedisCachePassword, _ = configuration.GetVariable(config, "RedisCachePassword") config.RedisCachePassword, _ = configuration.GetVariable(config, "RedisCachePassword")
bouncer.cacheClient.Init( bouncer.cacheClient.New(
log, log,
config.RedisCacheEnabled, config.RedisCacheEnabled,
config.RedisCacheHost, config.RedisCacheHost,
config.RedisCachePassword, config.RedisCachePassword,
config.RedisCacheDatabase, 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.StreamMode || config.CrowdsecMode == configuration.AloneMode) && ticker == nil {
if config.CrowdsecMode == configuration.AloneMode { if config.CrowdsecMode == configuration.AloneMode {
@@ -221,21 +242,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
// TODO This should be simplified // TODO This should be simplified
if bouncer.crowdsecMode != configuration.NoneMode { if bouncer.crowdsecMode != configuration.NoneMode {
isBanned, cacheErr := bouncer.cacheClient.GetDecision(remoteIP) value, cacheErr := bouncer.cacheClient.Get(remoteIP)
if cacheErr != nil { if cacheErr != nil {
errString := cacheErr.Error() cacheErrString := cacheErr.Error()
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:getDecision ip:%s isBanned:false %s", remoteIP, errString)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
if errString != cache.CacheMiss { if cacheErrString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getDecision ip:%s %s", remoteIP, errString)) bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
return return
} }
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, isBanned)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
if isBanned { if value == cache.NoBannedValue {
rw.WriteHeader(http.StatusForbidden)
} else {
handleNextServeHTTP(bouncer, remoteIP, rw, req) handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
handleErrorServeHTTP(bouncer, remoteIP, value, rw, req)
} }
return return
} }
@@ -250,12 +271,12 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
} }
} else { } else {
err = handleNoStreamCache(bouncer, remoteIP) value, err := handleNoStreamCache(bouncer, remoteIP)
if err != nil { if value == cache.NoBannedValue {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
} else {
handleNextServeHTTP(bouncer, remoteIP, rw, req) 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"` 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) { func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
if bouncer.appsecEnabled { if bouncer.appsecEnabled {
err := appsecQuery(bouncer, remoteIP, req) if err := appsecQuery(bouncer, remoteIP, req); err != nil {
if err != nil {
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error())) bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden) rw.WriteHeader(http.StatusForbidden)
return return
@@ -327,7 +360,7 @@ func startTicker(config *configuration.Config, log *logger.Log, work func()) cha
} }
// We are now in none or live mode. // 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 isLiveMode := bouncer.crowdsecMode == configuration.LiveMode
routeURL := url.URL{ routeURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
@@ -337,39 +370,55 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) error {
} }
body, err := crowdsecQuery(bouncer, routeURL.String(), false) body, err := crowdsecQuery(bouncer, routeURL.String(), false)
if err != nil { if err != nil {
return err return cache.BannedValue, err
} }
if bytes.Equal(body, []byte("null")) { if bytes.Equal(body, []byte("null")) {
if isLiveMode { 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 var decisions []Decision
err = json.Unmarshal(body, &decisions) err = json.Unmarshal(body, &decisions)
if err != nil { if err != nil {
return fmt.Errorf("handleNoStreamCache:parseBody %w", err) return cache.BannedValue, fmt.Errorf("handleNoStreamCache:parseBody %w", err)
} }
if len(decisions) == 0 { if len(decisions) == 0 {
if isLiveMode { 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 { 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 { if isLiveMode {
durationSecond := int64(duration.Seconds()) durationSecond := int64(duration.Seconds())
if bouncer.defaultDecisionTimeout < durationSecond { if bouncer.defaultDecisionTimeout < durationSecond {
durationSecond = bouncer.defaultDecisionTimeout 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 { 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, // Instead of blocking the goroutine interval for all the secondary node,
// if the master service is shut down, other goroutine can take the lead // if the master service is shut down, other goroutine can take the lead
// because updated routine information is in the cache // because updated routine information is in the cache
_, err := bouncer.cacheClient.GetDecision(cacheTimeoutKey) _, err := bouncer.cacheClient.Get(cacheTimeoutKey)
if err == nil { if err == nil {
bouncer.log.Debug("handleStreamCache:alreadyUpdated") bouncer.log.Debug("handleStreamCache:alreadyUpdated")
return nil return nil
@@ -409,7 +458,7 @@ func handleStreamCache(bouncer *Bouncer) error {
if err.Error() != cache.CacheMiss { if err.Error() != cache.CacheMiss {
return err return err
} }
bouncer.cacheClient.SetDecision(cacheTimeoutKey, false, bouncer.updateInterval-1) bouncer.cacheClient.Set(cacheTimeoutKey, cache.NoBannedValue, bouncer.updateInterval-1)
streamRouteURL := url.URL{ streamRouteURL := url.URL{
Scheme: bouncer.crowdsecScheme, Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost, Host: bouncer.crowdsecHost,
@@ -428,11 +477,20 @@ func handleStreamCache(bouncer *Bouncer) error {
for _, decision := range stream.New { for _, decision := range stream.New {
duration, err := time.ParseDuration(decision.Duration) duration, err := time.ParseDuration(decision.Duration)
if err == nil { 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 { for _, decision := range stream.Deleted {
bouncer.cacheClient.DeleteDecision(decision.Value) bouncer.cacheClient.Delete(decision.Value)
} }
bouncer.log.Debug("handleStreamCache:updated") bouncer.log.Debug("handleStreamCache:updated")
isCrowdsecStreamHealthy = true isCrowdsecStreamHealthy = true
+1 -1
View File
@@ -130,7 +130,7 @@ func Test_handleNoStreamCache(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if err := handleNoStreamCache(tt.args.bouncer, tt.args.remoteIP); (err != nil) != tt.wantErr { if _, err := handleNoStreamCache(tt.args.bouncer, tt.args.remoteIP); (err != nil) != tt.wantErr {
t.Errorf("handleNoStreamCache() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("handleNoStreamCache() error = %v, wantErr %v", err, tt.wantErr)
} }
}) })
+338
View File
@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>CrowdSec Captcha</title>
<meta content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/
*,
:after,
:before {
border: 0 solid #e5e7eb;
box-sizing: border-box
}
:after,
:before {
--tw-content: ""
}
html {
-webkit-text-size-adjust: 100%;
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
body {
line-height: inherit;
margin: 0
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit
}
a {
color: inherit;
text-decoration: inherit
}
h1,
h2,
h3,
h4,
h5,
h6,
hr,
p,
pre {
margin: 0
}
*,
::backdrop,
:after,
:before {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #3b82f680;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia:
}
.flex {
display: flex
}
.flex-wrap {
flex-wrap: wrap
}
.inline-flex {
display: inline-flex
}
.h-24 {
height: 6rem
}
.h-6 {
height: 1.5rem
}
.h-full {
height: 100%
}
.h-screen {
height: 100vh
}
.text-center {
text-align: center
}
.w-24 {
width: 6rem
}
.w-6 {
width: 1.5rem
}
.w-full {
width: 100%
}
.w-screen {
width: 100vw
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem
}
.flex-col {
flex-direction: column
}
.items-center {
align-items: center
}
.justify-center {
justify-content: center
}
.justify-between {
justify-content: space-between
}
.space-y-1>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(.25rem*var(--tw-space-y-reverse));
margin-top: calc(.25rem*(1 - var(--tw-space-y-reverse)))
}
.space-y-4>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(1rem*var(--tw-space-y-reverse));
margin-top: calc(1rem*(1 - var(--tw-space-y-reverse)))
}
.rounded-xl {
border-radius: .75rem
}
.border-2 {
border-width: 2px
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0/var(--tw-border-opacity))
}
.p-4 {
padding: 1rem
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem
}
.py-2 {
padding-bottom: .5rem;
padding-top: .5rem
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem
}
.text-sm {
font-size: .875rem;
line-height: 1.25rem
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
.font-bold {
font-weight: 700
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255/var(--tw-text-opacity))
}
@media (min-width:640px) {
.sm\:w-2\/3 {
width: 66.666667%
}
}
@media (min-width:768px) {
.md\:flex-row {
flex-direction: row
}
}
@media (min-width:1024px) {
.lg\:w-1\/2 {
width: 50%
}
.lg\:text-3xl {
font-size: 1.875rem;
line-height: 2.25rem
}
.lg\:text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
}
@media (min-width:1280px) {
.xl\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem
}
}
</style>
<script src="{{ .FrontendJS }}" async defer></script>
</head>
<body class="h-screen w-screen p-4">
<div class="h-full w-full flex flex-col justify-center items-center">
<div class="border-2 border-black rounded-xl p-4 text-center w-full sm:w-2/3 lg:w-1/2">
<div class="flex flex-col items-center space-y-4">
<svg fill="black" class="h-24 w-24" aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="exclamation-triangle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"
class="warning">
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z">
</path>
</svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div>
</form>
<div class="flex justify-center flex-wrap">
<p class="my-3">This security check has been powered by</p>
<a href="https://crowdsec.net/" target="_blank" rel="noopener" class="inline-flex flex-col items-center">
<svg fill="black" width="33.92" height="33.76" viewBox="0 0 254.4 253.2">
<defs>
<clipPath id="a">
<path d="M0 52h84v201.2H0zm0 0" />
</clipPath>
<clipPath id="b">
<path d="M170 52h84.4v201.2H170zm0 0" />
</clipPath>
</defs>
<path
d="M59.3 128.4c1.4 2.3 2.5 4.6 3.4 7-1-4.1-2.3-8.1-4.3-12-3.1-6-7.8-5.8-10.7 0-2 4-3.2 8-4.3 12.1 1-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M207.8 128.4a42.9 42.9 0 013.4 7c-1-4.1-2.3-8.1-4.3-12-3.2-6-7.8-5.8-10.7 0-2 4-3.3 8-4.3 12.1.9-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M134.6 92.9c2 3.5 3.6 7 4.8 10.7-1.3-5.4-3-10.6-5.6-15.7-4-7.5-9.7-7.2-13.3 0a75.4 75.4 0 00-5.6 16c1.2-3.8 2.7-7.4 4.7-11 4.1-7.2 10.6-7.5 15 0M43.8 136.8c.9 4.6 3.7 8.3 7.3 9.2 0 2.7 0 5.5.2 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4M192.4 136.8c.8 4.6 3.7 8.3 7.2 9.2 0 2.7 0 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3.9 2.2 1.2 0 .6-3 .7-6.3 1-9.6.2-2.7.3-5.5.2-8.2 3.6-1 6.4-4.6 7.3-9.2a17.8 17.8 0 01-9.1 2.4c-3.4 0-6.6-1-9.1-2.4M138.3 104.6c-3.1 1.9-7 3-11.3 3-4.3 0-8.2-1.1-11.3-3 1 5.8 4.5 10.3 9 11.5 0 3.4 0 6.8.3 10.2.4 4.1.5 8.2 1.2 12 .4 2.9 1.2 2.7 1.6 0 .7-3.8.8-7.9 1.2-12 .3-3.4.3-6.8.3-10.2 4.5-1.2 8-5.7 9-11.5" />
<path
d="M51 146c0 2.7.1 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4.9 4.6 3.7 8.3 7.3 9.2M143.9 105c-1.9-.4-3.5-1.2-4.9-2.3 1.4 5.6 2.5 11.3 4 17 1.2 5 2 10 2.4 15 .6 7.8-4.5 14.5-10.9 14.5h-15c-6.4 0-11.5-6.7-11-14.5.5-5 1.3-10 2.6-15 1.3-5.3 2.3-10.5 3.6-15.7-2.2 1.2-4.8 1.9-7.7 2-4.7.1-9.4-.3-14-1-4-.4-6.7-3-8-6.7-1.3-3.4-2-7-3.3-10.4-.5-1.5-1.6-2.8-2.4-4.2-.4-.6-.8-1.2-.9-1.8v-7.8a77 77 0 0124.5-3c6.1 0 12 1 17.8 3.2 4.7 1.7 9.7 1.8 14.4 0 9-3.4 18.2-3.8 27.5-3 4.9.5 9.8 1.6 14.8 2.4v8.2c0 .6-.3 1.5-.7 1.7-2 .9-2.2 2.7-2.7 4.5-.9 3.2-1.8 6.4-2.9 9.5a11 11 0 01-8.8 7.7 40.6 40.6 0 01-18.4-.2m29.4 80.6c-3.2-26.8-6.4-50-8.9-60.7a14.3 14.3 0 0014.1-14h.4a9 9 0 005.6-16.5 14.3 14.3 0 00-3.7-27.2 9 9 0 00-6.9-14.6c2.4-1.1 4.5-3 5.8-5 3.4-5.3 4-29-8-44.4-5-6.3-9.8-2.5-10 1.8-1 13.2-1.1 23-4.5 34.3a9 9 0 00-16-4.1 14.3 14.3 0 00-28.4 0 9 9 0 00-16 4.1c-3.4-11.2-3.5-21.1-4.4-34.3-.3-4.3-5.2-8-10-1.8-12 15.3-11.5 39-8.1 44.4 1.3 2 3.4 3.9 5.8 5a9 9 0 00-7 14.6 14.3 14.3 0 00-3.6 27.2A9 9 0 0075 111h.5a14.5 14.5 0 0014.3 14c-4 17.2-10 66.3-15 111.3l-1.3 13.4a1656.4 1656.4 0 01106.6 0l-1.4-12.7-5.4-51.3" />
<g clip-path="url(#a)">
<path
d="M83.5 136.6l-2.3.7c-5 1-9.8 1-14.8-.2-1.4-.3-2.7-1-3.8-1.9l3.1 13.7c1 4 1.7 8 2 12 .5 6.3-3.6 11.6-8.7 11.6H46.9c-5.1 0-9.2-5.3-8.7-11.6.3-4 1-8 2-12 1-4.2 1.8-8.5 2.9-12.6-1.8 1-3.9 1.5-6.3 1.6a71 71 0 01-11.1-.7 7.7 7.7 0 01-6.5-5.5c-1-2.7-1.6-5.6-2.6-8.3-.4-1.2-1.3-2.3-2-3.4-.2-.4-.6-1-.6-1.4v-6.3c6.4-2 13-2.6 19.6-2.5 4.9.1 9.6 1 14.2 2.6 3.9 1.4 7.9 1.5 11.7 0 1.8-.7 3.6-1.2 5.5-1.6a13 13 0 01-1.6-15.5A18.3 18.3 0 0159 73.1a11.5 11.5 0 00-17.4 8.1 7.2 7.2 0 00-12.9 3.3c-2.7-9-2.8-17-3.6-27.5-.2-3.4-4-6.5-8-1.4C7.5 67.8 7.9 86.9 10.6 91c1.1 1.7 2.8 3.1 4.7 4a7.2 7.2 0 00-5.6 11.7 11.5 11.5 0 00-2.9 21.9 7.2 7.2 0 004.5 13.2h.3c0 .6 0 1.1.2 1.7.9 5.4 5.6 9.5 11.3 9.5A1177.2 1177.2 0 0010 253.2c18.1-1.5 38.1-2.6 59.5-3.4.4-4.6.8-9.3 1.4-14 1.2-11.6 3.3-30.5 5.7-49.7 2.2-18 4.7-36.3 7-49.5" />
</g>
<g clip-path="url(#b)">
<path
d="M254.4 118.2c0-5.8-4.2-10.5-9.7-11.4a7.2 7.2 0 00-5.6-11.7c2-.9 3.6-2.3 4.7-4 2.7-4.2 3.1-23.3-6.5-35.5-4-5.1-7.8-2-8 1.4-.8 10.5-.9 18.5-3.6 27.5a7.2 7.2 0 00-12.8-3.3 11.5 11.5 0 00-17.8-7.9 18.4 18.4 0 01-4.5 22 13 13 0 01-1.3 15.2c2.4.5 4.8 1 7.1 2 3.8 1.3 7.8 1.4 11.6 0 7.2-2.8 14.6-3 22-2.4 4 .4 7.9 1.2 12 1.9l-.1 6.6c0 .5-.2 1.2-.5 1.3-1.7.7-1.8 2.2-2.2 3.7l-2.3 7.6a8.8 8.8 0 01-7 6.1c-5 1-10 1-14.9-.2-1.5-.3-2.8-1-3.9-1.9 1.2 4.5 2 9.1 3.2 13.7 1 4 1.6 8 2 12 .4 6.3-3.6 11.6-8.8 11.6h-12c-5.2 0-9.3-5.3-8.8-11.6.4-4 1-8 2-12 1-4.2 1.9-8.5 3-12.6-1.8 1-4 1.5-6.3 1.6-3.7 0-7.5-.3-11.2-.7a7.7 7.7 0 01-3.7-1.5c3.1 18.4 7.1 51.2 12.5 100.9l.6 5.3.8 7.9c21.4.7 41.5 1.9 59.7 3.4L243 243l-4.4-41.2a606 606 0 00-7-48.7 11.5 11.5 0 0011.2-11.2h.4a7.2 7.2 0 004.4-13.2c4-1.8 6.8-5.8 6.8-10.5" />
</g>
<path
d="M180 249.6h.4a6946 6946 0 00-7.1-63.9l5.4 51.3 1.4 12.6M164.4 125c2.5 10.7 5.7 33.9 8.9 60.7a570.9 570.9 0 00-8.9-60.7M74.8 236.3l-1.4 13.4 1.4-13.4" />
</svg>
<span>CrowdSec</span>
</a>
</div>
</div>
</div>
<script>
function captchaCallback() {
setTimeout(() => document.querySelector('#captcha-form').submit(), 500);
}
</script>
</body>
</html>
+9 -13
View File
@@ -1,8 +1,6 @@
version: "3.8"
services: services:
traefik: traefik:
image: "traefik:v2.10.4" image: "traefik:v2.11.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -33,11 +31,9 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
- "traefik.http.services.service-foo.loadbalancer.server.port=80" - "traefik.http.services.service-foo.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
whoami2: whoami2:
image: traefik/whoami image: traefik/whoami
container_name: "simple-service-bar" container_name: "simple-service-bar"
@@ -46,15 +42,15 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)" - "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec-bar@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.loglevel=DEBUG" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.crowdsecappsecenabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5=" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:dev image: crowdsecurity/crowdsec:1.6.0
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
+9 -18
View File
@@ -1,8 +1,6 @@
version: "3.8"
services: services:
traefik: traefik:
image: "traefik:v2.10.4" image: "traefik:v2.11.0"
container_name: "traefik" container_name: "traefik"
restart: unless-stopped restart: unless-stopped
command: command:
@@ -33,16 +31,9 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-foo.rule=Path(`/foo`)" - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-foo.loadbalancer.server.port=80" - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# Definition of the middleware
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.enabled=true"
# crowdseclapikey must be unique to the middleware attached to the service
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY-1="
# forwardedheaderstrustedips should be the IP of the proxy that is in front of traefik (if any)
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
whoami2: whoami2:
image: traefik/whoami image: traefik/whoami
@@ -53,20 +44,20 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-bar.rule=Path(`/bar`)" - "traefik.http.routers.router-bar.rule=Path(`/bar`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec-bar@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
# Definitin of the middleware # Definitin of the middleware
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# crowdseclapikey must be unique to the middleware attached to the service # crowdseclapikey is the key to authenticate to crowdsec
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY-1=" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=FIXME-LAPI-KEY-1="
# enable AppSec real time check # enable AppSec real time check
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.crowdsecappsecenabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
# forwardedheaderstrustedips should be the IP of the proxy that is in front of traefik (if any) # forwardedheaderstrustedips should be the IP of the proxy that is in front of traefik (if any)
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
crowdsec: crowdsec:
image: crowdsecurity/crowdsec:v1.5.3 image: crowdsecurity/crowdsec:v1.6.0
container_name: "crowdsec" container_name: "crowdsec"
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -36,7 +36,7 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)" - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-foo.loadbalancer.server.port=80" - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# Definition of the middleware # Definition of the middleware
@@ -54,7 +54,7 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-foo.rule=Path(`/foo`)" - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-foo.loadbalancer.server.port=80" - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# Definition of the middleware # Definition of the middleware
@@ -73,15 +73,11 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-bar.rule=Path(`/bar`)" - "traefik.http.routers.router-bar.rule=Path(`/bar`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec-bar@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
# Definition of the middleware # Definition of the middleware
- "traefik.http.middlewares.crowdsec-bar.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=live"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.forwardedheaderstrustedips=172.21.0.5"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
crowdsec: crowdsec:
+146
View File
@@ -0,0 +1,146 @@
# Example
## Enabling catpcha response from crowdsec
Crowdsec support 3 remediations solutions `ban`, `captcha`, and `throttle`.
This plugins support the `ban` and `captcha` remediation.
### Traefik configuration
The minimal configuration is defined below.
For now 3 captcha providers are supported:
- [hcaptcha](https://www.hcaptcha.com/)
- [recaptcha](https://www.google.com/recaptcha/about/)
- [turnstile](https://www.cloudflare.com/fr-fr/products/turnstile/)
```yaml
labels:
# Choose captcha provider
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=hcaptcha"
# Define captcha site key
- "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
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
# Define captcha HTML file path
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
```
The captcha HTML file must be present in the Traefik container (bind mounted or added during a custom build).
It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/captcha.html) it locally to expose it to Traefik.
```yaml
...
traefik:
image: "traefik:v2.11.0"
volumes:
- './captcha.html:/captcha.html'
...
```
### Crowdsec configuration
Crowdsec by default will take the ban action on suspicious activity detected in logs.
To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profiles.yaml`.
2 modes are supported:
- Always return a captcha decision
- Return a captcha decision the first X times and then a ban decision.
The second mode could be used to prevent repeated malicious activity.
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/profiles/captcha_profile/).
```yaml
...
crowdsec:
image: crowdsecurity/crowdsec:v1.6.0
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'
...
```
## Exemple navigation
We can try to query normally the whoami server:
```bash
curl http://localhost:8000/foo
```
We can try to ban ourself
```bash
docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 4h --type captcha
```
![image decision captcha](image_decision_captcha.png)
We will see in the browser the captcha validation page:
![image captcha validation](image_captcha_validation.png)
To play the demo environment run:
```bash
make run_captcha
```
> Note, if we are banned with a "ban" decision from crowdsec a captcha will not be asked and you will have to wait for the decision to expire or remove it manually.
```bash
docker exec crowdsec cscli decisions add --ip 10.0.0.10 -d 10m --type ban
```
## Captcha Workflow
> Context: The user has no decision attached to his IP
```mermaid
sequenceDiagram
participant User
participant TraefikPlugin
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the user IP has a crowdsec decision ?
Destroy PluginCache
PluginCache-->>TraefikPlugin: Nothing, all good!
Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
> Context: The user has a captcha decision attached to his IP
```mermaid
sequenceDiagram
participant User
participant TraefikPlugin
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
PluginCache-->>TraefikPlugin: Yes a Catpcha Decision
TraefikPlugin->>User: Please complete this captcha
User->>TraefikPlugin: Fine, done!
create participant ProviderCaptcha
TraefikPlugin-->>ProviderCaptcha: Is the validation OK ?
Destroy ProviderCaptcha
ProviderCaptcha-->>TraefikPlugin: Yes
TraefikPlugin-->>PluginCache: Set the User IP Clean for captchaGracePeriodSeconds
Destroy PluginCache
PluginCache-->>TraefikPlugin: Done
Destroy TraefikPlugin
TraefikPlugin->>Webserver: Forwarding this HTTP Request from User
Webserver->>User: HTTP Response
```
> Context: The user has a ban decision attached to his IP
```mermaid
sequenceDiagram
participant User
participant TraefikPlugin
User->>TraefikPlugin: Can I access that webpage
create participant PluginCache
TraefikPlugin-->>PluginCache: Does the User IP has a Crowdsec Decision ?
Destroy PluginCache
PluginCache-->>TraefikPlugin: Yes a ban Decision
TraefikPlugin->>User: No, HTTP 403
```
+338
View File
@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>CrowdSec Captcha</title>
<meta content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
/*! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com*/
*,
:after,
:before {
border: 0 solid #e5e7eb;
box-sizing: border-box
}
:after,
:before {
--tw-content: ""
}
html {
-webkit-text-size-adjust: 100%;
font-feature-settings: normal;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4
}
body {
line-height: inherit;
margin: 0
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit
}
a {
color: inherit;
text-decoration: inherit
}
h1,
h2,
h3,
h4,
h5,
h6,
hr,
p,
pre {
margin: 0
}
*,
::backdrop,
:after,
:before {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: #3b82f680;
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia:
}
.flex {
display: flex
}
.flex-wrap {
flex-wrap: wrap
}
.inline-flex {
display: inline-flex
}
.h-24 {
height: 6rem
}
.h-6 {
height: 1.5rem
}
.h-full {
height: 100%
}
.h-screen {
height: 100vh
}
.text-center {
text-align: center
}
.w-24 {
width: 6rem
}
.w-6 {
width: 1.5rem
}
.w-full {
width: 100%
}
.w-screen {
width: 100vw
}
.my-3 {
margin-top: 0.75rem;
margin-bottom: 0.75rem
}
.flex-col {
flex-direction: column
}
.items-center {
align-items: center
}
.justify-center {
justify-content: center
}
.justify-between {
justify-content: space-between
}
.space-y-1>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(.25rem*var(--tw-space-y-reverse));
margin-top: calc(.25rem*(1 - var(--tw-space-y-reverse)))
}
.space-y-4>:not([hidden])~:not([hidden]) {
--tw-space-y-reverse: 0;
margin-bottom: calc(1rem*var(--tw-space-y-reverse));
margin-top: calc(1rem*(1 - var(--tw-space-y-reverse)))
}
.rounded-xl {
border-radius: .75rem
}
.border-2 {
border-width: 2px
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0/var(--tw-border-opacity))
}
.p-4 {
padding: 1rem
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem
}
.py-2 {
padding-bottom: .5rem;
padding-top: .5rem
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem
}
.text-sm {
font-size: .875rem;
line-height: 1.25rem
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
.font-bold {
font-weight: 700
}
.text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255/var(--tw-text-opacity))
}
@media (min-width:640px) {
.sm\:w-2\/3 {
width: 66.666667%
}
}
@media (min-width:768px) {
.md\:flex-row {
flex-direction: row
}
}
@media (min-width:1024px) {
.lg\:w-1\/2 {
width: 50%
}
.lg\:text-3xl {
font-size: 1.875rem;
line-height: 2.25rem
}
.lg\:text-xl {
font-size: 1.25rem;
line-height: 1.75rem
}
}
@media (min-width:1280px) {
.xl\:text-4xl {
font-size: 2.25rem;
line-height: 2.5rem
}
}
</style>
<script src="{{ .FrontendJS }}" async defer></script>
</head>
<body class="h-screen w-screen p-4">
<div class="h-full w-full flex flex-col justify-center items-center">
<div class="border-2 border-black rounded-xl p-4 text-center w-full sm:w-2/3 lg:w-1/2">
<div class="flex flex-col items-center space-y-4">
<svg fill="black" class="h-24 w-24" aria-hidden="true" focusable="false" data-prefix="fas"
data-icon="exclamation-triangle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"
class="warning">
<path
d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z">
</path>
</svg>
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div>
<form action="" method="POST" class="flex flex-col space-y-1" id="captcha-form">
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
</div>
</form>
<div class="flex justify-center flex-wrap">
<p class="my-3">This security check has been powered by</p>
<a href="https://crowdsec.net/" target="_blank" rel="noopener" class="inline-flex flex-col items-center">
<svg fill="black" width="33.92" height="33.76" viewBox="0 0 254.4 253.2">
<defs>
<clipPath id="a">
<path d="M0 52h84v201.2H0zm0 0" />
</clipPath>
<clipPath id="b">
<path d="M170 52h84.4v201.2H170zm0 0" />
</clipPath>
</defs>
<path
d="M59.3 128.4c1.4 2.3 2.5 4.6 3.4 7-1-4.1-2.3-8.1-4.3-12-3.1-6-7.8-5.8-10.7 0-2 4-3.2 8-4.3 12.1 1-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M207.8 128.4a42.9 42.9 0 013.4 7c-1-4.1-2.3-8.1-4.3-12-3.2-6-7.8-5.8-10.7 0-2 4-3.3 8-4.3 12.1.9-2.4 2-4.8 3.4-7.1 3.4-5.8 8.8-6 12.5 0M134.6 92.9c2 3.5 3.6 7 4.8 10.7-1.3-5.4-3-10.6-5.6-15.7-4-7.5-9.7-7.2-13.3 0a75.4 75.4 0 00-5.6 16c1.2-3.8 2.7-7.4 4.7-11 4.1-7.2 10.6-7.5 15 0M43.8 136.8c.9 4.6 3.7 8.3 7.3 9.2 0 2.7 0 5.5.2 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4M192.4 136.8c.8 4.6 3.7 8.3 7.2 9.2 0 2.7 0 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3.9 2.2 1.2 0 .6-3 .7-6.3 1-9.6.2-2.7.3-5.5.2-8.2 3.6-1 6.4-4.6 7.3-9.2a17.8 17.8 0 01-9.1 2.4c-3.4 0-6.6-1-9.1-2.4M138.3 104.6c-3.1 1.9-7 3-11.3 3-4.3 0-8.2-1.1-11.3-3 1 5.8 4.5 10.3 9 11.5 0 3.4 0 6.8.3 10.2.4 4.1.5 8.2 1.2 12 .4 2.9 1.2 2.7 1.6 0 .7-3.8.8-7.9 1.2-12 .3-3.4.3-6.8.3-10.2 4.5-1.2 8-5.7 9-11.5" />
<path
d="M51 146c0 2.7.1 5.5.3 8.2.3 3.3.4 6.6 1 9.6.3 2.3 1 2.2 1.3 0 .5-3 .6-6.3 1-9.6l.2-8.2c3.5-1 6.4-4.6 7.2-9.2a17.8 17.8 0 01-9 2.4c-3.5 0-6.6-1-9.2-2.4.9 4.6 3.7 8.3 7.3 9.2M143.9 105c-1.9-.4-3.5-1.2-4.9-2.3 1.4 5.6 2.5 11.3 4 17 1.2 5 2 10 2.4 15 .6 7.8-4.5 14.5-10.9 14.5h-15c-6.4 0-11.5-6.7-11-14.5.5-5 1.3-10 2.6-15 1.3-5.3 2.3-10.5 3.6-15.7-2.2 1.2-4.8 1.9-7.7 2-4.7.1-9.4-.3-14-1-4-.4-6.7-3-8-6.7-1.3-3.4-2-7-3.3-10.4-.5-1.5-1.6-2.8-2.4-4.2-.4-.6-.8-1.2-.9-1.8v-7.8a77 77 0 0124.5-3c6.1 0 12 1 17.8 3.2 4.7 1.7 9.7 1.8 14.4 0 9-3.4 18.2-3.8 27.5-3 4.9.5 9.8 1.6 14.8 2.4v8.2c0 .6-.3 1.5-.7 1.7-2 .9-2.2 2.7-2.7 4.5-.9 3.2-1.8 6.4-2.9 9.5a11 11 0 01-8.8 7.7 40.6 40.6 0 01-18.4-.2m29.4 80.6c-3.2-26.8-6.4-50-8.9-60.7a14.3 14.3 0 0014.1-14h.4a9 9 0 005.6-16.5 14.3 14.3 0 00-3.7-27.2 9 9 0 00-6.9-14.6c2.4-1.1 4.5-3 5.8-5 3.4-5.3 4-29-8-44.4-5-6.3-9.8-2.5-10 1.8-1 13.2-1.1 23-4.5 34.3a9 9 0 00-16-4.1 14.3 14.3 0 00-28.4 0 9 9 0 00-16 4.1c-3.4-11.2-3.5-21.1-4.4-34.3-.3-4.3-5.2-8-10-1.8-12 15.3-11.5 39-8.1 44.4 1.3 2 3.4 3.9 5.8 5a9 9 0 00-7 14.6 14.3 14.3 0 00-3.6 27.2A9 9 0 0075 111h.5a14.5 14.5 0 0014.3 14c-4 17.2-10 66.3-15 111.3l-1.3 13.4a1656.4 1656.4 0 01106.6 0l-1.4-12.7-5.4-51.3" />
<g clip-path="url(#a)">
<path
d="M83.5 136.6l-2.3.7c-5 1-9.8 1-14.8-.2-1.4-.3-2.7-1-3.8-1.9l3.1 13.7c1 4 1.7 8 2 12 .5 6.3-3.6 11.6-8.7 11.6H46.9c-5.1 0-9.2-5.3-8.7-11.6.3-4 1-8 2-12 1-4.2 1.8-8.5 2.9-12.6-1.8 1-3.9 1.5-6.3 1.6a71 71 0 01-11.1-.7 7.7 7.7 0 01-6.5-5.5c-1-2.7-1.6-5.6-2.6-8.3-.4-1.2-1.3-2.3-2-3.4-.2-.4-.6-1-.6-1.4v-6.3c6.4-2 13-2.6 19.6-2.5 4.9.1 9.6 1 14.2 2.6 3.9 1.4 7.9 1.5 11.7 0 1.8-.7 3.6-1.2 5.5-1.6a13 13 0 01-1.6-15.5A18.3 18.3 0 0159 73.1a11.5 11.5 0 00-17.4 8.1 7.2 7.2 0 00-12.9 3.3c-2.7-9-2.8-17-3.6-27.5-.2-3.4-4-6.5-8-1.4C7.5 67.8 7.9 86.9 10.6 91c1.1 1.7 2.8 3.1 4.7 4a7.2 7.2 0 00-5.6 11.7 11.5 11.5 0 00-2.9 21.9 7.2 7.2 0 004.5 13.2h.3c0 .6 0 1.1.2 1.7.9 5.4 5.6 9.5 11.3 9.5A1177.2 1177.2 0 0010 253.2c18.1-1.5 38.1-2.6 59.5-3.4.4-4.6.8-9.3 1.4-14 1.2-11.6 3.3-30.5 5.7-49.7 2.2-18 4.7-36.3 7-49.5" />
</g>
<g clip-path="url(#b)">
<path
d="M254.4 118.2c0-5.8-4.2-10.5-9.7-11.4a7.2 7.2 0 00-5.6-11.7c2-.9 3.6-2.3 4.7-4 2.7-4.2 3.1-23.3-6.5-35.5-4-5.1-7.8-2-8 1.4-.8 10.5-.9 18.5-3.6 27.5a7.2 7.2 0 00-12.8-3.3 11.5 11.5 0 00-17.8-7.9 18.4 18.4 0 01-4.5 22 13 13 0 01-1.3 15.2c2.4.5 4.8 1 7.1 2 3.8 1.3 7.8 1.4 11.6 0 7.2-2.8 14.6-3 22-2.4 4 .4 7.9 1.2 12 1.9l-.1 6.6c0 .5-.2 1.2-.5 1.3-1.7.7-1.8 2.2-2.2 3.7l-2.3 7.6a8.8 8.8 0 01-7 6.1c-5 1-10 1-14.9-.2-1.5-.3-2.8-1-3.9-1.9 1.2 4.5 2 9.1 3.2 13.7 1 4 1.6 8 2 12 .4 6.3-3.6 11.6-8.8 11.6h-12c-5.2 0-9.3-5.3-8.8-11.6.4-4 1-8 2-12 1-4.2 1.9-8.5 3-12.6-1.8 1-4 1.5-6.3 1.6-3.7 0-7.5-.3-11.2-.7a7.7 7.7 0 01-3.7-1.5c3.1 18.4 7.1 51.2 12.5 100.9l.6 5.3.8 7.9c21.4.7 41.5 1.9 59.7 3.4L243 243l-4.4-41.2a606 606 0 00-7-48.7 11.5 11.5 0 0011.2-11.2h.4a7.2 7.2 0 004.4-13.2c4-1.8 6.8-5.8 6.8-10.5" />
</g>
<path
d="M180 249.6h.4a6946 6946 0 00-7.1-63.9l5.4 51.3 1.4 12.6M164.4 125c2.5 10.7 5.7 33.9 8.9 60.7a570.9 570.9 0 00-8.9-60.7M74.8 236.3l-1.4 13.4 1.4-13.4" />
</svg>
<span>CrowdSec</span>
</a>
</div>
</div>
</div>
<script>
function captchaCallback() {
setTimeout(() => document.querySelector('#captcha-form').submit(), 500);
}
</script>
</body>
</html>
@@ -0,0 +1,80 @@
services:
traefik:
image: "traefik:v2.11.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.2.0"
- "--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
whoami1:
image: traefik/whoami
container_name: "simple-service-foo"
restart: unless-stopped
labels:
- "traefik.enable=true"
# 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"
# 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=hcaptcha"
# Define captcha site key
- "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
- "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.0
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"
volumes:
logs-captcha-enabled:
crowdsec-db-captcha-enabled:
crowdsec-config-captcha-enabled:
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+17
View File
@@ -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
@@ -0,0 +1,17 @@
name: captcha_remediation
filters:
- Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http"
## Any scenario with http in its name will trigger a captcha challenge
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
@@ -39,7 +39,7 @@ services:
# # Definition of the router # # Definition of the router
# - "traefik.http.routers.router-foo.rule=Path(`/redis-insecure`)" # - "traefik.http.routers.router-foo.rule=Path(`/redis-insecure`)"
# - "traefik.http.routers.router-foo.entrypoints=web" # - "traefik.http.routers.router-foo.entrypoints=web"
# - "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" # - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# # Definition of the service # # Definition of the service
# - "traefik.http.services.service-foo.loadbalancer.server.port=80" # - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# # Definition of the middleware # # Definition of the middleware
@@ -59,7 +59,7 @@ services:
# Definition of the router # Definition of the router
- "traefik.http.routers.router-bar.rule=Path(`/redis-secure`)" - "traefik.http.routers.router-bar.rule=Path(`/redis-secure`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec-bar@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
# Definition of the service # Definition of the service
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
# Definition of the middleware # Definition of the middleware
@@ -30,16 +30,16 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.router-foo.rule=Path(`/foo`)" - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web" - "traefik.http.routers.router-foo.entrypoints=web"
- "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
- "traefik.http.services.service-foo.loadbalancer.server.port=80" - "traefik.http.services.service-foo.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# - "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.loglevel=DEBUG" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.crowdsecmode=alone" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=alone"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.CrowdsecCapiMachineId=logincacacalfkrjebfreifgzfblezgyfoerxsqxsqxsqxsr" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiMachineId=logincacacalfkrjebfreifgzfblezgyfoerxsqxsqxsqxsr"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.CrowdsecCapiPassword=Password2" - "traefik.http.middlewares.crowdsec.plugin.bouncer.CrowdsecCapiPassword=Password2"
- "traefik.http.middlewares.crowdsec-foo.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios" - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseccapiscenarios=crowdsecurity/sshd,crowdsecurity/asterisk_bf,crowdsecurity/asterisk_user_enum,crowdsecurity/base-http-scenarios"
volumes: volumes:
logs-local: logs-local:
@@ -39,7 +39,7 @@ services:
# - "traefik.enable=true" # - "traefik.enable=true"
# - "traefik.http.routers.router-foo.rule=Path(`/foo`)" # - "traefik.http.routers.router-foo.rule=Path(`/foo`)"
# - "traefik.http.routers.router-foo.entrypoints=web" # - "traefik.http.routers.router-foo.entrypoints=web"
# - "traefik.http.routers.router-foo.middlewares=crowdsec-foo@docker" # - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# - "traefik.http.services.service-foo.loadbalancer.server.port=80" # - "traefik.http.services.service-foo.loadbalancer.server.port=80"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
# - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" # - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
@@ -56,7 +56,7 @@ services:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.router-bar.rule=Path(`/bar`)" - "traefik.http.routers.router-bar.rule=Path(`/bar`)"
- "traefik.http.routers.router-bar.entrypoints=web" - "traefik.http.routers.router-bar.entrypoints=web"
- "traefik.http.routers.router-bar.middlewares=crowdsec-bar@docker" - "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
- "traefik.http.services.service-bar.loadbalancer.server.port=80" - "traefik.http.services.service-bar.loadbalancer.server.port=80"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true" - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG" - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
+48 -49
View File
@@ -12,13 +12,18 @@ import (
) )
const ( const (
cacheBannedValue = "t" // BannedValue Banned string.
cacheNoBannedValue = "f" 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 //nolint:gochecknoglobals
var ( var (
redis simpleredis.SimpleRedis redis simpleredis.SimpleRedis
@@ -27,55 +32,55 @@ var (
type localCache struct{} type localCache struct{}
func (localCache) getDecision(clientIP string) (bool, error) { func (localCache) get(key string) (string, error) {
banned, isCached := cache.Get(clientIP) value, isCached := cache.Get(key)
bannedString, isValid := banned.(string) valueString, isValid := value.(string)
if isCached && isValid && len(bannedString) > 0 { if isCached && isValid && len(valueString) > 0 {
return bannedString == cacheBannedValue, nil return valueString, nil
} }
return false, fmt.Errorf(CacheMiss) return "", fmt.Errorf(CacheMiss)
} }
func (localCache) setDecision(clientIP string, value string, duration int64) { func (localCache) set(key, value string, duration int64) {
cache.Set(clientIP, value, duration) cache.Set(key, value, duration)
} }
func (localCache) deleteDecision(clientIP string) { func (localCache) delete(key string) {
cache.Del(clientIP) cache.Del(key)
} }
type redisCache struct { type redisCache struct {
log *logger.Log log *logger.Log
} }
func (redisCache) getDecision(clientIP string) (bool, error) { func (redisCache) get(key string) (string, error) {
banned, err := redis.Get(clientIP) value, err := redis.Get(key)
bannedString := string(banned) valueString := string(value)
if err == nil && len(bannedString) > 0 { if err == nil && len(valueString) > 0 {
return bannedString == cacheBannedValue, nil return valueString, nil
} }
if err.Error() == simpleredis.RedisMiss { 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) { func (rc redisCache) set(key, value string, duration int64) {
if err := redis.Set(clientIP, []byte(value), duration); err != nil { if err := redis.Set(key, []byte(value), duration); err != nil {
rc.log.Error(fmt.Sprintf("cache:setDecisionRedisCache %s", err.Error())) rc.log.Error(fmt.Sprintf("cache:setDecisionRedisCache %s", err.Error()))
} }
} }
func (rc redisCache) deleteDecision(clientIP string) { func (rc redisCache) delete(key string) {
if err := redis.Del(clientIP); err != nil { if err := redis.Del(key); err != nil {
rc.log.Error(fmt.Sprintf("cache:deleteDecisionRedisCache %s", err.Error())) rc.log.Error(fmt.Sprintf("cache:deleteDecisionRedisCache %s", err.Error()))
} }
} }
type cacheInterface interface { type cacheInterface interface {
setDecision(clientIP string, value string, duration int64) set(key, value string, duration int64)
getDecision(clientIP string) (bool, error) get(key string) (string, error)
deleteDecision(clientIP string) delete(key string)
} }
// Client Cache client. // Client Cache client.
@@ -84,8 +89,8 @@ type Client struct {
log *logger.Log log *logger.Log
} }
// Init Initialize cache client. // New Initialize cache client.
func (c *Client) Init(log *logger.Log, isRedis bool, host, pass, database string) { func (c *Client) New(log *logger.Log, isRedis bool, host, pass, database string) {
c.log = log c.log = log
if isRedis { if isRedis {
redis.Init(host, pass, database) 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)) c.log.Debug(fmt.Sprintf("cache:New initialized isRedis:%v", isRedis))
} }
// DeleteDecision delete decision in cache. // Delete delete decision in cache.
func (c *Client) DeleteDecision(clientIP string) { func (c *Client) Delete(key string) {
c.log.Debug(fmt.Sprintf("cache:DeleteDecision ip:%v", clientIP)) c.log.Debug(fmt.Sprintf("cache:Delete key:%v", key))
c.cache.deleteDecision(clientIP) 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. // Otherwise return with an error to add the IP in cache if we are on.
func (c *Client) GetDecision(clientIP string) (bool, error) { func (c *Client) Get(key string) (string, error) {
c.log.Debug(fmt.Sprintf("cache:GetDecision ip:%v", clientIP)) c.log.Debug(fmt.Sprintf("cache:Get key:%v", key))
return c.cache.getDecision(clientIP) return c.cache.get(key)
} }
// SetDecision update the cache with the IP as key and the value banned / not banned. // Set update the cache with the IP as key and the value banned / not banned.
func (c *Client) SetDecision(clientIP string, isBanned bool, duration int64) { func (c *Client) Set(key string, value string, duration int64) {
c.log.Debug(fmt.Sprintf("cache:SetDecision ip:%v isBanned:%v duration:%vs", clientIP, isBanned, duration)) c.log.Debug(fmt.Sprintf("cache:Set key:%v value:%v duration:%vs", key, value, duration))
var value string c.cache.set(key, value, duration)
if isBanned {
value = cacheBannedValue
} else {
value = cacheNoBannedValue
}
c.cache.setDecision(clientIP, value, duration)
} }
+54 -34
View File
@@ -8,97 +8,117 @@ import (
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger" 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" IPInCache := "10.0.0.10"
IPNotInCache := "10.0.0.20" IPNotInCache := "10.0.0.20"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO")}
client.SetDecision(IPInCache, true, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
} }
tests := []struct { tests := []struct {
name string name string
args args args args
want bool want string
wantErr bool wantErr bool
valueErr string valueErr string
}{ }{
{name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: true, wantErr: false, valueErr: ""}, {name: "Fetch Known valid IP", args: args{clientIP: IPInCache}, want: BannedValue, wantErr: false, valueErr: ""},
{name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: false, wantErr: true, valueErr: "cache:miss"}, {name: "Fetch Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Fetch invalid value", args: args{clientIP: "test"}, want: false, wantErr: true, valueErr: "cache:miss"}, {name: "Fetch invalid value", args: args{clientIP: "test"}, want: "", wantErr: true, valueErr: CacheMiss},
{name: "Fetch empty value", args: args{clientIP: ""}, want: false, wantErr: true, valueErr: "cache:miss"}, {name: "Fetch empty value", args: args{clientIP: ""}, want: "", wantErr: true, valueErr: CacheMiss},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { 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 return
} }
if got != tt.want { if got != tt.want {
t.Errorf("GetDecision() = %v, want %v", got, tt.want) t.Errorf("Get() = %v, want %v", got, tt.want)
return return
} }
if tt.valueErr != "" && tt.valueErr != err.Error() { 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")} client := &Client{cache: &localCache{}, log: logger.New("INFO")}
IPInCache := "10.0.0.11" IPInCache := "10.0.0.11"
type args struct { type args struct {
clientIP string clientIP string
value bool value string
duration int64 duration int64
} }
tests := []struct { tests := []struct {
name string name string
args args args args
want bool 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 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: true, duration: 10}, want: true}, {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: false, duration: 10}, want: false}, {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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
client.SetDecision(tt.args.clientIP, tt.args.value, tt.args.duration) client.Set(tt.args.clientIP, tt.args.value, tt.args.duration)
got, _ := client.GetDecision(tt.args.clientIP) got, err := client.Get(tt.args.clientIP)
if got != tt.want { if (err != nil) != tt.wantErr {
t.Errorf("SetDecision() = %v, want %v", got, tt.want) t.Errorf("Set() error = %v, wantErr %v", err, tt.wantErr)
return 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" IPInCache := "10.0.0.12"
IPNotInCache := "10.0.0.22" IPNotInCache := "10.0.0.22"
client := &Client{cache: &localCache{}, log: logger.New("INFO")} client := &Client{cache: &localCache{}, log: logger.New("INFO")}
client.SetDecision(IPInCache, true, 10) client.Set(IPInCache, BannedValue, 10)
type args struct { type args struct {
clientIP string clientIP string
} }
tests := []struct { tests := []struct {
name string name string
args args args args
want bool want string
wantErr bool
valueErr string
}{ }{
{name: "Delete Known valid IP", args: args{clientIP: IPInCache}, 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: false}, {name: "Delete Unknown valid IP", args: args{clientIP: IPNotInCache}, want: "", wantErr: true, valueErr: CacheMiss},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
client.DeleteDecision(tt.args.clientIP) client.Delete(tt.args.clientIP)
got, _ := client.GetDecision(tt.args.clientIP) got, err := client.Get(tt.args.clientIP)
if got != tt.want { if (err != nil) != tt.wantErr {
t.Errorf("DeleteDecision() = %v, want %v", got, tt.want) t.Errorf("Delete() error = %v, wantErr %v", err, tt.wantErr)
return 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. // Enums for crowdsec mode.
const ( const (
AloneMode = "alone" AloneMode = "alone"
StreamMode = "stream" StreamMode = "stream"
LiveMode = "live" LiveMode = "live"
NoneMode = "none" NoneMode = "none"
AppsecMode = "appsec" AppsecMode = "appsec"
HTTPS = "https" HTTPS = "https"
HTTP = "http" HTTP = "http"
HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile"
) )
// Config the plugin configuration. // Config the plugin configuration.
@@ -63,6 +66,13 @@ type Config struct {
RedisCachePassword string `json:"redisCachePassword,omitempty"` RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"` RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,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 { func contains(source []string, target string) bool {
@@ -90,6 +100,11 @@ func New() *Config {
UpdateIntervalSeconds: 60, UpdateIntervalSeconds: 60,
DefaultDecisionSeconds: 60, DefaultDecisionSeconds: 60,
HTTPTimeoutSeconds: 10, HTTPTimeoutSeconds: 10,
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
CaptchaHTMLFilePath: "/captcha.html",
CaptchaGracePeriodSeconds: 1800,
ForwardedHeadersCustomName: "X-Forwarded-For", ForwardedHeadersCustomName: "X-Forwarded-For",
ForwardedHeadersTrustedIPs: []string{}, ForwardedHeadersTrustedIPs: []string{},
ClientTrustedIPs: []string{}, ClientTrustedIPs: []string{},
@@ -156,6 +171,15 @@ func ValidateParams(config *Config) error {
return nil 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 { if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {
return err return err
} }
@@ -254,9 +278,10 @@ func validateParamsRequired(config *Config) error {
} }
} }
requiredInt := map[string]int64{ requiredInt := map[string]int64{
"UpdateIntervalSeconds": config.UpdateIntervalSeconds, "UpdateIntervalSeconds": config.UpdateIntervalSeconds,
"DefaultDecisionSeconds": config.DefaultDecisionSeconds, "DefaultDecisionSeconds": config.DefaultDecisionSeconds,
"HTTPTimeoutSeconds": config.HTTPTimeoutSeconds, "HTTPTimeoutSeconds": config.HTTPTimeoutSeconds,
"CaptchaGracePeriodSeconds": config.CaptchaGracePeriodSeconds,
} }
for key, val := range requiredInt { for key, val := range requiredInt {
if val < 1 { if val < 1 {
@@ -269,6 +294,9 @@ func validateParamsRequired(config *Config) error {
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) { if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
return fmt.Errorf("CrowdsecLapiScheme: must be one of 'http' or 'https'") 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 return nil
} }