Transform banTemplate to add blocking reason and client IP (#290)

*  Transform banTemplate to add blocking reason

* 🍱 fix test

* 🍱 fix lint

* 🍱 fix test

* 🍱 fix lint

* 🍱 fix lint

* 🍱 add doc and fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 fix lint

* 🍱 lint html

* 🍱 fix comments + fix wicketpeeker readme

* 🍱 Give ClientIP in ban page

* 🍱 fix test
This commit is contained in:
maxlerebourg
2025-11-15 10:42:14 +01:00
committed by GitHub
parent 2aac531ea7
commit 4ab4f3f183
7 changed files with 61 additions and 48 deletions

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
htmltemplate "html/template"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -106,7 +107,7 @@ type Bouncer struct {
crowdsecStreamRoute string crowdsecStreamRoute string
crowdsecHeader string crowdsecHeader string
redisUnreachableBlock bool redisUnreachableBlock bool
banTemplateString string banTemplate *htmltemplate.Template
clientPoolStrategy *ip.PoolStrategy clientPoolStrategy *ip.PoolStrategy
serverPoolStrategy *ip.PoolStrategy serverPoolStrategy *ip.PoolStrategy
httpClient *http.Client httpClient *http.Client
@@ -159,16 +160,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
config.CrowdsecLapiKey = apiKey config.CrowdsecLapiKey = apiKey
} }
var banTemplateString string var banTemplate *htmltemplate.Template
if config.BanHTMLFilePath != "" { if config.BanHTMLFilePath != "" {
var buf bytes.Buffer banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
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()
} }
bouncer := &Bouncer{ bouncer := &Bouncer{
@@ -198,7 +192,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
defaultDecisionTimeout: config.DefaultDecisionSeconds, defaultDecisionTimeout: config.DefaultDecisionSeconds,
remediationStatusCode: config.RemediationStatusCode, remediationStatusCode: config.RemediationStatusCode,
redisUnreachableBlock: config.RedisCacheUnreachableBlock, redisUnreachableBlock: config.RedisCacheUnreachableBlock,
banTemplateString: banTemplateString, banTemplate: banTemplate,
crowdsecStreamRoute: crowdsecStreamRoute, crowdsecStreamRoute: crowdsecStreamRoute,
crowdsecHeader: crowdsecHeader, crowdsecHeader: crowdsecHeader,
log: log, 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) remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
if err != nil { if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error())) 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 return
} }
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP) isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
if err != nil { if err != nil {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error())) 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 return
} }
// if our IP is in the trusted list we bypass the next checks // 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 { if bouncer.crowdsecMode == configuration.AppsecMode {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
return 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)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable { if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP)) bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
return return
} }
if cacheErrString != cache.CacheMiss { if cacheErrString != cache.CacheMiss {
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString)) 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 return
} }
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value)) bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
if value == cache.NoBannedValue { if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req) bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
} }
return 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. // 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 bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
if isCrowdsecStreamHealthy { if isCrowdsecStreamHealthy {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure)) 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 { } else {
value, err := handleNoStreamCache(bouncer, remoteIP) value, err := handleNoStreamCache(bouncer, remoteIP)
if value == cache.NoBannedValue { if value == cache.NoBannedValue {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
} else { } else {
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error())) 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. // 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) atomic.AddInt64(&blockedRequests, 1)
if bouncer.remediationCustomHeader != "" { if bouncer.remediationCustomHeader != "" {
rw.Header().Set(bouncer.remediationCustomHeader, "ban") rw.Header().Set(bouncer.remediationCustomHeader, "ban")
} }
if bouncer.banTemplateString == "" { if bouncer.banTemplate == nil {
rw.WriteHeader(bouncer.remediationStatusCode) rw.WriteHeader(bouncer.remediationStatusCode)
return return
} }
rw.Header().Set("Content-Type", "text/html; charset=utf-8") rw.Header().Set("Content-Type", "text/html; charset=utf-8")
rw.WriteHeader(bouncer.remediationStatusCode) rw.WriteHeader(bouncer.remediationStatusCode)
if method == http.MethodHead { if req.Method == http.MethodHead {
return return
} }
_, err := fmt.Fprint(rw, bouncer.banTemplateString) err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP})
if err != nil { if err != nil {
// use warn when https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pull/276 is completed bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter: " + 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)) 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.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
if bouncer.captchaClient.Check(remoteIP) { if bouncer.captchaClient.Check(remoteIP) {
handleNextServeHTTP(bouncer, remoteIP, rw, req) bouncer.handleNextServeHTTP(rw, req, remoteIP)
return return
} }
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request. atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP) bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
return 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 bouncer.appsecEnabled {
if err := appsecQuery(bouncer, remoteIP, req); err != nil { if err := appsecQuery(bouncer, remoteIP, req); err != nil {
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error())) bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
handleBanServeHTTP(bouncer, rw, req.Method) bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
return return
} }
} }

View File

@@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
import ( import (
"context" "context"
htmltemplate "html/template"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"reflect" "reflect"
@@ -188,40 +189,42 @@ func Test_crowdsecQuery(t *testing.T) {
} }
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) { func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
html := "<html>You are banned</html>"
banTemplate, _ := htmltemplate.New("html").Parse(html)
tests := []struct { tests := []struct {
name string name string
method string method string
banTemplateString string banTemplate *htmltemplate.Template
expectBodyContent bool expectBodyContent bool
}{ }{
{ {
name: "GET request should have body with template", name: "GET request should have body with template",
method: http.MethodGet, method: http.MethodGet,
banTemplateString: "<html>You are banned</html>", banTemplate: banTemplate,
expectBodyContent: true, expectBodyContent: true,
}, },
{ {
name: "HEAD request should NOT have body even with template", name: "HEAD request should NOT have body even with template",
method: http.MethodHead, method: http.MethodHead,
banTemplateString: "<html>You are banned</html>", banTemplate: banTemplate,
expectBodyContent: false, expectBodyContent: false,
}, },
{ {
name: "POST request should have body with template", name: "POST request should have body with template",
method: http.MethodPost, method: http.MethodPost,
banTemplateString: "<html>You are banned</html>", banTemplate: banTemplate,
expectBodyContent: true, expectBodyContent: true,
}, },
{ {
name: "PUT request should have body with template", name: "PUT request should have body with template",
method: http.MethodPut, method: http.MethodPut,
banTemplateString: "<html>You are banned</html>", banTemplate: banTemplate,
expectBodyContent: true, expectBodyContent: true,
}, },
{ {
name: "DELETE request should have body with template", name: "DELETE request should have body with template",
method: http.MethodDelete, method: http.MethodDelete,
banTemplateString: "<html>You are banned</html>", banTemplate: banTemplate,
expectBodyContent: true, expectBodyContent: true,
}, },
} }
@@ -229,16 +232,17 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
bouncer := &Bouncer{ bouncer := &Bouncer{
remediationStatusCode: 403, remediationStatusCode: http.StatusForbidden,
remediationCustomHeader: "X-Test-Remediation", remediationCustomHeader: "X-Test-Remediation",
banTemplateString: tt.banTemplateString, banTemplate: tt.banTemplate,
} }
rw := httptest.NewRecorder() 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 // Check status code
if rw.Code != 403 { if rw.Code != http.StatusForbidden {
t.Errorf("Expected status code 403, got %d", rw.Code) 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 we expect body content, verify it matches template
if tt.expectBodyContent && body != tt.banTemplateString { if tt.expectBodyContent && body != html {
t.Errorf("Expected body %q, got %q", tt.banTemplateString, body) t.Errorf("Expected body %q, got %q", html, body)
} }
}) })
} }
} }
func TestCaptchaMethodBasedLogic(t *testing.T) { func TestCaptchaMethodBasedLogic(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -45,3 +45,13 @@ To play the demo environment run:
```bash ```bash
make run_custom_ban_page 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.
```
<script>var remediation = "{{ .RemediationReason }}"</script>
<script>var clientIp = "{{ .ClientIP }}"</script>
```
With the above tweak and some other js, you can customize your ban page on runtime.

View File

@@ -41,7 +41,6 @@ wicketkeeper:
ports: ports:
- "8080:8080" - "8080:8080"
environment: environment:
- ROOT_URL=http://localhost:8080
- LISTEN_PORT=8080 - LISTEN_PORT=8080
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- DIFFICULTY=4 - DIFFICULTY=4
@@ -55,6 +54,10 @@ redis:
image: redis/redis-stack-server:latest 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 ## Exemple navigation
We can try to query normally the whoami server: We can try to query normally the whoami server:

View File

@@ -294,7 +294,7 @@
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1> <h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
</div> </div>
<form action="" method="POST" class="flex flex-col items-center 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 id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
</div> </div>
</form> </form>
<div class="flex justify-center flex-wrap"> <div class="flex justify-center flex-wrap">

View File

@@ -82,7 +82,6 @@ services:
image: ghcr.io/a-ve/wicketkeeper:latest image: ghcr.io/a-ve/wicketkeeper:latest
container_name: "wicketkeeper" container_name: "wicketkeeper"
environment: environment:
- ROOT_URL=http://captcha.localhost:8000
- LISTEN_PORT=8080 - LISTEN_PORT=8080
- REDIS_ADDR=redis:6379 - REDIS_ADDR=redis:6379
- DIFFICULTY=4 - DIFFICULTY=4

View File

@@ -31,6 +31,9 @@ const (
LogDEBUG = "DEBUG" LogDEBUG = "DEBUG"
LogINFO = "INFO" LogINFO = "INFO"
LogERROR = "ERROR" LogERROR = "ERROR"
ReasonTECH = "TECHNICAL_ISSUE"
ReasonLAPI = "LAPI"
ReasonAPPSEC = "APPSEC"
HcaptchaProvider = "hcaptcha" HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha" RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile" TurnstileProvider = "turnstile"