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 | |
|---|---|---|---|
|
|
889c5b55fe | ||
|
|
efb3a67019 | ||
|
|
0780027252 | ||
|
|
892909b9b8 | ||
|
|
c26923dee5 | ||
|
|
a9d83f2097 | ||
|
|
e20ccc5d0c | ||
|
|
50beb4294f | ||
|
|
4ab4f3f183 | ||
|
|
2aac531ea7 | ||
|
|
e8e60c958f | ||
|
|
a2d3708bc3 | ||
|
|
65a2f79fb3 | ||
|
|
a2ecc95dc9 |
@@ -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@v5
|
||||
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') }}
|
||||
|
||||
@@ -99,7 +99,7 @@ clean_all_docker:
|
||||
docker compose -f examples/redis-cache/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/trusted-ips/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/tls-auth/docker-compose.yml down --remove-orphans
|
||||
docker compose -f examples/appsec-enabled/docker-compose.appsec-enabled.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
|
||||
|
||||
@@ -310,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
|
||||
|
||||
@@ -351,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: "/"
|
||||
@@ -368,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`
|
||||
@@ -406,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"
|
||||
@@ -497,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
|
||||
|
||||
@@ -550,6 +568,7 @@ http:
|
||||
httpTimeoutSeconds: 10
|
||||
crowdsecMode: live
|
||||
crowdsecAppsecEnabled: false
|
||||
crowdsecAppsecScheme: ""
|
||||
crowdsecAppsecHost: crowdsec:7422
|
||||
crowdsecAppsecPath: "/"
|
||||
crowdsecAppsecFailureBlock: true
|
||||
@@ -603,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:
|
||||
@@ -671,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
|
||||
|
||||
+92
-43
@@ -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{},
|
||||
}
|
||||
@@ -296,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
|
||||
@@ -313,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
|
||||
}
|
||||
|
||||
@@ -325,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
|
||||
}
|
||||
@@ -347,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,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
|
||||
}
|
||||
}
|
||||
@@ -524,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
|
||||
@@ -658,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,
|
||||
}
|
||||
@@ -683,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 {
|
||||
|
||||
+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
-2
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
traefik:
|
||||
image: "traefik:v3.0.0"
|
||||
image: "traefik:v3.5.0"
|
||||
container_name: "traefik"
|
||||
restart: unless-stopped
|
||||
command:
|
||||
@@ -13,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
|
||||
@@ -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
|
||||
...
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,7 @@ 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/wicketpeeker, a self-hosted captcha provider that have a similar API than big providers.
|
||||
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:
|
||||
|
||||
@@ -41,7 +41,6 @@ wicketkeeper:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- ROOT_URL=http://localhost:8080
|
||||
- LISTEN_PORT=8080
|
||||
- REDIS_ADDR=redis:6379
|
||||
- DIFFICULTY=4
|
||||
@@ -55,6 +54,10 @@ 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:
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
<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">
|
||||
<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">
|
||||
|
||||
@@ -82,7 +82,6 @@ services:
|
||||
image: ghcr.io/a-ve/wicketkeeper:latest
|
||||
container_name: "wicketkeeper"
|
||||
environment:
|
||||
- ROOT_URL=http://captcha.localhost:8000
|
||||
- LISTEN_PORT=8080
|
||||
- REDIS_ADDR=redis:6379
|
||||
- DIFFICULTY=4
|
||||
@@ -94,10 +93,10 @@ services:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Definition of the router
|
||||
- "traefik.http.routers.router-wicketpeeker.rule=Host(`captcha.localhost`)"
|
||||
- "traefik.http.routers.router-wicketpeeker.entrypoints=web"
|
||||
- "traefik.http.routers.router-wicketkeeper.rule=Host(`captcha.localhost`)"
|
||||
- "traefik.http.routers.router-wicketkeeper.entrypoints=web"
|
||||
# Definition of the service
|
||||
- "traefik.http.services.service-whitekeeper.loadbalancer.server.port=8080"
|
||||
- "traefik.http.services.service-wicketkeeper.loadbalancer.server.port=8080"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
|
||||
@@ -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" && \
|
||||
|
||||
@@ -94,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
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ const (
|
||||
LogDEBUG = "DEBUG"
|
||||
LogINFO = "INFO"
|
||||
LogERROR = "ERROR"
|
||||
ReasonTECH = "TECHNICAL_ISSUE"
|
||||
ReasonLAPI = "LAPI"
|
||||
ReasonAPPSEC = "APPSEC"
|
||||
HcaptchaProvider = "hcaptcha"
|
||||
RecaptchaProvider = "recaptcha"
|
||||
TurnstileProvider = "turnstile"
|
||||
@@ -44,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"`
|
||||
@@ -72,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"`
|
||||
@@ -113,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: "/",
|
||||
@@ -139,6 +156,7 @@ func New() *Config {
|
||||
CaptchaGracePeriodSeconds: 1800,
|
||||
CaptchaHTMLFilePath: "/captcha.html",
|
||||
BanHTMLFilePath: "",
|
||||
TraceHeadersCustomName: "",
|
||||
RemediationHeadersCustomName: "",
|
||||
ForwardedHeadersCustomName: "X-Forwarded-For",
|
||||
ForwardedHeadersTrustedIPs: []string{},
|
||||
@@ -259,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
|
||||
@@ -267,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
|
||||
}
|
||||
}
|
||||
@@ -311,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
|
||||
}
|
||||
@@ -409,26 +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{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
|
||||
}
|
||||
@@ -436,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
|
||||
}
|
||||
@@ -461,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
|
||||
|
||||
Reference in New Issue
Block a user