mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-09-02 20:28:50 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2bbc4dac5 | ||
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 | ||
|
|
7c4f5163e9 | ||
|
|
734975c206 | ||
|
|
7397834e58 |
@@ -1,5 +1,8 @@
|
||||
name: Main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -24,20 +27,20 @@ jobs:
|
||||
|
||||
# https://github.com/marketplace/actions/setup-go-environment
|
||||
- name: Set up Go ${{ env.GO_VERSION }}
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
# https://github.com/marketplace/actions/checkout
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: go/src/github.com/${{ github.repository }}
|
||||
fetch-depth: 0
|
||||
|
||||
# https://github.com/marketplace/actions/cache
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Release Version Update
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-version:
|
||||
name: Update version in source
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
run: |
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update version in version.go
|
||||
run: |
|
||||
sed -i 's/pluginVersion = "[^"]*"/pluginVersion = "'"${{ steps.get_version.outputs.version }}"'"/' version.go
|
||||
cat version.go
|
||||
|
||||
- name: Commit, push, and retag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add version.go
|
||||
if git diff --cached --quiet; then
|
||||
echo "Version already up to date, nothing to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "⬆️ chore: bump version to ${{ steps.get_version.outputs.version }}"
|
||||
git push origin main
|
||||
# Move the release tag to include the version update
|
||||
git tag -f "${{ steps.get_version.outputs.tag }}"
|
||||
git push -f origin "${{ steps.get_version.outputs.tag }}"
|
||||
@@ -41,13 +41,16 @@ run_tlsauth:
|
||||
docker compose -f examples/tls-auth/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
run_appsec:
|
||||
docker compose -f examples/appsec-enabled/docker-compose.yml up -d
|
||||
docker compose -f examples/appsec-enabled/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
run_custom_captcha:
|
||||
docker compose -f examples/custom-captcha/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
run_captcha:
|
||||
docker compose -f examples/captcha/docker-compose.yml up -d
|
||||
docker compose -f examples/captcha/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
run_custom_ban_page:
|
||||
docker compose -f examples/custom-ban-page/docker-compose.yml up -d
|
||||
docker compose -f examples/custom-ban-page/docker-compose.yml up -d --remove-orphans
|
||||
|
||||
run:
|
||||
docker compose -f docker-compose.yml up -d --remove-orphans
|
||||
@@ -98,6 +101,7 @@ clean_all_docker:
|
||||
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/appsec-enabled/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/captcha/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/custom-captcha/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/custom-ban-page/docker-compose.yml down --remove-orphans
|
||||
docker compose -f docker-compose.local.yml down --remove-orphans
|
||||
docker compose -f docker-compose.yml down --remove-orphans
|
||||
|
||||
@@ -39,6 +39,7 @@ The following captcha providers are supported now:
|
||||
- [hcaptcha](https://www.hcaptcha.com/)
|
||||
- [recaptcha](https://www.google.com/recaptcha/about/)
|
||||
- [turnstile](https://www.cloudflare.com/products/turnstile/)
|
||||
- [custom/wicketkeeper](https://github.com/a-ve/wicketkeeper)
|
||||
|
||||
There are 5 operating modes (CrowdsecMode) for this plugin:
|
||||
|
||||
@@ -309,17 +310,16 @@ make run
|
||||
### Note
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Some of the behaviours and configuration parameters are shared globally across *all* crowdsec middlewares even if you declare different middlewares with different settings.
|
||||
> Some of the behaviours and configuration parameters are shared globally across _all_ crowdsec middlewares even if you declare different middlewares with different settings.
|
||||
>
|
||||
> **Cache is shared by all services**: This means if an IP is banned, all services which are protected by an instance of the plugin will deny requests from that IP
|
||||
>
|
||||
> If you define different caches for different middlewares, only the first one to be instantiated will be bound to the crowdsec stream.
|
||||
>
|
||||
> Overall, this middleware is designed in such a way that **only one instance of the plugin is *possible*.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||
> Overall, this middleware is designed in such a way that **only one instance of the plugin is _possible_.** You can have multiple crowdsec middlewares in the same cluster, the key parameters must be aligned (MetricsUpdateIntervalSeconds, CrowdsecMode, CrowdsecAppsecEnabled, etc.)
|
||||
|
||||
> [!WARNING]
|
||||
> **Appsec maximum body limit is defaulted to 10MB**
|
||||
> *Be careful when you upgrade to >1.4.x*
|
||||
> **Appsec maximum body limit is defaulted to 10MB** > _Be careful when you upgrade to >1.4.x_
|
||||
|
||||
### Variables
|
||||
|
||||
@@ -350,7 +350,18 @@ make run
|
||||
- CrowdsecAppsecHost
|
||||
- string
|
||||
- default: "crowdsec:7422"
|
||||
- Crowdsec Appsec Server available on which host and port. The scheme will be handled by the CrowdsecLapiScheme var.
|
||||
- Crowdsec Appsec Server available on which host and port.
|
||||
- CrowdsecAppsecTlsInsecureVerify
|
||||
- bool
|
||||
- default: false
|
||||
- Disable verification of certificate presented by Appsec
|
||||
- CrowdsecAppsecTlsCertificateAuthority
|
||||
- string
|
||||
- default: ""
|
||||
- PEM-encoded Certificate Authority of Appsec
|
||||
- CrowdsecAppsecScheme
|
||||
- string
|
||||
- default: value of `CrowdsecLapiScheme`, expected values are: `http`, `https`
|
||||
- CrowdsecAppsecPath
|
||||
- string
|
||||
- default: "/"
|
||||
@@ -367,6 +378,10 @@ make run
|
||||
- int64
|
||||
- default: 10485760 (= 10MB)
|
||||
- Transmit only the first number of bytes to Crowdsec Appsec Server.
|
||||
- CrowdsecAppsecKey
|
||||
- string
|
||||
- default: value of `CrowdsecLapiKey`
|
||||
- Crowdsec AppSec key for the bouncer.
|
||||
- CrowdsecLapiScheme
|
||||
- string
|
||||
- default: `http`, expected values are: `http`, `https`
|
||||
@@ -405,7 +420,7 @@ make run
|
||||
- RemediationHeadersCustomName
|
||||
- string
|
||||
- default: ""
|
||||
- Name of the header you want in response when request are cancelled (possible value of the header `ban` or `captcha`)
|
||||
- Name of the header you want in response when request are handled by plugin (possible value of the header `ban`, `captcha` or `solved-captcha`)
|
||||
- ForwardedHeadersCustomName
|
||||
- string
|
||||
- default: "X-Forwarded-For"
|
||||
@@ -465,7 +480,19 @@ make run
|
||||
- Used only in `alone` mode, scenarios for Crowdsec CAPI
|
||||
- CaptchaProvider
|
||||
- string
|
||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile`
|
||||
- Provider to validate the captcha, expected values are: `hcaptcha`, `recaptcha`, `turnstile` or `custom`
|
||||
- CaptchaCustomJsURL
|
||||
- string
|
||||
- If CaptchaProvider is `custom`, URL used to load the challenge in the HTML (in case of hcaptcha: `https://hcaptcha.com/1/api.js`)
|
||||
- CaptchaCustomValidateURL
|
||||
- string
|
||||
- If CaptchaProvider is `custom`, URL used to validate the challenge (in case of hcaptcha: `https://api.hcaptcha.com/siteverify`)
|
||||
- CaptchaCustomKey
|
||||
- string
|
||||
- If CaptchaProvider is `custom`, used to set class name of the div used by captcha provider (in case of hcaptcha: `h-captcha`)
|
||||
- CaptchaCustomResponse
|
||||
- string
|
||||
- If CaptchaProvider is `custom`, used to set the field in the POST body from the captcha.html to Traefik (in case of hcaptcha: `h-captcha-response`)
|
||||
- CaptchaSiteKey
|
||||
- string
|
||||
- Site key for the captcha provider
|
||||
@@ -484,6 +511,10 @@ make run
|
||||
- string
|
||||
- default: ""
|
||||
- Path where the ban html file is stored (default empty ""=disabled)
|
||||
- TraceHeadersCustomName
|
||||
- string
|
||||
- default: ""
|
||||
- Request Header name whose value to inject in ban HTML response (default empty ""=disabled)
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -537,6 +568,7 @@ http:
|
||||
httpTimeoutSeconds: 10
|
||||
crowdsecMode: live
|
||||
crowdsecAppsecEnabled: false
|
||||
crowdsecAppsecScheme: ""
|
||||
crowdsecAppsecHost: crowdsec:7422
|
||||
crowdsecAppsecPath: "/"
|
||||
crowdsecAppsecFailureBlock: true
|
||||
@@ -590,12 +622,13 @@ http:
|
||||
captchaGracePeriodSeconds: 1800
|
||||
captchaHTMLFilePath: /captcha.html
|
||||
banHTMLFilePath: /ban.html
|
||||
traceHeadersCustomName: X-Request-ID
|
||||
metricsUpdateIntervalSeconds: 600
|
||||
```
|
||||
|
||||
#### Fill variable with value of file
|
||||
|
||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||
`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecAppsecTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CrowdsecAppsecKey`, `CaptchaSiteKey`, `CaptchaSecretKey` and `RedisCachePassword` can be provided with the content as raw or through a file path that Traefik can read.
|
||||
The file variable will be used as preference if both content and file are provided for the same variable.
|
||||
|
||||
Format is:
|
||||
@@ -658,6 +691,13 @@ Set the `crowdsecLapiScheme` to https.
|
||||
Crowdsec must be listening in HTTPS for this to work.
|
||||
Please see the [tls-auth example](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/tls-auth/README.md) or the official documentation: [docs.crowdsec.net/docs/local_api/tls_auth/](https://docs.crowdsec.net/docs/local_api/tls_auth/)
|
||||
|
||||
#### Use HTTPS to communicate with the Appsec
|
||||
|
||||
To communicate with the Appsec in HTTPS you need to either accept any certificates by setting the `crowdsecAppsecTLSInsecureVerify` to true or add the CA used by the server certificate of Crowdsec using `crowdsecAppsecTLSCertificateAuthority` or `crowdsecAppsecTLSCertificateAuthorityFile`.
|
||||
Set the `crowdsecAppsecScheme` to https.
|
||||
|
||||
Currently AppSec does not support mTLS authentication for the AppSec Component.
|
||||
|
||||
#### Manually add an IP to the blocklist (for testing purposes)
|
||||
|
||||
```bash
|
||||
@@ -690,6 +730,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
|
||||
|
||||
#### 10. Using Traefik with Custom Ban HTML Page [examples/custom-ban-page/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-ban-page/README.md)
|
||||
|
||||
#### 11. Using Traefik with Custom Captcha Whiketkeeper[examples/custom-captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/custom-captcha/README.md)
|
||||
|
||||
### Local Mode
|
||||
|
||||
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
|
||||
|
||||
+99
-45
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
htmltemplate "html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -84,8 +85,10 @@ type Bouncer struct {
|
||||
|
||||
enabled bool
|
||||
appsecEnabled bool
|
||||
appsecScheme string
|
||||
appsecHost string
|
||||
appsecPath string
|
||||
appsecKey string
|
||||
appsecFailureBlock bool
|
||||
appsecUnreachableBlock bool
|
||||
appsecBodyLimit int64
|
||||
@@ -106,10 +109,12 @@ type Bouncer struct {
|
||||
crowdsecStreamRoute string
|
||||
crowdsecHeader string
|
||||
redisUnreachableBlock bool
|
||||
banTemplateString string
|
||||
banTemplate *htmltemplate.Template
|
||||
traceCustomHeader string
|
||||
clientPoolStrategy *ip.PoolStrategy
|
||||
serverPoolStrategy *ip.PoolStrategy
|
||||
httpClient *http.Client
|
||||
httpAppsecClient *http.Client
|
||||
cacheClient *cache.Client
|
||||
captchaClient *captcha.Client
|
||||
log *logger.Log
|
||||
@@ -130,6 +135,23 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
serverChecker, _ := ip.NewChecker(log, config.ForwardedHeadersTrustedIPs)
|
||||
clientChecker, _ := ip.NewChecker(log, config.ClientTrustedIPs)
|
||||
|
||||
var tlsAppsecConfig *tls.Config
|
||||
if config.CrowdsecAppsecEnabled {
|
||||
tlsAppsecConfig, err = configuration.GetTLSConfigCrowdsec(config, log, true)
|
||||
if config.CrowdsecAppsecScheme == "" {
|
||||
config.CrowdsecAppsecScheme = config.CrowdsecLapiScheme
|
||||
}
|
||||
if err != nil {
|
||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsAppsecConfig " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
apiAppsecKey, errAppsecKey := configuration.GetVariable(config, "CrowdsecAppsecKey")
|
||||
if errAppsecKey != nil && len(tlsAppsecConfig.Certificates) == 0 {
|
||||
log.Info("New:crowdsecLapiKey fail to get CrowdsecAppsecKey and no client certificate setup " + errAppsecKey.Error())
|
||||
}
|
||||
config.CrowdsecAppsecKey = apiAppsecKey
|
||||
}
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
crowdsecStreamRoute := ""
|
||||
crowdsecHeader := ""
|
||||
@@ -139,36 +161,31 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
config.CrowdsecLapiScheme = configuration.HTTPS
|
||||
config.CrowdsecLapiHost = crowdsecCapiHost
|
||||
config.CrowdsecLapiPath = "/"
|
||||
config.CrowdsecAppsecEnabled = false
|
||||
config.UpdateIntervalSeconds = 7200 // 2 hours
|
||||
crowdsecStreamRoute = crowdsecCapiStreamRoute
|
||||
crowdsecHeader = crowdsecCapiHeader
|
||||
} else {
|
||||
crowdsecStreamRoute = crowdsecLapiStreamRoute
|
||||
crowdsecHeader = crowdsecLapiHeader
|
||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log)
|
||||
tlsConfig, err = configuration.GetTLSConfigCrowdsec(config, log, false)
|
||||
if err != nil {
|
||||
log.Error("New:getTLSConfigCrowdsec fail to get tlsConfig " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
apiKey, errAPIKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||
if errAPIKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errAPIKey.Error())
|
||||
return nil, errAPIKey
|
||||
apiKey, errKey := configuration.GetVariable(config, "CrowdsecLapiKey")
|
||||
if errKey != nil && len(tlsConfig.Certificates) == 0 {
|
||||
log.Error("New:crowdsecLapiKey fail to get CrowdsecLapiKey and no client certificate setup " + errKey.Error())
|
||||
return nil, errKey
|
||||
}
|
||||
config.CrowdsecLapiKey = apiKey
|
||||
if config.CrowdsecAppsecKey == "" {
|
||||
config.CrowdsecAppsecKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
var banTemplateString string
|
||||
var banTemplate *htmltemplate.Template
|
||||
if config.BanHTMLFilePath != "" {
|
||||
var buf bytes.Buffer
|
||||
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||
err = banTemplate.Execute(&buf, nil)
|
||||
if err != nil {
|
||||
log.Error("New:banTemplate is bad formatted " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
banTemplateString = buf.String()
|
||||
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
|
||||
}
|
||||
|
||||
bouncer := &Bouncer{
|
||||
@@ -179,8 +196,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
enabled: config.Enabled,
|
||||
crowdsecMode: config.CrowdsecMode,
|
||||
appsecEnabled: config.CrowdsecAppsecEnabled,
|
||||
appsecScheme: config.CrowdsecAppsecScheme,
|
||||
appsecHost: config.CrowdsecAppsecHost,
|
||||
appsecPath: config.CrowdsecAppsecPath,
|
||||
appsecKey: config.CrowdsecAppsecKey,
|
||||
appsecFailureBlock: config.CrowdsecAppsecFailureBlock,
|
||||
appsecUnreachableBlock: config.CrowdsecAppsecUnreachableBlock,
|
||||
appsecBodyLimit: config.CrowdsecAppsecBodyLimit,
|
||||
@@ -198,7 +217,8 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
defaultDecisionTimeout: config.DefaultDecisionSeconds,
|
||||
remediationStatusCode: config.RemediationStatusCode,
|
||||
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
|
||||
banTemplateString: banTemplateString,
|
||||
banTemplate: banTemplate,
|
||||
traceCustomHeader: config.TraceHeadersCustomName,
|
||||
crowdsecStreamRoute: crowdsecStreamRoute,
|
||||
crowdsecHeader: crowdsecHeader,
|
||||
log: log,
|
||||
@@ -216,6 +236,14 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
},
|
||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||
},
|
||||
httpAppsecClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSClientConfig: tlsAppsecConfig,
|
||||
},
|
||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||
},
|
||||
cacheClient: &cache.Client{},
|
||||
captchaClient: &captcha.Client{},
|
||||
}
|
||||
@@ -240,6 +268,10 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
Timeout: time.Duration(config.HTTPTimeoutSeconds) * time.Second,
|
||||
},
|
||||
config.CaptchaProvider,
|
||||
config.CaptchaCustomJsURL,
|
||||
config.CaptchaCustomKey,
|
||||
config.CaptchaCustomResponse,
|
||||
config.CaptchaCustomValidateURL,
|
||||
config.CaptchaSiteKey,
|
||||
config.CaptchaSecretKey,
|
||||
config.RemediationHeadersCustomName,
|
||||
@@ -247,6 +279,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
|
||||
config.CaptchaGracePeriodSeconds,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error("CaptchaClient not valid " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -291,13 +324,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
|
||||
if err != nil {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
|
||||
if err != nil {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
// if our IP is in the trusted list we bypass the next checks
|
||||
@@ -308,7 +341,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
|
||||
if bouncer.crowdsecMode == configuration.AppsecMode {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,20 +353,20 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
|
||||
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
if cacheErrString != cache.CacheMiss {
|
||||
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
|
||||
if value == cache.NoBannedValue {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
||||
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -342,18 +375,21 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
// Right here if we cannot join the stream we forbid the request to go on.
|
||||
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
|
||||
if isCrowdsecStreamHealthy {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
|
||||
}
|
||||
} else {
|
||||
value, err := handleNoStreamCache(bouncer, remoteIP)
|
||||
if err != nil {
|
||||
bouncer.log.Debug("handleNoStreamCache:crowdsecQuery " + err.Error())
|
||||
}
|
||||
if value == cache.NoBannedValue {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
} else {
|
||||
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
|
||||
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
|
||||
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,43 +423,61 @@ type Login struct {
|
||||
}
|
||||
|
||||
// To append Headers we need to call rw.WriteHeader after set any header.
|
||||
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
|
||||
func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
|
||||
atomic.AddInt64(&blockedRequests, 1)
|
||||
|
||||
if bouncer.remediationCustomHeader != "" {
|
||||
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
|
||||
}
|
||||
if bouncer.banTemplateString == "" {
|
||||
if bouncer.banTemplate == nil {
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
return
|
||||
}
|
||||
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
rw.WriteHeader(bouncer.remediationStatusCode)
|
||||
_, err := fmt.Fprint(rw, bouncer.banTemplateString)
|
||||
if err != nil {
|
||||
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
|
||||
|
||||
if req.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
templateData := map[string]string{
|
||||
"RemediationReason": reason,
|
||||
"ClientIP": remoteIP,
|
||||
}
|
||||
|
||||
if bouncer.traceCustomHeader != "" {
|
||||
headerVal := req.Header.Get(bouncer.traceCustomHeader)
|
||||
|
||||
if headerVal != "" {
|
||||
templateData["TraceID"] = headerVal
|
||||
}
|
||||
}
|
||||
|
||||
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
|
||||
err := bouncer.banTemplate.Execute(rw, templateData)
|
||||
|
||||
if err != nil {
|
||||
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
|
||||
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
|
||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
|
||||
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
|
||||
if bouncer.captchaClient.Check(remoteIP) {
|
||||
handleNextServeHTTP(bouncer, remoteIP, rw, req)
|
||||
bouncer.handleNextServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
|
||||
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
|
||||
return
|
||||
}
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
|
||||
}
|
||||
|
||||
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
|
||||
func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
|
||||
if bouncer.appsecEnabled {
|
||||
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
|
||||
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
|
||||
handleBanServeHTTP(bouncer, rw)
|
||||
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -519,7 +573,7 @@ func handleNoStreamCache(bouncer *Bouncer, remoteIP string) (string, error) {
|
||||
default:
|
||||
bouncer.log.Debug("handleStreamCache:unknownType " + decision.Type)
|
||||
}
|
||||
if isLiveMode {
|
||||
if isLiveMode && bouncer.defaultDecisionTimeout > 0 {
|
||||
durationSecond := int64(duration.Seconds())
|
||||
if bouncer.defaultDecisionTimeout < durationSecond {
|
||||
durationSecond = bouncer.defaultDecisionTimeout
|
||||
@@ -620,7 +674,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
req, _ = http.NewRequest(http.MethodGet, stringURL, nil)
|
||||
}
|
||||
req.Header.Add(bouncer.crowdsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/1.X.X")
|
||||
req.Header.Add("User-Agent", "Crowdsec-Bouncer-Traefik-Plugin/"+pluginVersion)
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
if err != nil {
|
||||
@@ -653,7 +707,7 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, data []byte) ([]byte, err
|
||||
|
||||
func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
routeURL := url.URL{
|
||||
Scheme: bouncer.crowdsecScheme,
|
||||
Scheme: bouncer.appsecScheme,
|
||||
Host: bouncer.appsecHost,
|
||||
Path: bouncer.appsecPath,
|
||||
}
|
||||
@@ -678,14 +732,14 @@ func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
|
||||
req.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
req.Header.Set(crowdsecAppsecHeader, bouncer.crowdsecKey)
|
||||
req.Header.Set(crowdsecAppsecHeader, bouncer.appsecKey)
|
||||
req.Header.Set(crowdsecAppsecIPHeader, ip)
|
||||
req.Header.Set(crowdsecAppsecVerbHeader, httpReq.Method)
|
||||
req.Header.Set(crowdsecAppsecHostHeader, httpReq.Host)
|
||||
req.Header.Set(crowdsecAppsecURIHeader, httpReq.URL.String())
|
||||
req.Header.Set(crowdsecAppsecUserAgent, httpReq.Header.Get("User-Agent"))
|
||||
|
||||
res, err := bouncer.httpClient.Do(req)
|
||||
res, err := bouncer.httpAppsecClient.Do(req)
|
||||
if err != nil {
|
||||
bouncer.log.Error("appsecQuery:unreachable")
|
||||
if bouncer.appsecUnreachableBlock {
|
||||
@@ -725,7 +779,7 @@ func reportMetrics(bouncer *Bouncer) error {
|
||||
metrics := map[string]interface{}{
|
||||
"remediation_components": []map[string]interface{}{
|
||||
{
|
||||
"version": "1.X.X",
|
||||
"version": pluginVersion,
|
||||
"type": "bouncer",
|
||||
"name": "traefik_plugin",
|
||||
"metrics": []map[string]interface{}{
|
||||
|
||||
+146
@@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
htmltemplate "html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
@@ -186,3 +187,148 @@ func Test_crowdsecQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
||||
html := "<html>You are banned</html>"
|
||||
banTemplate, _ := htmltemplate.New("html").Parse(html)
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
banTemplate *htmltemplate.Template
|
||||
expectBodyContent bool
|
||||
}{
|
||||
{
|
||||
name: "GET request should have body with template",
|
||||
method: http.MethodGet,
|
||||
banTemplate: banTemplate,
|
||||
expectBodyContent: true,
|
||||
},
|
||||
{
|
||||
name: "HEAD request should NOT have body even with template",
|
||||
method: http.MethodHead,
|
||||
banTemplate: banTemplate,
|
||||
expectBodyContent: false,
|
||||
},
|
||||
{
|
||||
name: "POST request should have body with template",
|
||||
method: http.MethodPost,
|
||||
banTemplate: banTemplate,
|
||||
expectBodyContent: true,
|
||||
},
|
||||
{
|
||||
name: "PUT request should have body with template",
|
||||
method: http.MethodPut,
|
||||
banTemplate: banTemplate,
|
||||
expectBodyContent: true,
|
||||
},
|
||||
{
|
||||
name: "DELETE request should have body with template",
|
||||
method: http.MethodDelete,
|
||||
banTemplate: banTemplate,
|
||||
expectBodyContent: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bouncer := &Bouncer{
|
||||
remediationStatusCode: http.StatusForbidden,
|
||||
remediationCustomHeader: "X-Test-Remediation",
|
||||
banTemplate: tt.banTemplate,
|
||||
}
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
req := &http.Request{Method: tt.method}
|
||||
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
|
||||
|
||||
// Check status code
|
||||
if rw.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status code 403, got %d", rw.Code)
|
||||
}
|
||||
|
||||
// Check custom header
|
||||
headerValue := rw.Header().Get("X-Test-Remediation")
|
||||
if headerValue != "ban" {
|
||||
t.Errorf("Expected header X-Test-Remediation to be 'ban', got %s", headerValue)
|
||||
}
|
||||
|
||||
// Check body content
|
||||
body := rw.Body.String()
|
||||
hasBodyContent := len(body) > 0
|
||||
|
||||
if hasBodyContent != tt.expectBodyContent {
|
||||
t.Errorf("Method %s: expected body content: %v, got body content: %v (body: %q)",
|
||||
tt.method, tt.expectBodyContent, hasBodyContent, body)
|
||||
}
|
||||
|
||||
// If we expect body content, verify it matches template
|
||||
if tt.expectBodyContent && body != html {
|
||||
t.Errorf("Expected body %q, got %q", html, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaMethodBasedLogic(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
remediation string
|
||||
expectBanFallback bool
|
||||
}{
|
||||
{
|
||||
name: "GET with captcha remediation should allow captcha",
|
||||
method: http.MethodGet,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
{
|
||||
name: "HEAD with captcha remediation should fallback to ban",
|
||||
method: http.MethodHead,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: true,
|
||||
},
|
||||
{
|
||||
name: "POST with captcha remediation should allow captcha",
|
||||
method: http.MethodPost,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
{
|
||||
name: "PUT with captcha remediation should allow captcha",
|
||||
method: http.MethodPut,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
{
|
||||
name: "DELETE with captcha remediation should allow captcha",
|
||||
method: http.MethodDelete,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
{
|
||||
name: "PATCH with captcha remediation should allow captcha",
|
||||
method: http.MethodPatch,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS with captcha remediation should allow captcha",
|
||||
method: http.MethodOptions,
|
||||
remediation: cache.CaptchaValue,
|
||||
expectBanFallback: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test the core logic: captcha is served for all methods except HEAD
|
||||
shouldUseCaptcha := tt.remediation == cache.CaptchaValue && tt.method != http.MethodHead
|
||||
|
||||
if shouldUseCaptcha == tt.expectBanFallback {
|
||||
t.Errorf("Method %s with %s remediation: expected ban fallback %v, but logic would use captcha %v",
|
||||
tt.method, tt.remediation, tt.expectBanFallback, shouldUseCaptcha)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.5.0"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -16,8 +16,8 @@ services:
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- logs-local:/var/log/traefik
|
||||
- './ban.html:/ban.html:ro'
|
||||
- './captcha.html:/captcha.html:ro'
|
||||
- "./ban.html:/ban.html:ro"
|
||||
- "./captcha.html:/captcha.html:ro"
|
||||
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- 8000:80
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=stream"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.ForwardedHeadersTrustedIPs=172.21.0.1/8"
|
||||
|
||||
bar2:
|
||||
image: traefik/whoami
|
||||
|
||||
+2
-6
@@ -1,8 +1,6 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.5.0"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -15,7 +13,7 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
@@ -48,8 +46,6 @@ services:
|
||||
# Define AppSec host and port informations
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
|
||||
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
container_name: "crowdsec"
|
||||
@@ -20,7 +20,7 @@ For now 3 captcha providers are supported:
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSiteKey=FIXME"
|
||||
# Define captcha secret key
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaSecretKey=FIXME"
|
||||
# Define captcha grade period seconds
|
||||
# Define captcha grace period seconds
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||
# Define captcha HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
@@ -47,7 +47,7 @@ To instruct Crowdsec to use captcha remediation, change the `/etc/crowdsec/profi
|
||||
- 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/).
|
||||
More information is available on configuring Crowdsec in the [official documentation](https://docs.crowdsec.net/docs/next/local_api/profiles/captcha_profile/).
|
||||
|
||||
```yaml
|
||||
...
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
</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">
|
||||
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
|
||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -45,3 +45,15 @@ To play the demo environment run:
|
||||
```bash
|
||||
make run_custom_ban_page
|
||||
```
|
||||
|
||||
## Another thing to note
|
||||
In the html of the ban page, you can use:
|
||||
- {{ .ClientIP }} to display the IP used to ban the request.
|
||||
- {{ .RemediationReason }} that convert on runtime into why the ban page is served. It's an enum with "APPSEC", "LAPI", "TECHNICAL_ISSUE" and it is useful to help user understand why the request is blocked.
|
||||
- {{ .CustomHeader }} value of the specified Request Header (for example X-Request-ID)
|
||||
```
|
||||
<script>var remediation = "{{ .RemediationReason }}"</script>
|
||||
<script>var clientIp = "{{ .ClientIP }}"</script>
|
||||
<script>var traceID = "{{ .TraceID }}"</script>
|
||||
```
|
||||
With the above tweak and some other js, you can customize your ban page on runtime.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Example
|
||||
|
||||
Read the example captcha before this, to better understand what is done here.
|
||||
|
||||
### Traefik configuration
|
||||
|
||||
The minimal configuration is defined below to implement custom captcha.
|
||||
This documentation use https://github.com/a-ve/wicketkeeper, a self-hosted captcha provider that have a similar API than big providers.
|
||||
|
||||
Minimal API requirement:
|
||||
|
||||
- the JS file URL to load the captcha on the served `captcha.html`
|
||||
- the HTML className to tell to the JS where to display the challenge
|
||||
- the verify URL endpoint to send the field `response` from the captcha with `content-type: application/x-www-form-urlencoded`
|
||||
- the name of the field when you POST the resolved captcha to Traefik
|
||||
|
||||
- the JS file need to respect the `data-callback` on the div that contains the captcha if you use our template, but you can customize it by your side
|
||||
|
||||
```yaml
|
||||
traefik:
|
||||
...
|
||||
labels:
|
||||
# Choose captcha provider
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
|
||||
# Define captcha grace period seconds
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=1800"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
|
||||
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
|
||||
# domain which would resolve traefik itself and the port for the dashboard
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
|
||||
# Define captcha HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
```
|
||||
|
||||
```yaml
|
||||
wicketkeeper:
|
||||
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||
user: root
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- LISTEN_PORT=8080
|
||||
- REDIS_ADDR=redis:6379
|
||||
- DIFFICULTY=4
|
||||
- ALLOWED_ORIGINS=*
|
||||
- PRIVATE_KEY_PATH=/data/wicketkeeper.key
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
- redis
|
||||
redis:
|
||||
image: redis/redis-stack-server:latest
|
||||
```
|
||||
|
||||
```html
|
||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
|
||||
```
|
||||
|
||||
## Exemple navigation
|
||||
|
||||
We can try to query normally the whoami server:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/foo
|
||||
```
|
||||
|
||||
We can try to ban ourself and retry.
|
||||
|
||||
```bash
|
||||
docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 10m --type captcha
|
||||
```
|
||||
|
||||
To play the demo environment run:
|
||||
|
||||
```bash
|
||||
make run_custom_captcha
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
filenames:
|
||||
- /var/log/traefik/access.log
|
||||
labels:
|
||||
type: traefik
|
||||
@@ -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 items-center space-y-1" id="captcha-form">
|
||||
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
|
||||
</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,110 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.5.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"
|
||||
- "--entrypoints.web.forwardedheaders.trustedips=172.18.0.0/24"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.4.5"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- logs-custom-captcha-enabled:/var/log/traefik
|
||||
- "./captcha.html:/captcha.html"
|
||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
ports:
|
||||
- 8000:80
|
||||
- 8080:8080
|
||||
depends_on:
|
||||
- crowdsec
|
||||
|
||||
whoami-foo:
|
||||
image: traefik/whoami
|
||||
container_name: "simple-service-custom-captcha-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.loglevel=DEBUG"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecmode=none"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
|
||||
|
||||
# Choose captcha provider
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaProvider=custom"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaCustomJsURL=http://captcha.localhost:8000/fast.js"
|
||||
# Inside Traefik container the plugin must be able to reach wicketkeeper service so we can go through a Traefik localhost
|
||||
# domain which would resolve traefik itself and the port for the dashboard
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomValidateURL=http://wicketkeeper:8080/v0/siteverify"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomKey=wicketkeeper"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.CaptchaCustomResponse=wicketkeeper_solution"
|
||||
# Define captcha grade period seconds
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaGracePeriodSeconds=20"
|
||||
# Define captcha HTML file path
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/captcha.html"
|
||||
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
container_name: "crowdsec"
|
||||
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-custom-captcha-enabled:/var/log/traefik:ro
|
||||
- crowdsec-db-custom-captcha-enabled:/var/lib/crowdsec/data/
|
||||
- crowdsec-config-custom-captcha-enabled:/etc/crowdsec/
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
|
||||
wicketkeeper:
|
||||
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||
container_name: "wicketkeeper"
|
||||
environment:
|
||||
- LISTEN_PORT=8080
|
||||
- REDIS_ADDR=redis:6379
|
||||
- DIFFICULTY=4
|
||||
- ALLOWED_ORIGINS=*
|
||||
- PRIVATE_KEY_PATH=/data/wicketkeeper.key # To override in production environment
|
||||
volumes:
|
||||
- wicketkeeper-custom-captcha-enabled:/data
|
||||
user: root
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Definition of the router
|
||||
- "traefik.http.routers.router-wicketkeeper.rule=Host(`captcha.localhost`)"
|
||||
- "traefik.http.routers.router-wicketkeeper.entrypoints=web"
|
||||
# Definition of the service
|
||||
- "traefik.http.services.service-wicketkeeper.loadbalancer.server.port=8080"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
redis:
|
||||
image: redis/redis-stack-server:latest
|
||||
|
||||
volumes:
|
||||
logs-custom-captcha-enabled:
|
||||
wicketkeeper-custom-captcha-enabled:
|
||||
crowdsec-db-custom-captcha-enabled:
|
||||
crowdsec-config-custom-captcha-enabled:
|
||||
@@ -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
|
||||
@@ -1,7 +1,9 @@
|
||||
# Example
|
||||
|
||||
## Using https communication and tls authentication with Crowdsec
|
||||
|
||||
##### Summary
|
||||
|
||||
This example demonstrates the use of https between the Traefik plugin and the Crowdsec LAPI.
|
||||
|
||||
It is possible to communicate with the LAPI in https and still authenticate with API key.
|
||||
@@ -17,7 +19,9 @@ In that case the setting **crowdsecLapiTLSInsecureVerify** must be set to true.
|
||||
It is recommended to validate the certificate presented by Crowdsec LAPI using the Certificate Authority which created it.
|
||||
|
||||
You can provide the Certificate Authority using:
|
||||
* A file path readable by Traefik
|
||||
|
||||
- A file path readable by Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
@@ -26,9 +30,11 @@ http:
|
||||
bouncer:
|
||||
crowdsecLapiTlsCertificateAuthorityFile: /etc/traefik/certs/crowdsecCA.pem
|
||||
```
|
||||
* The PEM encoded certificate as a text variable
|
||||
|
||||
- The PEM encoded certificate as a text variable
|
||||
|
||||
In the static file configuration of Traefik
|
||||
|
||||
```yaml
|
||||
http:
|
||||
middlewares:
|
||||
@@ -44,7 +50,9 @@ http:
|
||||
Q0veeNzBQXg1f/JxfeA39IDIX1kiCf71tGlT
|
||||
-----END CERTIFICATE-----
|
||||
```
|
||||
|
||||
In a dynamic configuration of a provider (ex docker) as a Label
|
||||
|
||||
```yaml
|
||||
services:
|
||||
whoami-foo:
|
||||
@@ -71,22 +79,34 @@ The service `whoami-foo` will authenticate with an **API key** over HTTPS after
|
||||
The service `whoami-bar` will authenticate with a **client certificate** signed by the CA.
|
||||
|
||||
Access to a route that communicate via https and authenticate with API-key:
|
||||
|
||||
```
|
||||
curl http://localhost:8000/foo
|
||||
```
|
||||
|
||||
Access to a route that communicate via https and authenticate with a client certificate:
|
||||
|
||||
```
|
||||
curl http://localhost:8000/bar
|
||||
```
|
||||
|
||||
Access to the traefik dashboard
|
||||
|
||||
```
|
||||
curl http://localhost:8080/dashboard/#/
|
||||
```
|
||||
|
||||
To play the demo environnement run:
|
||||
|
||||
```bash
|
||||
make run_tlsauth
|
||||
```
|
||||
|
||||
Note:
|
||||
> Traefik need to be restart if certificates are regenerated after his launch
|
||||
|
||||
> Traefik need to be restarted if certificates are regenerated after his launch, crowdsec also
|
||||
|
||||
## Separate LAPI and Appsec HTTP/S config
|
||||
|
||||
To separate TLS config for LAPI and Appsec, you can use all the TLS LAPI variable beginning with `CrowdsecLapi...` into `CrowdsecAppsec...`.
|
||||
Don't forget to set `CrowdsecAppsecScheme: HTTP` or `HTTPS` to trigger the separate setup.
|
||||
|
||||
@@ -2,3 +2,12 @@ filenames:
|
||||
- /var/log/traefik/access.log
|
||||
labels:
|
||||
type: traefik
|
||||
---
|
||||
listen_addr: 0.0.0.0:7422
|
||||
appsec_config: crowdsecurity/virtual-patching
|
||||
name: myAppSecComponent
|
||||
source: appsec
|
||||
labels:
|
||||
type: appsec
|
||||
cert_file: /etc/crowdsec/certs/server.pem
|
||||
key_file: /etc/crowdsec/certs/server-key.pem
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.5.0"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,15 +13,13 @@ services:
|
||||
- "--entrypoints.web.address=:80"
|
||||
|
||||
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
- "--experimental.plugins.bouncer.version=v1.3.0"
|
||||
- "--experimental.plugins.bouncer.version=v1.5.0"
|
||||
# - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./LAPIKEY:/etc/traefik/LAPIKEY:ro
|
||||
- logs-tls-auth:/var/log/traefik
|
||||
- crowdsec-certs-tls-auth:/etc/traefik/crowdsec-certs
|
||||
# - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
|
||||
|
||||
ports:
|
||||
- 8000:80
|
||||
- 8080:8080
|
||||
@@ -53,27 +51,34 @@ services:
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.router-bar.rule=Path(`/bar`)"
|
||||
- "traefik.http.routers.router-bar.rule=PathPrefix(`/bar`)"
|
||||
- "traefik.http.routers.router-bar.entrypoints=web"
|
||||
- "traefik.http.routers.router-bar.middlewares=crowdsec@docker"
|
||||
- "traefik.http.services.service-bar.loadbalancer.server.port=80"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecMode=none"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapischeme=https"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecscheme=https"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecAppsecTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapikey=40796d93c2958f9e58345514e67740e5="
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateAuthorityFile=/etc/traefik/crowdsec-certs/inter.pem"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerFile=/etc/traefik/crowdsec-certs/bouncer.pem"
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecLapiTLSCertificateBouncerKeyFile=/etc/traefik/crowdsec-certs/bouncer-key.pem"
|
||||
|
||||
# Enable AppSec
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsecenabled=true"
|
||||
# Define AppSec host and port informations
|
||||
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdsecappsechost=crowdsec:7422"
|
||||
crowdsec:
|
||||
image: crowdsecurity/crowdsec:v1.6.1-2
|
||||
image: crowdsecurity/crowdsec:latest
|
||||
container_name: "crowdsec"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
COLLECTIONS: crowdsecurity/traefik
|
||||
CUSTOM_HOSTNAME: crowdsec
|
||||
# whoami-foo is authenticating with api key over https
|
||||
# whoami-bar is authenticating with tls cert over https
|
||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5
|
||||
BOUNCER_KEY_TRAEFIK_FOO: 40796d93c2958f9e58345514e67740e5=
|
||||
LOCAL_API_URL: https://127.0.0.1:8080
|
||||
USE_TLS: "true"
|
||||
CERT_FILE: "/etc/crowdsec/certs/server.pem"
|
||||
@@ -88,6 +93,7 @@ services:
|
||||
# DISABLE_AGENT: "true"
|
||||
# Disabled for the examples
|
||||
DISABLE_ONLINE_API: "true"
|
||||
COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
|
||||
volumes:
|
||||
- ./config/acquis.yaml:/etc/crowdsec/acquis.yaml
|
||||
# - ./config/config.yaml:/etc/crowdsec/config_local.yaml
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
stdout=/out/res.log
|
||||
if [ -f "/out/inter-key.pem" ]; then
|
||||
exit 0
|
||||
fi
|
||||
cfssl gencert --initca /in/ca.json 2>${stdout} | cfssljson --bare "/out/ca" && \
|
||||
# Generate an intermediate certificate that will be used to sign the client certificates
|
||||
cfssl gencert --initca /in/intermediate.json 2>${stdout} | cfssljson --bare "/out/inter" && \
|
||||
|
||||
@@ -4,5 +4,5 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/leprosus/golang-ttl-map v1.1.7
|
||||
github.com/maxlerebourg/simpleredis v1.0.11
|
||||
github.com/maxlerebourg/simpleredis v1.0.12
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
github.com/leprosus/golang-ttl-map v1.1.7 h1:cF4AAFDDnJTFSV+/42sKLhmMluvLdRlCGS2UaifH6UM=
|
||||
github.com/leprosus/golang-ttl-map v1.1.7/go.mod h1:4QWHJPeVBbrkhOhXdhCv9IEiyj/YzkO04/iexy4vSe0=
|
||||
github.com/maxlerebourg/simpleredis v1.0.11 h1:B33TUeIrHtJH2/Qj2bRdU+UZ1BvZwFyP55JWMxHirWg=
|
||||
github.com/maxlerebourg/simpleredis v1.0.11/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||
github.com/maxlerebourg/simpleredis v1.0.12 h1:VsJpk2l8U9QqxOWbYnEXbrs7iSNZFm2fNmFvlxYlQNk=
|
||||
github.com/maxlerebourg/simpleredis v1.0.12/go.mod h1:lT4LX02SOsE9PxUcSrz1QW5ZnO86gPbaiYBxmtcXEls=
|
||||
|
||||
+22
-10
@@ -17,7 +17,6 @@ import (
|
||||
// Client Captcha client.
|
||||
type Client struct {
|
||||
Valid bool
|
||||
provider string
|
||||
siteKey string
|
||||
secretKey string
|
||||
remediationCustomHeader string
|
||||
@@ -26,44 +25,54 @@ type Client struct {
|
||||
cacheClient *cache.Client
|
||||
httpClient *http.Client
|
||||
log *logger.Log
|
||||
infoProvider *infoProvider
|
||||
}
|
||||
|
||||
// Information for self-hosted provider.
|
||||
type infoProvider struct {
|
||||
js string
|
||||
key string
|
||||
response string
|
||||
validate string
|
||||
}
|
||||
|
||||
var (
|
||||
//nolint:gochecknoglobals
|
||||
captcha = map[string]infoProvider{
|
||||
var infoProviders = map[string]*infoProvider{
|
||||
configuration.HcaptchaProvider: {
|
||||
js: "https://hcaptcha.com/1/api.js",
|
||||
key: "h-captcha",
|
||||
response: "h-captcha-response",
|
||||
validate: "https://api.hcaptcha.com/siteverify",
|
||||
},
|
||||
configuration.RecaptchaProvider: {
|
||||
js: "https://www.google.com/recaptcha/api.js",
|
||||
key: "g-recaptcha",
|
||||
response: "g-recaptcha-response",
|
||||
validate: "https://www.google.com/recaptcha/api/siteverify",
|
||||
},
|
||||
configuration.TurnstileProvider: {
|
||||
js: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
key: "cf-turnstile",
|
||||
response: "cf-turnstile-response",
|
||||
validate: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// New Initialize captcha client.
|
||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, js, key, response, validate, siteKey, secretKey, remediationCustomHeader, captchaTemplatePath string, gracePeriodSeconds int64) error {
|
||||
c.Valid = provider != ""
|
||||
if !c.Valid {
|
||||
return nil
|
||||
}
|
||||
var info *infoProvider
|
||||
if provider == configuration.CustomProvider {
|
||||
info = &infoProvider{js: js, key: key, response: response, validate: validate}
|
||||
} else {
|
||||
info = infoProviders[provider]
|
||||
}
|
||||
c.infoProvider = info
|
||||
c.siteKey = siteKey
|
||||
c.secretKey = secretKey
|
||||
c.provider = provider
|
||||
c.remediationCustomHeader = remediationCustomHeader
|
||||
html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
|
||||
c.captchaTemplate = html
|
||||
@@ -85,6 +94,9 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
||||
if valid {
|
||||
c.log.Debug("captcha:ServeHTTP captcha:valid")
|
||||
c.cacheClient.Set(remoteIP+"_captcha", cache.CaptchaDoneValue, c.gracePeriodSeconds)
|
||||
if c.remediationCustomHeader != "" {
|
||||
rw.Header().Set(c.remediationCustomHeader, "solved-captcha")
|
||||
}
|
||||
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -95,8 +107,8 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
|
||||
rw.WriteHeader(http.StatusOK)
|
||||
err = c.captchaTemplate.Execute(rw, map[string]string{
|
||||
"SiteKey": c.siteKey,
|
||||
"FrontendJS": captcha[c.provider].js,
|
||||
"FrontendKey": captcha[c.provider].key,
|
||||
"FrontendJS": c.infoProvider.js,
|
||||
"FrontendKey": c.infoProvider.key,
|
||||
})
|
||||
if err != nil {
|
||||
c.log.Info("captcha:ServeHTTP captchaTemplateServe " + err.Error())
|
||||
@@ -121,7 +133,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
||||
c.log.Debug("captcha:Validate invalid method: " + r.Method)
|
||||
return false, nil
|
||||
}
|
||||
var response = r.FormValue(captcha[c.provider].key + "-response")
|
||||
var response = r.FormValue(c.infoProvider.response)
|
||||
if response == "" {
|
||||
c.log.Debug("captcha:Validate no captcha response found in request")
|
||||
return false, nil
|
||||
@@ -129,7 +141,7 @@ func (c *Client) Validate(r *http.Request) (bool, error) {
|
||||
var body = url.Values{}
|
||||
body.Add("secret", c.secretKey)
|
||||
body.Add("response", response)
|
||||
res, err := c.httpClient.PostForm(captcha[c.provider].validate, body)
|
||||
res, err := c.httpClient.PostForm(c.infoProvider.validate, body)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -31,9 +31,13 @@ const (
|
||||
LogDEBUG = "DEBUG"
|
||||
LogINFO = "INFO"
|
||||
LogERROR = "ERROR"
|
||||
ReasonTECH = "TECHNICAL_ISSUE"
|
||||
ReasonLAPI = "LAPI"
|
||||
ReasonAPPSEC = "APPSEC"
|
||||
HcaptchaProvider = "hcaptcha"
|
||||
RecaptchaProvider = "recaptcha"
|
||||
TurnstileProvider = "turnstile"
|
||||
CustomProvider = "custom"
|
||||
)
|
||||
|
||||
// Config the plugin configuration.
|
||||
@@ -43,8 +47,18 @@ type Config struct {
|
||||
LogFilePath string `json:"logFilePath,omitempty"`
|
||||
CrowdsecMode string `json:"crowdsecMode,omitempty"`
|
||||
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
|
||||
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
|
||||
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
|
||||
CrowdsecAppsecPath string `json:"crowdsecAppsecPath,omitempty"`
|
||||
CrowdsecAppsecKey string `json:"crowdsecAppsecKey,omitempty"`
|
||||
CrowdsecAppsecKeyFile string `json:"crowdsecAppsecKeyFile,omitempty"`
|
||||
CrowdsecAppsecTLSInsecureVerify bool `json:"crowdsecAppsecTlsInsecureVerify,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateAuthority string `json:"crowdsecAppsecTlsCertificateAuthority,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateAuthorityFile string `json:"crowdsecAppsecTlsCertificateAuthorityFile,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncer string `json:"crowdsecAppsecTlsCertificateBouncer,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerFile string `json:"crowdsecAppsecTlsCertificateBouncerFile,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerKey string `json:"crowdsecAppsecTlsCertificateBouncerKey,omitempty"`
|
||||
CrowdsecAppsecTLSCertificateBouncerKeyFile string `json:"crowdsecAppsecTlsCertificateBouncerKeyFile,omitempty"`
|
||||
CrowdsecAppsecFailureBlock bool `json:"crowdsecAppsecFailureBlock,omitempty"`
|
||||
CrowdsecAppsecUnreachableBlock bool `json:"crowdsecAppsecUnreachableBlock,omitempty"`
|
||||
CrowdsecAppsecBodyLimit int64 `json:"crowdsecAppsecBodyLimit,omitempty"`
|
||||
@@ -71,6 +85,7 @@ type Config struct {
|
||||
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
|
||||
RemediationStatusCode int `json:"remediationStatusCode,omitempty"`
|
||||
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
|
||||
TraceHeadersCustomName string `json:"traceHeadersCustomName,omitempty"`
|
||||
RemediationHeadersCustomName string `json:"remediationHeadersCustomName,omitempty"`
|
||||
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomName,omitempty"`
|
||||
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
|
||||
@@ -84,6 +99,10 @@ type Config struct {
|
||||
BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
|
||||
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
|
||||
CaptchaProvider string `json:"captchaProvider,omitempty"`
|
||||
CaptchaCustomJsURL string `json:"captchaCustomJsUrl,omitempty"`
|
||||
CaptchaCustomValidateURL string `json:"captchaCustomValidateUrl,omitempty"`
|
||||
CaptchaCustomKey string `json:"captchaCustomKey,omitempty"`
|
||||
CaptchaCustomResponse string `json:"captchaCustomResponse,omitempty"`
|
||||
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
|
||||
CaptchaSiteKeyFile string `json:"captchaSiteKeyFile,omitempty"`
|
||||
CaptchaSecretKey string `json:"captchaSecretKey,omitempty"`
|
||||
@@ -108,11 +127,14 @@ func New() *Config {
|
||||
LogFilePath: "",
|
||||
CrowdsecMode: LiveMode,
|
||||
CrowdsecAppsecEnabled: false,
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecFailureBlock: true,
|
||||
CrowdsecAppsecUnreachableBlock: true,
|
||||
CrowdsecAppsecBodyLimit: 10485760,
|
||||
CrowdsecAppsecScheme: "",
|
||||
CrowdsecAppsecHost: "crowdsec:7422",
|
||||
CrowdsecAppsecPath: "/",
|
||||
CrowdsecAppsecKey: "",
|
||||
CrowdsecAppsecTLSInsecureVerify: false,
|
||||
CrowdsecLapiScheme: HTTP,
|
||||
CrowdsecLapiHost: "crowdsec:8080",
|
||||
CrowdsecLapiPath: "/",
|
||||
@@ -125,11 +147,16 @@ func New() *Config {
|
||||
RemediationStatusCode: http.StatusForbidden,
|
||||
HTTPTimeoutSeconds: 10,
|
||||
CaptchaProvider: "",
|
||||
CaptchaCustomJsURL: "",
|
||||
CaptchaCustomValidateURL: "",
|
||||
CaptchaCustomKey: "",
|
||||
CaptchaCustomResponse: "",
|
||||
CaptchaSiteKey: "",
|
||||
CaptchaSecretKey: "",
|
||||
CaptchaGracePeriodSeconds: 1800,
|
||||
CaptchaHTMLFilePath: "/captcha.html",
|
||||
BanHTMLFilePath: "",
|
||||
TraceHeadersCustomName: "",
|
||||
RemediationHeadersCustomName: "",
|
||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||
ForwardedHeadersTrustedIPs: []string{},
|
||||
@@ -196,6 +223,10 @@ func ValidateParams(config *Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateCaptcha(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateParamsIPs(config.ForwardedHeadersTrustedIPs, "ForwardedHeadersTrustedIPs"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -246,6 +277,10 @@ func ValidateParams(config *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
appsecKey, err := GetVariable(config, "CrowdsecAppsecKey")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -254,12 +289,21 @@ func ValidateParams(config *Config) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// We need to either have crowdsecLapiKey defined or the BouncerCert and Bouncerkey
|
||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") {
|
||||
if lapiKey == "" && (certBouncer == "" || certBouncerKey == "") && config.CrowdsecMode != AppsecMode {
|
||||
return errors.New("CrowdsecLapiKey || (CrowdsecLapiTLSCertificateBouncer && CrowdsecLapiTLSCertificateBouncerKey): cannot be all empty")
|
||||
} else if lapiKey != "" && (certBouncer == "" || certBouncerKey == "") {
|
||||
lapiKey = strings.TrimSpace(lapiKey)
|
||||
if err = validateParamsAPIKey(lapiKey); err != nil {
|
||||
if err = validateParamsAPIKey(lapiKey, "CrowdsecLapiKey"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate CrowdsecAppsecKey if provided
|
||||
if appsecKey != "" {
|
||||
appsecKey = strings.TrimSpace(appsecKey)
|
||||
if err = validateParamsAPIKey(appsecKey, "CrowdsecAppsecKey"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -298,10 +342,10 @@ func validateURL(variable, scheme, host, path string) error {
|
||||
// field name. RFC 7230 says:
|
||||
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
|
||||
// See https://httpwg.github.io/specs/rfc7230.html#rule.token.separators
|
||||
func validateParamsAPIKey(lapiKey string) error {
|
||||
func validateParamsAPIKey(key string, paramName string) error {
|
||||
reg := regexp.MustCompile("^[a-zA-Z0-9 !#$%&'*+-.^_`|~=/]*$")
|
||||
if !reg.MatchString(lapiKey) {
|
||||
return fmt.Errorf("CrowdsecLapiKey doesn't valid this regexp: '/%s/'", reg.String())
|
||||
if !reg.MatchString(key) {
|
||||
return fmt.Errorf("%s doesn't validate this regexp: '/%s/'", paramName, reg.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -331,6 +375,24 @@ func validateParamsIPs(listIP []string, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCaptcha(config *Config) error {
|
||||
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider}, config.CaptchaProvider) {
|
||||
return fmt.Errorf("CaptchaProvider: must be one of '%s', '%s', '%s' or '%s'", HcaptchaProvider, RecaptchaProvider, TurnstileProvider, CustomProvider)
|
||||
}
|
||||
if config.CaptchaProvider == CustomProvider {
|
||||
if config.CaptchaCustomKey == "" || config.CaptchaCustomResponse == "" || config.CaptchaCustomValidateURL == "" || config.CaptchaCustomJsURL == "" {
|
||||
return fmt.Errorf(
|
||||
"CaptchaProvider: provider is custom, captchaCustom variables must be filled: CaptchaCustomKey:%s, CaptchaCustomResponse:%s, CaptchaCustomValidateURL:%s, CaptchaCustomJsURL:%s",
|
||||
config.CaptchaCustomKey,
|
||||
config.CaptchaCustomResponse,
|
||||
config.CaptchaCustomValidateURL,
|
||||
config.CaptchaCustomJsURL,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateParamsRequired(config *Config) error {
|
||||
requiredStrings := map[string]string{
|
||||
"CrowdsecLapiScheme": config.CrowdsecLapiScheme,
|
||||
@@ -339,7 +401,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredStrings {
|
||||
if len(val) == 0 {
|
||||
return fmt.Errorf("%v: cannot be empty", key)
|
||||
return errors.New(key + ": cannot be empty")
|
||||
}
|
||||
}
|
||||
requiredInt0 := map[string]int64{
|
||||
@@ -348,7 +410,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredInt0 {
|
||||
if val < 0 {
|
||||
return fmt.Errorf("%v: cannot be less than 0", key)
|
||||
return errors.New(key + ": cannot be less than 0")
|
||||
}
|
||||
}
|
||||
requiredInt1 := map[string]int64{
|
||||
@@ -359,7 +421,7 @@ func validateParamsRequired(config *Config) error {
|
||||
}
|
||||
for key, val := range requiredInt1 {
|
||||
if val < 1 {
|
||||
return fmt.Errorf("%v: cannot be less than 1", key)
|
||||
return errors.New(key + ": cannot be less than 1")
|
||||
}
|
||||
}
|
||||
if config.UpdateMaxFailure < -1 {
|
||||
@@ -378,29 +440,27 @@ func validateParamsRequired(config *Config) error {
|
||||
if !contains([]string{HTTP, HTTPS}, config.CrowdsecLapiScheme) {
|
||||
return errors.New("CrowdsecLapiScheme: must be one of 'http' or 'https'")
|
||||
}
|
||||
if !contains([]string{"", HcaptchaProvider, RecaptchaProvider, TurnstileProvider}, config.CaptchaProvider) {
|
||||
return errors.New("CaptchaProvider: must be one of 'hcaptcha', 'recaptcha' or 'turnstile'")
|
||||
if !contains([]string{HTTP, HTTPS, ""}, config.CrowdsecAppsecScheme) {
|
||||
return errors.New("CrowdsecAppsecScheme: must be one of 'http' or 'https'")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||
//
|
||||
//nolint:nestif
|
||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error) {
|
||||
func getTLSConfig(config *Config, log *logger.Log, prefix, scheme string, insecureVerify bool) (*tls.Config, error) {
|
||||
tlsConfig := new(tls.Config)
|
||||
tlsConfig.RootCAs = x509.NewCertPool()
|
||||
//nolint:gocritic
|
||||
if config.CrowdsecLapiScheme != HTTPS {
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiScheme https:no")
|
||||
if scheme != HTTPS {
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "Scheme https:no")
|
||||
return tlsConfig, nil
|
||||
} else if config.CrowdsecLapiTLSInsecureVerify {
|
||||
}
|
||||
//nolint:nestif
|
||||
if insecureVerify {
|
||||
tlsConfig.InsecureSkipVerify = true
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSInsecureVerify tlsInsecure:true")
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSInsecureVerify tlsInsecure:true")
|
||||
// If we return here and still want to use client auth this won't work
|
||||
// return tlsConfig, nil
|
||||
} else {
|
||||
certAuthority, err := GetVariable(config, "CrowdsecLapiTLSCertificateAuthority")
|
||||
certAuthority, err := GetVariable(config, prefix+"TLSCertificateAuthority")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -408,17 +468,16 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
||||
if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(certAuthority)) {
|
||||
// here we return because if CrowdsecLapiTLSInsecureVerify is false
|
||||
// and CA not load, we can't communicate with https
|
||||
return nil, errors.New("getTLSConfigCrowdsec:cannot load CA and verify cert is enabled")
|
||||
return nil, errors.New("getTLSConfigCrowdsec:" + prefix + "cannot load CA and verify cert is enabled")
|
||||
}
|
||||
log.Debug("getTLSConfigCrowdsec:CrowdsecLapiTLSCertificateAuthority CA added successfully")
|
||||
log.Debug("getTLSConfigCrowdsec:" + prefix + "TLSCertificateAuthority CA added successfully")
|
||||
}
|
||||
}
|
||||
|
||||
certBouncer, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncer")
|
||||
certBouncer, err := GetVariable(config, prefix+"TLSCertificateBouncer")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certBouncerKey, err := GetVariable(config, "CrowdsecLapiTLSCertificateBouncerKey")
|
||||
certBouncerKey, err := GetVariable(config, prefix+"TLSCertificateBouncerKey")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -433,3 +492,14 @@ func GetTLSConfigCrowdsec(config *Config, log *logger.Log) (*tls.Config, error)
|
||||
|
||||
return tlsConfig, nil
|
||||
}
|
||||
|
||||
// GetTLSConfigCrowdsec get TLS config from Config.
|
||||
func GetTLSConfigCrowdsec(config *Config, log *logger.Log, isAppsec bool) (*tls.Config, error) {
|
||||
var prefix string
|
||||
if isAppsec && config.CrowdsecAppsecScheme != "" {
|
||||
prefix = "CrowdsecAppsec"
|
||||
return getTLSConfig(config, log, prefix, config.CrowdsecAppsecScheme, config.CrowdsecAppsecTLSInsecureVerify)
|
||||
}
|
||||
prefix = "CrowdsecLapi"
|
||||
return getTLSConfig(config, log, prefix, config.CrowdsecLapiScheme, config.CrowdsecLapiTLSInsecureVerify)
|
||||
}
|
||||
|
||||
@@ -206,22 +206,23 @@ func Test_validateParamsRequired(t *testing.T) {
|
||||
func Test_validateParamsAPIKey(t *testing.T) {
|
||||
type args struct {
|
||||
lapiKey string
|
||||
paramName string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~"}, wantErr: false},
|
||||
{name: "Not validate a @", args: args{lapiKey: "test@"}, wantErr: true},
|
||||
{name: "Not validate a (", args: args{lapiKey: "test("}, wantErr: true},
|
||||
{name: "Not validate a [", args: args{lapiKey: "test["}, wantErr: true},
|
||||
{name: "Not validate a ?", args: args{lapiKey: "test?"}, wantErr: true},
|
||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n"}, wantErr: true},
|
||||
{name: "Validate all the valid characters", args: args{lapiKey: "test!#$%&'*+-.^_`|~", paramName: "CrowdsecParamName"}, wantErr: false},
|
||||
{name: "Not validate a @", args: args{lapiKey: "test@", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a (", args: args{lapiKey: "test(", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a [", args: args{lapiKey: "test[", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a ?", args: args{lapiKey: "test?", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
{name: "Not validate a \\n, (must be trimed before)", args: args{lapiKey: "test\n", paramName: "CrowdsecParamName"}, wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := validateParamsAPIKey(tt.args.lapiKey); (err != nil) != tt.wantErr {
|
||||
if err := validateParamsAPIKey(tt.args.lapiKey, tt.args.paramName); (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateParamsAPIKey() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
@@ -242,7 +243,7 @@ func Test_GetTLSConfigCrowdsec(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""))
|
||||
got, err := GetTLSConfigCrowdsec(tt.args.config, logger.New("INFO", ""), false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getTLSConfigCrowdsec() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ func (sr *SimpleRedis) askRedis(cmd redisCmd, channel chan redisCmd) redisCmd {
|
||||
str := string(read)
|
||||
if strings.Contains(str, "-NOAUTH") {
|
||||
return redisCmd{Error: fmt.Errorf(RedisNoAuth)}
|
||||
} else if str != "$1" {
|
||||
} else if str == "$-1" {
|
||||
return redisCmd{Error: fmt.Errorf(RedisMiss)}
|
||||
}
|
||||
read, _ = reader.ReadLineBytes()
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
# github.com/leprosus/golang-ttl-map v1.1.7
|
||||
## explicit; go 1.15
|
||||
github.com/leprosus/golang-ttl-map
|
||||
# github.com/maxlerebourg/simpleredis v1.0.11
|
||||
# github.com/maxlerebourg/simpleredis v1.0.12
|
||||
## explicit; go 1.22
|
||||
github.com/maxlerebourg/simpleredis
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
||||
|
||||
// pluginVersion is updated automatically by the release workflow.
|
||||
var pluginVersion = "1.5.0" //nolint:gochecknoglobals
|
||||
Reference in New Issue
Block a user