From 4ab4f3f18304a506517dae3919b9222a134d1da1 Mon Sep 17 00:00:00 2001 From: maxlerebourg Date: Sat, 15 Nov 2025 10:42:14 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Transform=20banTemplate=20to=20add?= =?UTF-8?q?=20blocking=20reason=20and=20client=20IP=20(#290)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: Transform banTemplate to add blocking reason * :bento: fix test * :bento: fix lint * :bento: fix test * :bento: fix lint * :bento: fix lint * :bento: add doc and fix lint * :bento: fix lint * :bento: fix lint * :bento: fix lint * :bento: fix lint * :bento: fix lint * :bento: lint html * :bento: fix comments + fix wicketpeeker readme * :bento: Give ClientIP in ban page * :bento: fix test --- bouncer.go | 59 ++++++++++------------ bouncer_test.go | 29 ++++++----- examples/custom-ban-page/README.md | 10 ++++ examples/custom-captcha/README.md | 5 +- examples/custom-captcha/captcha.html | 2 +- examples/custom-captcha/docker-compose.yml | 1 - pkg/configuration/configuration.go | 3 ++ 7 files changed, 61 insertions(+), 48 deletions(-) diff --git a/bouncer.go b/bouncer.go index e28d8f4..eff1d19 100644 --- a/bouncer.go +++ b/bouncer.go @@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + htmltemplate "html/template" "io" "net/http" "net/url" @@ -106,7 +107,7 @@ type Bouncer struct { crowdsecStreamRoute string crowdsecHeader string redisUnreachableBlock bool - banTemplateString string + banTemplate *htmltemplate.Template clientPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy httpClient *http.Client @@ -159,16 +160,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam config.CrowdsecLapiKey = 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{ @@ -198,7 +192,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam defaultDecisionTimeout: config.DefaultDecisionSeconds, remediationStatusCode: config.RemediationStatusCode, redisUnreachableBlock: config.RedisCacheUnreachableBlock, - banTemplateString: banTemplateString, + banTemplate: banTemplate, crowdsecStreamRoute: crowdsecStreamRoute, crowdsecHeader: crowdsecHeader, log: log, @@ -296,13 +290,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, req.Method) + 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, req.Method) + bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH) return } // if our IP is in the trusted list we bypass the next checks @@ -313,7 +307,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 +319,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, req.Method) + 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 +341,18 @@ 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, req.Method) + bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH) } } else { value, err := handleNoStreamCache(bouncer, remoteIP) 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,48 +386,47 @@ type Login struct { } // To append Headers we need to call rw.WriteHeader after set any header. -func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter, method string) { +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) - if method == http.MethodHead { + if req.Method == http.MethodHead { return } - _, err := fmt.Fprint(rw, bouncer.banTemplateString) + err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP}) if err != nil { - // use warn when https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pull/276 is completed - bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error()) + bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error()) } } -func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) { +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 && 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, req.Method) + 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, req.Method) + bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC) return } } diff --git a/bouncer_test.go b/bouncer_test.go index 62ee7b5..3146ed0 100644 --- a/bouncer_test.go +++ b/bouncer_test.go @@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck import ( "context" + htmltemplate "html/template" "net/http" "net/http/httptest" "reflect" @@ -188,40 +189,42 @@ func Test_crowdsecQuery(t *testing.T) { } func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { + html := "You are banned" + banTemplate, _ := htmltemplate.New("html").Parse(html) tests := []struct { name string method string - banTemplateString string + banTemplate *htmltemplate.Template expectBodyContent bool }{ { name: "GET request should have body with template", method: http.MethodGet, - banTemplateString: "You are banned", + banTemplate: banTemplate, expectBodyContent: true, }, { name: "HEAD request should NOT have body even with template", method: http.MethodHead, - banTemplateString: "You are banned", + banTemplate: banTemplate, expectBodyContent: false, }, { name: "POST request should have body with template", method: http.MethodPost, - banTemplateString: "You are banned", + banTemplate: banTemplate, expectBodyContent: true, }, { name: "PUT request should have body with template", method: http.MethodPut, - banTemplateString: "You are banned", + banTemplate: banTemplate, expectBodyContent: true, }, { name: "DELETE request should have body with template", method: http.MethodDelete, - banTemplateString: "You are banned", + banTemplate: banTemplate, expectBodyContent: true, }, } @@ -229,16 +232,17 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { bouncer := &Bouncer{ - remediationStatusCode: 403, + remediationStatusCode: http.StatusForbidden, remediationCustomHeader: "X-Test-Remediation", - banTemplateString: tt.banTemplateString, + banTemplate: tt.banTemplate, } rw := httptest.NewRecorder() - handleBanServeHTTP(bouncer, rw, tt.method) + req := &http.Request{Method: tt.method} + bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST") // Check status code - if rw.Code != 403 { + if rw.Code != http.StatusForbidden { t.Errorf("Expected status code 403, got %d", rw.Code) } @@ -258,12 +262,13 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { } // If we expect body content, verify it matches template - if tt.expectBodyContent && body != tt.banTemplateString { - t.Errorf("Expected body %q, got %q", tt.banTemplateString, body) + if tt.expectBodyContent && body != html { + t.Errorf("Expected body %q, got %q", html, body) } }) } } + func TestCaptchaMethodBasedLogic(t *testing.T) { tests := []struct { name string diff --git a/examples/custom-ban-page/README.md b/examples/custom-ban-page/README.md index 49258d3..e81c5e4 100644 --- a/examples/custom-ban-page/README.md +++ b/examples/custom-ban-page/README.md @@ -45,3 +45,13 @@ 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. +``` + + +``` +With the above tweak and some other js, you can customize your ban page on runtime. diff --git a/examples/custom-captcha/README.md b/examples/custom-captcha/README.md index 7310984..fd78dad 100644 --- a/examples/custom-captcha/README.md +++ b/examples/custom-captcha/README.md @@ -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 +
+``` + ## Exemple navigation We can try to query normally the whoami server: diff --git a/examples/custom-captcha/captcha.html b/examples/custom-captcha/captcha.html index bf7a8af..7fded8e 100644 --- a/examples/custom-captcha/captcha.html +++ b/examples/custom-captcha/captcha.html @@ -294,7 +294,7 @@

CrowdSec Captcha

-
+
diff --git a/examples/custom-captcha/docker-compose.yml b/examples/custom-captcha/docker-compose.yml index 7390312..be3a125 100644 --- a/examples/custom-captcha/docker-compose.yml +++ b/examples/custom-captcha/docker-compose.yml @@ -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 diff --git a/pkg/configuration/configuration.go b/pkg/configuration/configuration.go index 0ad4432..cbda4ca 100644 --- a/pkg/configuration/configuration.go +++ b/pkg/configuration/configuration.go @@ -31,6 +31,9 @@ const ( LogDEBUG = "DEBUG" LogINFO = "INFO" LogERROR = "ERROR" + ReasonTECH = "TECHNICAL_ISSUE" + ReasonLAPI = "LAPI" + ReasonAPPSEC = "APPSEC" HcaptchaProvider = "hcaptcha" RecaptchaProvider = "recaptcha" TurnstileProvider = "turnstile"