Compare commits

...
6 Commits
Author SHA1 Message Date
maxlerebourg 4ab4f3f183 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
2025-11-15 10:42:14 +01:00
usr3 2aac531ea7 📝 docs: fix captcha profile reference doc link (#289)
Fixed broken link referencing the captcha profile in Crowdsec documentation
2025-10-30 14:20:02 +01:00
Patrick Evans e8e60c958f 📝 docs: fix wicketkeeper name references (#285) 2025-10-19 12:53:07 +02:00
Davidandmaxlerebourg a2d3708bc3 Do not cache decisions if DefaultDecisionSeconds=0 (#280)
* xx

* Remove outdated comment on decision caching

Remove comment about decision caching based on timeout.

---------

Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
2025-10-18 11:58:00 +02:00
DavidandMax Lerebourg 65a2f79fb3 🐛Not write response body for HEAD requests (#277)
* Fixes

* XX

* Fix

* 🍱 Lint

* 🍱 remove useless comments

---------

Co-authored-by: Max Lerebourg <maxlerebourg@gmail.com>
2025-10-06 11:19:19 +02:00
dependabot[bot] a2ecc95dc9 ⬆️ Bump actions/setup-go from 5 to 6 (#268)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 16:23:10 +02:00
9 changed files with 202 additions and 43 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ 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 }}
+31 -33
View File
@@ -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)
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 +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)
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)
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,43 +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) {
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 req.Method == http.MethodHead {
return
}
err := bouncer.banTemplate.Execute(rw, map[string]string{"RemediationReason": reason, "ClientIP": remoteIP})
if err != nil {
bouncer.log.Error("handleBanServeHTTP could not write template to ResponseWriter")
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 {
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 +522,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
+146
View File
@@ -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)
}
})
}
}
+2 -2
View File
@@ -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
...
@@ -158,4 +158,4 @@ TODO
- Hcatpcha
TODO
TODO
+10
View File
@@ -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.
```
<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.
+5 -2
View File
@@ -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:
+1 -1
View File
@@ -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">
+3 -4
View File
@@ -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
+3
View File
@@ -31,6 +31,9 @@ const (
LogDEBUG = "DEBUG"
LogINFO = "INFO"
LogERROR = "ERROR"
ReasonTECH = "TECHNICAL_ISSUE"
ReasonLAPI = "LAPI"
ReasonAPPSEC = "APPSEC"
HcaptchaProvider = "hcaptcha"
RecaptchaProvider = "recaptcha"
TurnstileProvider = "turnstile"