Skip to content

Commit 4ab4f3f

Browse files
authored
✨ 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
1 parent 2aac531 commit 4ab4f3f

File tree

7 files changed

+61
-48
lines changed

7 files changed

+61
-48
lines changed

bouncer.go

Lines changed: 26 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"encoding/json"
1010
"errors"
1111
"fmt"
12+
htmltemplate "html/template"
1213
"io"
1314
"net/http"
1415
"net/url"
@@ -106,7 +107,7 @@ type Bouncer struct {
106107
crowdsecStreamRoute string
107108
crowdsecHeader string
108109
redisUnreachableBlock bool
109-
banTemplateString string
110+
banTemplate *htmltemplate.Template
110111
clientPoolStrategy *ip.PoolStrategy
111112
serverPoolStrategy *ip.PoolStrategy
112113
httpClient *http.Client
@@ -159,16 +160,9 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
159160
config.CrowdsecLapiKey = apiKey
160161
}
161162

162-
var banTemplateString string
163+
var banTemplate *htmltemplate.Template
163164
if config.BanHTMLFilePath != "" {
164-
var buf bytes.Buffer
165-
banTemplate, _ := configuration.GetHTMLTemplate(config.BanHTMLFilePath)
166-
err = banTemplate.Execute(&buf, nil)
167-
if err != nil {
168-
log.Error("New:banTemplate is bad formatted " + err.Error())
169-
return nil, err
170-
}
171-
banTemplateString = buf.String()
165+
banTemplate, _ = configuration.GetHTMLTemplate(config.BanHTMLFilePath)
172166
}
173167

174168
bouncer := &Bouncer{
@@ -198,7 +192,7 @@ func New(_ context.Context, next http.Handler, config *configuration.Config, nam
198192
defaultDecisionTimeout: config.DefaultDecisionSeconds,
199193
remediationStatusCode: config.RemediationStatusCode,
200194
redisUnreachableBlock: config.RedisCacheUnreachableBlock,
201-
banTemplateString: banTemplateString,
195+
banTemplate: banTemplate,
202196
crowdsecStreamRoute: crowdsecStreamRoute,
203197
crowdsecHeader: crowdsecHeader,
204198
log: log,
@@ -296,13 +290,13 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
296290
remoteIP, err := ip.GetRemoteIP(req, bouncer.serverPoolStrategy, bouncer.forwardedCustomHeader)
297291
if err != nil {
298292
bouncer.log.Error(fmt.Sprintf("ServeHTTP:getRemoteIp ip:%s %s", remoteIP, err.Error()))
299-
handleBanServeHTTP(bouncer, rw, req.Method)
293+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
300294
return
301295
}
302296
isTrusted, err := bouncer.clientPoolStrategy.Checker.Contains(remoteIP)
303297
if err != nil {
304298
bouncer.log.Error(fmt.Sprintf("ServeHTTP:checkerContains ip:%s %s", remoteIP, err.Error()))
305-
handleBanServeHTTP(bouncer, rw, req.Method)
299+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
306300
return
307301
}
308302
// 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) {
313307
}
314308

315309
if bouncer.crowdsecMode == configuration.AppsecMode {
316-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
310+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
317311
return
318312
}
319313

@@ -325,20 +319,20 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
325319
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:Get ip:%s isBanned:false %s", remoteIP, cacheErrString))
326320
if !bouncer.redisUnreachableBlock && cacheErrString == cache.CacheUnreachable {
327321
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s redisUnreachable=true", remoteIP))
328-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
322+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
329323
return
330324
}
331325
if cacheErrString != cache.CacheMiss {
332326
bouncer.log.Error(fmt.Sprintf("ServeHTTP:Get ip:%s %s", remoteIP, cacheErrString))
333-
handleBanServeHTTP(bouncer, rw, req.Method)
327+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
334328
return
335329
}
336330
} else {
337331
bouncer.log.Debug(fmt.Sprintf("ServeHTTP ip:%s cache:hit isBanned:%v", remoteIP, value))
338332
if value == cache.NoBannedValue {
339-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
333+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
340334
} else {
341-
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
335+
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
342336
}
343337
return
344338
}
@@ -347,18 +341,18 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
347341
// Right here if we cannot join the stream we forbid the request to go on.
348342
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
349343
if isCrowdsecStreamHealthy {
350-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
344+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
351345
} else {
352346
bouncer.log.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s updateFailure:%d", remoteIP, updateFailure))
353-
handleBanServeHTTP(bouncer, rw, req.Method)
347+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonTECH)
354348
}
355349
} else {
356350
value, err := handleNoStreamCache(bouncer, remoteIP)
357351
if value == cache.NoBannedValue {
358-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
352+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
359353
} else {
360354
bouncer.log.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:%v %s", remoteIP, value, err.Error()))
361-
handleRemediationServeHTTP(bouncer, remoteIP, value, rw, req)
355+
bouncer.handleRemediationServeHTTP(rw, req, remoteIP, value)
362356
}
363357
}
364358
}
@@ -392,48 +386,47 @@ type Login struct {
392386
}
393387

394388
// To append Headers we need to call rw.WriteHeader after set any header.
395-
func handleBanServeHTTP(bouncer *Bouncer, rw http.ResponseWriter, method string) {
389+
func (bouncer *Bouncer) handleBanServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, reason string) {
396390
atomic.AddInt64(&blockedRequests, 1)
397391

398392
if bouncer.remediationCustomHeader != "" {
399393
rw.Header().Set(bouncer.remediationCustomHeader, "ban")
400394
}
401-
if bouncer.banTemplateString == "" {
395+
if bouncer.banTemplate == nil {
402396
rw.WriteHeader(bouncer.remediationStatusCode)
403397
return
404398
}
405399
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
406400
rw.WriteHeader(bouncer.remediationStatusCode)
407401

408-
if method == http.MethodHead {
402+
if req.Method == http.MethodHead {
409403
return
410404
}
411-
_, err := fmt.Fprint(rw, bouncer.banTemplateString)
405+
err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP})
412406
if err != nil {
413-
// use warn when https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pull/276 is completed
414-
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter: " + err.Error())
407+
bouncer.log.Error("handleBanServeHTTP banTemplateServe " + err.Error())
415408
}
416409
}
417410

418-
func handleRemediationServeHTTP(bouncer *Bouncer, remoteIP, remediation string, rw http.ResponseWriter, req *http.Request) {
411+
func (bouncer *Bouncer) handleRemediationServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP, remediation string) {
419412
bouncer.log.Debug(fmt.Sprintf("handleRemediationServeHTTP ip:%s remediation:%s", remoteIP, remediation))
420413
if bouncer.captchaClient.Valid && remediation == cache.CaptchaValue && req.Method != http.MethodHead {
421414
if bouncer.captchaClient.Check(remoteIP) {
422-
handleNextServeHTTP(bouncer, remoteIP, rw, req)
415+
bouncer.handleNextServeHTTP(rw, req, remoteIP)
423416
return
424417
}
425418
atomic.AddInt64(&blockedRequests, 1) // If we serve a captcha that should count as a dropped request.
426419
bouncer.captchaClient.ServeHTTP(rw, req, remoteIP)
427420
return
428421
}
429-
handleBanServeHTTP(bouncer, rw, req.Method)
422+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonLAPI)
430423
}
431424

432-
func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
425+
func (bouncer *Bouncer) handleNextServeHTTP(rw http.ResponseWriter, req *http.Request, remoteIP string) {
433426
if bouncer.appsecEnabled {
434427
if err := appsecQuery(bouncer, remoteIP, req); err != nil {
435428
bouncer.log.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
436-
handleBanServeHTTP(bouncer, rw, req.Method)
429+
bouncer.handleBanServeHTTP(rw, req, remoteIP, configuration.ReasonAPPSEC)
437430
return
438431
}
439432
}

bouncer_test.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
22

33
import (
44
"context"
5+
htmltemplate "html/template"
56
"net/http"
67
"net/http/httptest"
78
"reflect"
@@ -188,57 +189,60 @@ func Test_crowdsecQuery(t *testing.T) {
188189
}
189190

190191
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
192+
html := "<html>You are banned</html>"
193+
banTemplate, _ := htmltemplate.New("html").Parse(html)
191194
tests := []struct {
192195
name string
193196
method string
194-
banTemplateString string
197+
banTemplate *htmltemplate.Template
195198
expectBodyContent bool
196199
}{
197200
{
198201
name: "GET request should have body with template",
199202
method: http.MethodGet,
200-
banTemplateString: "<html>You are banned</html>",
203+
banTemplate: banTemplate,
201204
expectBodyContent: true,
202205
},
203206
{
204207
name: "HEAD request should NOT have body even with template",
205208
method: http.MethodHead,
206-
banTemplateString: "<html>You are banned</html>",
209+
banTemplate: banTemplate,
207210
expectBodyContent: false,
208211
},
209212
{
210213
name: "POST request should have body with template",
211214
method: http.MethodPost,
212-
banTemplateString: "<html>You are banned</html>",
215+
banTemplate: banTemplate,
213216
expectBodyContent: true,
214217
},
215218
{
216219
name: "PUT request should have body with template",
217220
method: http.MethodPut,
218-
banTemplateString: "<html>You are banned</html>",
221+
banTemplate: banTemplate,
219222
expectBodyContent: true,
220223
},
221224
{
222225
name: "DELETE request should have body with template",
223226
method: http.MethodDelete,
224-
banTemplateString: "<html>You are banned</html>",
227+
banTemplate: banTemplate,
225228
expectBodyContent: true,
226229
},
227230
}
228231

229232
for _, tt := range tests {
230233
t.Run(tt.name, func(t *testing.T) {
231234
bouncer := &Bouncer{
232-
remediationStatusCode: 403,
235+
remediationStatusCode: http.StatusForbidden,
233236
remediationCustomHeader: "X-Test-Remediation",
234-
banTemplateString: tt.banTemplateString,
237+
banTemplate: tt.banTemplate,
235238
}
236239

237240
rw := httptest.NewRecorder()
238-
handleBanServeHTTP(bouncer, rw, tt.method)
241+
req := &http.Request{Method: tt.method}
242+
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
239243

240244
// Check status code
241-
if rw.Code != 403 {
245+
if rw.Code != http.StatusForbidden {
242246
t.Errorf("Expected status code 403, got %d", rw.Code)
243247
}
244248

@@ -258,12 +262,13 @@ func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
258262
}
259263

260264
// If we expect body content, verify it matches template
261-
if tt.expectBodyContent && body != tt.banTemplateString {
262-
t.Errorf("Expected body %q, got %q", tt.banTemplateString, body)
265+
if tt.expectBodyContent && body != html {
266+
t.Errorf("Expected body %q, got %q", html, body)
263267
}
264268
})
265269
}
266270
}
271+
267272
func TestCaptchaMethodBasedLogic(t *testing.T) {
268273
tests := []struct {
269274
name string

examples/custom-ban-page/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,13 @@ To play the demo environment run:
4545
```bash
4646
make run_custom_ban_page
4747
```
48+
49+
## Another thing to note
50+
In the html of the ban page, you can use:
51+
- {{ .ClientIP }} to display the IP used to ban the request.
52+
- {{ .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.
53+
```
54+
<script>var remediation = "{{ .RemediationReason }}"</script>
55+
<script>var clientIp = "{{ .ClientIP }}"</script>
56+
```
57+
With the above tweak and some other js, you can customize your ban page on runtime.

examples/custom-captcha/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ wicketkeeper:
4141
ports:
4242
- "8080:8080"
4343
environment:
44-
- ROOT_URL=http://localhost:8080
4544
- LISTEN_PORT=8080
4645
- REDIS_ADDR=redis:6379
4746
- DIFFICULTY=4
@@ -55,6 +54,10 @@ redis:
5554
image: redis/redis-stack-server:latest
5655
```
5756
57+
```html
58+
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
59+
```
60+
5861
## Exemple navigation
5962

6063
We can try to query normally the whoami server:

examples/custom-captcha/captcha.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@
294294
<h1 class="text-2xl lg:text-3xl xl:text-4xl">CrowdSec Captcha</h1>
295295
</div>
296296
<form action="" method="POST" class="flex flex-col items-center space-y-1" id="captcha-form">
297-
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback">
297+
<div id="captcha" class="{{ .FrontendKey }}" data-sitekey="{{ .SiteKey }}" data-callback="captchaCallback" data-challenge-url="http://captcha.localhost:8000/v0/challenge">
298298
</div>
299299
</form>
300300
<div class="flex justify-center flex-wrap">

examples/custom-captcha/docker-compose.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ services:
8282
image: ghcr.io/a-ve/wicketkeeper:latest
8383
container_name: "wicketkeeper"
8484
environment:
85-
- ROOT_URL=http://captcha.localhost:8000
8685
- LISTEN_PORT=8080
8786
- REDIS_ADDR=redis:6379
8887
- DIFFICULTY=4

pkg/configuration/configuration.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const (
3131
LogDEBUG = "DEBUG"
3232
LogINFO = "INFO"
3333
LogERROR = "ERROR"
34+
ReasonTECH = "TECHNICAL_ISSUE"
35+
ReasonLAPI = "LAPI"
36+
ReasonAPPSEC = "APPSEC"
3437
HcaptchaProvider = "hcaptcha"
3538
RecaptchaProvider = "recaptcha"
3639
TurnstileProvider = "turnstile"

0 commit comments

Comments
 (0)