diff --git a/Makefile b/Makefile
index 6a60c8a..cb593af 100644
--- a/Makefile
+++ b/Makefile
@@ -46,6 +46,9 @@ run_appsec:
run_captcha:
docker compose -f examples/captcha/docker-compose.captcha.yml up -d
+run_custom_ban_page:
+ docker compose -f examples/custom-ban-page/docker-compose.yml up -d
+
run:
docker compose -f docker-compose.yml up -d --remove-orphans
diff --git a/README.md b/README.md
index a23c8bb..58cedc3 100644
--- a/README.md
+++ b/README.md
@@ -177,15 +177,18 @@ Only one instance of the plugin is *possible*.
- CaptchaSecretKey
- string
- Site secret key for the captcha provider
-- CaptchaHTMLFilePath
- - string
- - default: /captcha.html
- - Path where the captcha template is stored
- CaptchaGracePeriodSeconds
- int64
- default: 1800 (= 30 minutes)
- Period after validation of a captcha before a new validation is required if Crowdsec decision is still valid
-
+- CaptchaHTMLFilePath
+ - string
+ - default: /captcha.html
+ - Path where the captcha template is stored
+- BanHTMLFilePath
+ - string
+ - default: ""
+ - Path where the ban html file is stored (default empty ""=disabled)
### Configuration
@@ -285,11 +288,12 @@ http:
captchaSecretKey: FIXME
captchaGracePeriodSeconds: 1800
captchaHTMLFilePath: /captcha.html
+ banHTMLFilePath: ban.html
```
#### Fill variable with value of file
-`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword` and `CrowdsecLapiKey` can be provided with the content as raw or through a file path that Traefik can read.
+`CrowdsecLapiTlsCertificateBouncerKey`, `CrowdsecLapiTlsCertificateBouncer`, `CrowdsecLapiTlsCertificateAuthority`, `CrowdsecCapiMachineId`, `CrowdsecCapiPassword`, `CrowdsecLapiKey`, `CaptchaSiteKey` and `CaptchaSecretKey` 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:
@@ -378,6 +382,8 @@ docker exec crowdsec cscli decisions remove --ip 10.0.0.10 -t captcha
#### 9. Using Traefik with Captcha remediation feature enabled [examples/captcha/README.md](https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/blob/main/examples/captcha/README.md)
+#### 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)
+
### Local Mode
Traefik also offers a developer mode that can be used for temporary testing of plugins not hosted on GitHub.
diff --git a/ban.html b/ban.html
new file mode 100644
index 0000000..ffa4a8c
--- /dev/null
+++ b/ban.html
@@ -0,0 +1,329 @@
+
+
+
+
+ CrowdSec Access Forbidden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CrowdSec Access Forbidden
+
+
+
+
+
+
diff --git a/bouncer.go b/bouncer.go
index 791c9ad..89c2f82 100644
--- a/bouncer.go
+++ b/bouncer.go
@@ -8,6 +8,7 @@ import (
"crypto/tls"
"encoding/json"
"fmt"
+ htmlTemplate "html/template"
"io"
"net/http"
"net/url"
@@ -72,6 +73,7 @@ type Bouncer struct {
customHeader string
crowdsecStreamRoute string
crowdsecHeader string
+ banTemplate *htmlTemplate.Template
clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy
httpClient *http.Client
@@ -118,6 +120,10 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n
}
config.CrowdsecLapiKey = apiKey
}
+ var banTemplate *htmlTemplate.Template
+ if config.BanHTMLFilePath != "" {
+ banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
+ }
bouncer := &Bouncer{
next: next,
@@ -141,6 +147,7 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n
crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader,
log: log,
+ banTemplate: banTemplate,
serverPoolStrategy: &ip.PoolStrategy{
Checker: serverChecker,
},
@@ -219,13 +226,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.customHeader)
if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
return
}
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
return
}
// if our IP is in the trusted list we bypass the next checks
@@ -248,7 +255,7 @@ 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 cacheErrString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
return
}
} else {
@@ -256,7 +263,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
- handleErrorServeHTTP(bouncer, remoteIP, value, rw, req)
+ handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
}
return
}
@@ -268,7 +275,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s", remoteIP))
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
}
} else {
value, err := handleNoStreamCache(bouncer, remoteIP)
@@ -276,7 +283,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
- handleErrorServeHTTP(bouncer, remoteIP, value, rw, req)
+ handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
}
}
}
@@ -309,8 +316,18 @@ type Login struct {
Expire string `json:"expire"`
}
-func handleErrorServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
- bouncer.log.Debug(fmt.Sprintf("handleErrorServeHTTP ip:%s remediation:%s", remoteIP, remediation))
+func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter) {
+ rw.WriteHeader(http.StatusForbidden)
+ if bouncer.banTemplate != nil {
+ err := bouncer.banTemplate.Execute(rw, map[string]string{"caca": "caca"})
+ if err != nil {
+ bouncer.log.Info(fmt.Sprintf("handleBanServeHTTP banTemplateServe %s", err.Error()))
+ }
+ }
+}
+
+func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
+ bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue {
if bouncer.captchaClient.Check(remoteIP) {
handleNextServeHTTP(bouncer, remoteIP, rw, req)
@@ -319,14 +336,14 @@ func handleErrorServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw htt
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
return
}
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
}
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
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()))
- rw.WriteHeader(http.StatusForbidden)
+ handleBanServeHTTP(bouncer, rw)
return
}
}
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index d8e612e..2741212 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -16,6 +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'
- ./:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
ports:
- 8000:80
@@ -31,9 +33,9 @@ services:
- "traefik.enable=true"
- "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
- "traefik.http.routers.router-foo.entrypoints=web"
- - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
+ - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
-
+
whoami2:
image: traefik/whoami
container_name: "simple-service-bar"
@@ -50,7 +52,7 @@ services:
- "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5="
crowdsec:
- image: crowdsecurity/crowdsec:1.6.0
+ image: crowdsecurity/crowdsec:v1.6.0
container_name: "crowdsec"
restart: unless-stopped
environment:
diff --git a/docker-compose.yml b/docker-compose.yml
index cdb0ef1..2b9d88c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -12,9 +12,11 @@ services:
- "--entrypoints.web.address=:80"
- "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
- - "--experimental.plugins.bouncer.version=v1.1.15"
+ - "--experimental.plugins.bouncer.version=v1.3.0-beta1"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
+ # - './ban.html:/ban.html:ro'
+ # - './captcha.html:/captcha.html:ro'
- "logs:/var/log/traefik"
ports:
- 8000:80
@@ -34,7 +36,7 @@ services:
- "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
# Definition of the service
- "traefik.http.services.service-foo.loadbalancer.server.port=80"
-
+
whoami2:
image: traefik/whoami
container_name: "simple-service-bar"
diff --git a/examples/custom-ban-page/README.md b/examples/custom-ban-page/README.md
new file mode 100644
index 0000000..18e6b89
--- /dev/null
+++ b/examples/custom-ban-page/README.md
@@ -0,0 +1,47 @@
+# Example
+## Adding a custom ban page
+
+Traefik can return a custom HTML ban page along with the 403 HTTP response code.
+This can be usefull as some browser (Firefox for instance) return a 403 blank webpage and we can mistake a server/reverse-proxy error with a ban from Crowdsec.
+
+### Traefik configuration
+
+
+```yaml
+ labels:
+ # Define ban HTML file path
+ - "traefik.http.middlewares.crowdsec.plugin.bouncer.captchaHTMLFilePath=/ban.html"
+```
+
+The ban HTML file must be present in the Traefik container (bind mounted or added during a custom build).
+It is not directly accessible from Traefik even when importing the plugin, so [download](https://raw.githubusercontent.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/master/ban.html) it locally to expose it to Traefik.
+
+```yaml
+ ...
+ traefik:
+ image: "traefik:v2.11.0"
+ volumes:
+ - './ban.html:/ban.html'
+ ...
+```
+
+## Exemple navigation
+
+We can try to query normally the whoami server:
+```bash
+curl http://localhost:8000/foo
+```
+
+We can try to ban ourself
+
+```bash
+docker exec crowdsec cscli decisions add --ip 10.0.0.20 -d 4h --type ban
+```
+
+
+We will see in the browser the ban custom page:
+
+To play the demo environment run:
+```bash
+make run_custom_ban_page
+```
diff --git a/examples/custom-ban-page/acquis.yaml b/examples/custom-ban-page/acquis.yaml
new file mode 100644
index 0000000..5d52554
--- /dev/null
+++ b/examples/custom-ban-page/acquis.yaml
@@ -0,0 +1,4 @@
+filenames:
+ - /var/log/traefik/access.log
+labels:
+ type: traefik
diff --git a/examples/custom-ban-page/ban.html b/examples/custom-ban-page/ban.html
new file mode 100644
index 0000000..c6b59e0
--- /dev/null
+++ b/examples/custom-ban-page/ban.html
@@ -0,0 +1,330 @@
+
+
+
+
+ CrowdSec Access Forbidden
+
+
+
+
+
+
+
+
+
+
+
+
+
+
CrowdSec Access Forbidden
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/custom-ban-page/docker-compose.yml b/examples/custom-ban-page/docker-compose.yml
new file mode 100644
index 0000000..8a9fa99
--- /dev/null
+++ b/examples/custom-ban-page/docker-compose.yml
@@ -0,0 +1,67 @@
+services:
+ traefik:
+ image: "traefik:v2.11.0"
+ container_name: "traefik"
+ restart: unless-stopped
+ command:
+ # - "--log.level=DEBUG"
+ - "--accesslog"
+ - "--accesslog.filepath=/var/log/traefik/access.log"
+ - "--api.insecure=true"
+ - "--providers.docker=true"
+ - "--providers.docker.exposedbydefault=false"
+ - "--entrypoints.web.address=:80"
+
+ # - "--experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
+ # - "--experimental.plugins.bouncer.version=v1.2.0"
+ - "--experimental.localplugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock:ro
+ - logs-custom-ban-page-enabled:/var/log/traefik
+ - './ban.html:/ban.html'
+ - ./../../:/plugins-local/src/github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
+ ports:
+ - 8000:80
+ - 8080:8080
+ depends_on:
+ - crowdsec
+
+ whoami1:
+ image: traefik/whoami
+ container_name: "simple-service-foo"
+ restart: unless-stopped
+ labels:
+ - "traefik.enable=true"
+ # Definition of the router
+ - "traefik.http.routers.router-foo.rule=PathPrefix(`/foo`)"
+ - "traefik.http.routers.router-foo.entrypoints=web"
+ - "traefik.http.routers.router-foo.middlewares=crowdsec@docker"
+ # Definition of the service
+ - "traefik.http.services.service-foo.loadbalancer.server.port=80"
+ # Definition of the middleware
+ - "traefik.http.middlewares.crowdsec.plugin.bouncer.enabled=true"
+ - "traefik.http.middlewares.crowdsec.plugin.bouncer.crowdseclapikey=40796d93c2958f9e58345514e67740e5"
+ - "traefik.http.middlewares.crowdsec.plugin.bouncer.loglevel=DEBUG"
+ # Define ban HTML file path
+ - "traefik.http.middlewares.crowdsec.plugin.bouncer.banHtmlFilePath=/ban.html"
+
+ crowdsec:
+ image: crowdsecurity/crowdsec:v1.6.0
+ container_name: "crowdsec"
+ restart: unless-stopped
+ environment:
+ COLLECTIONS: crowdsecurity/traefik
+ CUSTOM_HOSTNAME: crowdsec
+ BOUNCER_KEY_TRAEFIK_DEV: 40796d93c2958f9e58345514e67740e5
+ volumes:
+ - './acquis.yaml:/etc/crowdsec/acquis.yaml:ro'
+ - logs-custom-ban-page-enabled:/var/log/traefik:ro
+ - crowdsec-db-custom-ban-page-enabled:/var/lib/crowdsec/data/
+ - crowdsec-config-custom-ban-page-enabled:/etc/crowdsec/
+ labels:
+ - "traefik.enable=false"
+
+volumes:
+ logs-custom-ban-page-enabled:
+ crowdsec-db-custom-ban-page-enabled:
+ crowdsec-config-custom-ban-page-enabled:
diff --git a/examples/custom-ban-page/image_decision_ban.png b/examples/custom-ban-page/image_decision_ban.png
new file mode 100644
index 0000000..62c50e3
Binary files /dev/null and b/examples/custom-ban-page/image_decision_ban.png differ
diff --git a/pkg/captcha/captcha.go b/pkg/captcha/captcha.go
index 15d73fc..af17e76 100644
--- a/pkg/captcha/captcha.go
+++ b/pkg/captcha/captcha.go
@@ -7,7 +7,6 @@ import (
"html/template"
"net/http"
"net/url"
- "os"
"strings"
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
@@ -22,7 +21,7 @@ type Client struct {
siteKey string
secretKey string
gracePeriodSeconds int64
- htmlPage *template.Template
+ captchaTemplate *template.Template
cacheClient *cache.Client
httpClient *http.Client
log *logger.Log
@@ -55,26 +54,8 @@ var (
}
)
-func compileTemplate(path string) (*template.Template, error) {
- var err error
- if path == "" {
- return nil, fmt.Errorf("no captcha template provided")
- }
- //nolint:gosec
- b, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- html := string(b)
- compiledTemplate, err := template.New("captcha").Parse(html)
- if err != nil {
- return nil, fmt.Errorf("impossible to compile captcha template: %w", err)
- }
- return compiledTemplate, nil
-}
-
// New Initialize captcha client.
-func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, htmlPagePath string, gracePeriodSeconds int64) error {
+func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *http.Client, provider, siteKey, secretKey, captchaTemplatePath string, gracePeriodSeconds int64) error {
c.Valid = provider != ""
if !c.Valid {
return nil
@@ -82,11 +63,8 @@ func (c *Client) New(log *logger.Log, cacheClient *cache.Client, httpClient *htt
c.siteKey = siteKey
c.secretKey = secretKey
c.provider = provider
- html, err := compileTemplate(htmlPagePath)
- if err != nil {
- return err
- }
- c.htmlPage = html
+ html, _ := configuration.GetHTMLTemplate(captchaTemplatePath)
+ c.captchaTemplate = html
c.gracePeriodSeconds = gracePeriodSeconds
c.log = log
c.httpClient = httpClient
@@ -108,13 +86,13 @@ func (c *Client) ServeHTTP(rw http.ResponseWriter, r *http.Request, remoteIP str
http.Redirect(rw, r, r.URL.String(), http.StatusFound)
return
}
- err = c.htmlPage.Execute(rw, map[string]string{
+ err = c.captchaTemplate.Execute(rw, map[string]string{
"SiteKey": c.siteKey,
"FrontendJS": captcha[c.provider].js,
"FrontendKey": captcha[c.provider].key,
})
if err != nil {
- c.log.Info("captcha:ServeHTTP Can't serve HTML")
+ c.log.Info(fmt.Sprintf("captcha:ServeHTTP captchaTemplateServe %s", err.Error()))
}
}
diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go
index 1e867f9..7c083b8 100644
--- a/pkg/configuration/configuration.go
+++ b/pkg/configuration/configuration.go
@@ -5,6 +5,7 @@ import (
"crypto/tls"
"crypto/x509"
"fmt"
+ "html/template"
"net/http"
"net/url"
"os"
@@ -66,6 +67,7 @@ type Config struct {
RedisCachePassword string `json:"redisCachePassword,omitempty"`
RedisCachePasswordFile string `json:"redisCachePasswordFile,omitempty"`
RedisCacheDatabase string `json:"redisCacheDatabase,omitempty"`
+ BanHTMLFilePath string `json:"banHtmlFilePath,omitempty"`
CaptchaHTMLFilePath string `json:"captchaHtmlFilePath,omitempty"`
CaptchaProvider string `json:"captchaProvider,omitempty"`
CaptchaSiteKey string `json:"captchaSiteKey,omitempty"`
@@ -103,8 +105,9 @@ func New() *Config {
CaptchaProvider: "",
CaptchaSiteKey: "",
CaptchaSecretKey: "",
- CaptchaHTMLFilePath: "/captcha.html",
CaptchaGracePeriodSeconds: 1800,
+ CaptchaHTMLFilePath: "/captcha.html",
+ BanHTMLFilePath: "",
ForwardedHeadersCustomName: "X-Forwarded-For",
ForwardedHeadersTrustedIPs: []string{},
ClientTrustedIPs: []string{},
@@ -142,6 +145,25 @@ func GetVariable(config *Config, key string) (string, error) {
return strings.TrimSpace(value), nil
}
+// GetHTMLTemplate get compiled HTML template.
+func GetHTMLTemplate(path string) (*template.Template, error) {
+ var err error
+ if path == "" {
+ return nil, fmt.Errorf("no html template provided")
+ }
+ //nolint:gosec
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ html := string(b)
+ compiledTemplate, err := template.New("html").Parse(html)
+ if err != nil {
+ return nil, fmt.Errorf("impossible to compile html template: %w", err)
+ }
+ return compiledTemplate, nil
+}
+
// ValidateParams validate all the param gave by user.
//
//nolint:gocyclo,gocognit
@@ -178,6 +200,14 @@ func ValidateParams(config *Config) error {
if _, err := GetVariable(config, "CaptchaSecretKey"); err != nil {
return err
}
+ if _, err := GetHTMLTemplate(config.CaptchaHTMLFilePath); err != nil {
+ return err
+ }
+ }
+ if config.BanHTMLFilePath != "" {
+ if _, err := GetHTMLTemplate(config.BanHTMLFilePath); err != nil {
+ return err
+ }
}
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {