Skip to content

Commit 63fb65b

Browse files
authored
Feature/allow setting ban status code (#94)
* chore: allow setting ban status code * chore: tests probably not needed, fix unit tests * chore: actually return the set status value * chore: return 500 on error
1 parent e2e5b6e commit 63fb65b

File tree

7 files changed

+104
-33
lines changed

7 files changed

+104
-33
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The bouncer integrates with Envoy Proxy as an external authorization service, si
3131
- If enabled and no blocking decision exists then the request is forwarded to Crowdsec AppSec for inspection
3232
4. **Decision Enforcement**
3333
- **Allow** - Request proceeds to backend
34-
- **Ban** - Returns 403 with ban page
34+
- **Ban** - Returns configurable status code (defaults to 403) with ban page
3535
- **Captcha** - Creates session and redirects to challenge
3636

3737
### Ban Flow

bouncer/bouncer.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -446,16 +446,16 @@ func (b *Bouncer) Check(ctx context.Context, req *auth.CheckRequest) CheckedRequ
446446
return captchaResult
447447
case "deny", "ban":
448448
if bouncerResult.HTTPStatus == 0 {
449-
bouncerResult.HTTPStatus = http.StatusForbidden
449+
bouncerResult.HTTPStatus = b.getBanStatusCode()
450450
}
451451
b.recordFinalMetric(bouncerResult)
452452
return bouncerResult
453453
case "error":
454-
finalResult := NewCheckedRequest(parsed.RealIP, "deny", bouncerResult.Reason, http.StatusForbidden, nil, "", parsed, nil)
454+
finalResult := NewCheckedRequest(parsed.RealIP, "error", bouncerResult.Reason, http.StatusInternalServerError, nil, "", parsed, nil)
455455
b.recordFinalMetric(finalResult)
456456
return finalResult
457457
default:
458-
finalResult := NewCheckedRequest(parsed.RealIP, "deny", "unknown decision cache action", http.StatusForbidden, nil, "", parsed, nil)
458+
finalResult := NewCheckedRequest(parsed.RealIP, "deny", "unknown decision cache action", b.getBanStatusCode(), nil, "", parsed, nil)
459459
b.recordFinalMetric(finalResult)
460460
return finalResult
461461
}
@@ -515,14 +515,21 @@ func (b *Bouncer) checkDecisionCache(ctx context.Context, parsed *ParsedRequest)
515515
if decision.Scenario != nil && *decision.Scenario != "" {
516516
reason = *decision.Scenario
517517
}
518-
return NewCheckedRequest(parsed.RealIP, "ban", reason, http.StatusForbidden, decision, "", parsed, nil)
518+
return NewCheckedRequest(parsed.RealIP, "ban", reason, b.getBanStatusCode(), decision, "", parsed, nil)
519519
case "captcha":
520520
return NewCheckedRequest(parsed.RealIP, "captcha", "crowdsec captcha", http.StatusFound, decision, "", parsed, nil)
521521
default:
522522
return NewCheckedRequest(parsed.RealIP, "allow", "decision allows", http.StatusOK, nil, "", parsed, nil)
523523
}
524524
}
525525

526+
func (b *Bouncer) getBanStatusCode() int {
527+
if b.config.Bouncer.BanStatusCode != 0 {
528+
return b.config.Bouncer.BanStatusCode
529+
}
530+
return http.StatusForbidden
531+
}
532+
526533
func (b *Bouncer) checkCaptcha(ctx context.Context, parsed *ParsedRequest, decision *models.Decision) CheckedRequest {
527534
logger := logger.FromContext(ctx)
528535
if b.CaptchaService == nil || !b.CaptchaService.IsEnabled() {
@@ -568,7 +575,7 @@ func (b *Bouncer) checkWAF(ctx context.Context, parsed *ParsedRequest) CheckedRe
568575
}
569576

570577
if wafResult.Action != "allow" {
571-
return NewCheckedRequest(parsed.RealIP, wafResult.Action, "ban", http.StatusForbidden, nil, "", parsed, nil)
578+
return NewCheckedRequest(parsed.RealIP, wafResult.Action, "ban", b.getBanStatusCode(), nil, "", parsed, nil)
572579
}
573580

574581
return NewCheckedRequest(parsed.RealIP, wafResult.Action, "ok", http.StatusOK, nil, "", parsed, nil)

bouncer/bouncer_test.go

Lines changed: 65 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/kdwils/envoy-proxy-bouncer/bouncer/components"
1616
remediationmocks "github.com/kdwils/envoy-proxy-bouncer/bouncer/mocks"
1717
"github.com/kdwils/envoy-proxy-bouncer/cache"
18+
"github.com/kdwils/envoy-proxy-bouncer/config"
1819
"github.com/stretchr/testify/require"
1920
"go.uber.org/mock/gomock"
2021
)
@@ -577,7 +578,16 @@ func TestBouncer_Check(t *testing.T) {
577578

578579
mb := remediationmocks.NewMockDecisionCache(ctrl)
579580
mw := remediationmocks.NewMockWAF(ctrl)
580-
r := Bouncer{DecisionCache: mb, WAF: mw, metrics: cache.New[RemediationMetrics]()}
581+
r := Bouncer{
582+
DecisionCache: mb,
583+
WAF: mw,
584+
metrics: cache.New[RemediationMetrics](),
585+
config: config.Config{
586+
Bouncer: config.Bouncer{
587+
BanStatusCode: 403,
588+
},
589+
},
590+
}
581591

582592
req := mkReq("1.2.3.4", "http", "example.com", "/foo", "GET", "HTTP/1.1", "")
583593

@@ -586,12 +596,12 @@ func TestBouncer_Check(t *testing.T) {
586596

587597
got := r.Check(context.Background(), req)
588598
want := CheckedRequest{
589-
IP: "1.2.3.4",
590-
Action: "ban",
591-
Reason: "crowdsec ban",
592-
HTTPStatus: 403,
593-
RedirectURL: "",
594-
Decision: &models.Decision{Type: ptr("ban")},
599+
IP: "1.2.3.4",
600+
Action: "ban",
601+
Reason: "crowdsec ban",
602+
HTTPStatus: 403,
603+
RedirectURL: "",
604+
Decision: &models.Decision{Type: ptr("ban")},
595605
ParsedRequest: &ParsedRequest{
596606
IP: "1.2.3.4",
597607
RealIP: "1.2.3.4",
@@ -624,20 +634,29 @@ func TestBouncer_Check(t *testing.T) {
624634

625635
mb := remediationmocks.NewMockDecisionCache(ctrl)
626636
mw := remediationmocks.NewMockWAF(ctrl)
627-
r := Bouncer{DecisionCache: mb, WAF: mw, metrics: cache.New[RemediationMetrics]()}
637+
r := Bouncer{
638+
DecisionCache: mb,
639+
WAF: mw,
640+
metrics: cache.New[RemediationMetrics](),
641+
config: config.Config{
642+
Bouncer: config.Bouncer{
643+
BanStatusCode: 403,
644+
},
645+
},
646+
}
628647

629648
req := mkReq("2.2.2.2", "http", "example.com", "/foo", "GET", "HTTP/1.1", "")
630649

631650
mb.EXPECT().GetDecision(gomock.Any(), "2.2.2.2").Return(&models.Decision{Type: ptr("ban"), Scenario: ptr("crowdsecurity/test"), Origin: ptr("CAPI"), Duration: ptr("1h"), Scope: ptr("Ip"), Value: ptr("2.2.2.2")}, nil)
632651

633652
got := r.Check(context.Background(), req)
634653
want := CheckedRequest{
635-
IP: "2.2.2.2",
636-
Action: "ban",
637-
Reason: "crowdsecurity/test",
638-
HTTPStatus: 403,
639-
RedirectURL: "",
640-
Decision: &models.Decision{Type: ptr("ban"), Scenario: ptr("crowdsecurity/test"), Origin: ptr("CAPI"), Duration: ptr("1h"), Scope: ptr("Ip"), Value: ptr("2.2.2.2")},
654+
IP: "2.2.2.2",
655+
Action: "ban",
656+
Reason: "crowdsecurity/test",
657+
HTTPStatus: 403,
658+
RedirectURL: "",
659+
Decision: &models.Decision{Type: ptr("ban"), Scenario: ptr("crowdsecurity/test"), Origin: ptr("CAPI"), Duration: ptr("1h"), Scope: ptr("Ip"), Value: ptr("2.2.2.2")},
641660
ParsedRequest: &ParsedRequest{
642661
IP: "2.2.2.2",
643662
RealIP: "2.2.2.2",
@@ -660,7 +679,8 @@ func TestBouncer_Check(t *testing.T) {
660679

661680
mb := remediationmocks.NewMockDecisionCache(ctrl)
662681
mw := remediationmocks.NewMockWAF(ctrl)
663-
r := Bouncer{DecisionCache: mb, WAF: mw, metrics: cache.New[RemediationMetrics]()}
682+
mc := remediationmocks.NewMockCaptchaService(ctrl)
683+
r := Bouncer{DecisionCache: mb, WAF: mw, CaptchaService: mc, metrics: cache.New[RemediationMetrics]()}
664684

665685
req := mkReq("5.6.7.8", "http", "example.com", "/foo", "GET", "HTTP/1.1", "")
666686

@@ -669,9 +689,9 @@ func TestBouncer_Check(t *testing.T) {
669689
got := r.Check(context.Background(), req)
670690
want := CheckedRequest{
671691
IP: "5.6.7.8",
672-
Action: "deny",
692+
Action: "error",
673693
Reason: "decision cache error",
674-
HTTPStatus: 403,
694+
HTTPStatus: 500,
675695
RedirectURL: "",
676696
Decision: nil,
677697
ParsedRequest: &ParsedRequest{
@@ -712,7 +732,16 @@ func TestBouncer_Check(t *testing.T) {
712732

713733
mb := remediationmocks.NewMockDecisionCache(ctrl)
714734
mw := remediationmocks.NewMockWAF(ctrl)
715-
r := Bouncer{DecisionCache: mb, WAF: mw, metrics: cache.New[RemediationMetrics]()}
735+
r := Bouncer{
736+
DecisionCache: mb,
737+
WAF: mw,
738+
metrics: cache.New[RemediationMetrics](),
739+
config: config.Config{
740+
Bouncer: config.Bouncer{
741+
BanStatusCode: 403,
742+
},
743+
},
744+
}
716745

717746
req := mkReq("9.9.9.9", "https", "host", "/bar", "POST", "HTTP/2", "abc")
718747

@@ -721,11 +750,11 @@ func TestBouncer_Check(t *testing.T) {
721750

722751
got := r.Check(context.Background(), req)
723752
want := CheckedRequest{
724-
IP: "9.9.9.9",
725-
Action: "ban",
726-
Reason: "ban",
727-
HTTPStatus: 403,
728-
RedirectURL: "",
753+
IP: "9.9.9.9",
754+
Action: "ban",
755+
Reason: "ban",
756+
HTTPStatus: 403,
757+
RedirectURL: "",
729758
ParsedRequest: &ParsedRequest{
730759
IP: "9.9.9.9",
731760
RealIP: "9.9.9.9",
@@ -1069,7 +1098,17 @@ func TestBouncer_Check_AllScenarios(t *testing.T) {
10691098
mb := remediationmocks.NewMockDecisionCache(ctrl)
10701099
mw := remediationmocks.NewMockWAF(ctrl)
10711100
mc := remediationmocks.NewMockCaptchaService(ctrl)
1072-
r := Bouncer{DecisionCache: mb, WAF: mw, CaptchaService: mc, metrics: cache.New[RemediationMetrics]()}
1101+
r := Bouncer{
1102+
DecisionCache: mb,
1103+
WAF: mw,
1104+
CaptchaService: mc,
1105+
metrics: cache.New[RemediationMetrics](),
1106+
config: config.Config{
1107+
Bouncer: config.Bouncer{
1108+
BanStatusCode: 403,
1109+
},
1110+
},
1111+
}
10731112

10741113
req := mkReq("3.3.3.3", "https", "example.com", "/test", "GET", "HTTP/1.1", "")
10751114

@@ -1078,9 +1117,9 @@ func TestBouncer_Check_AllScenarios(t *testing.T) {
10781117
got := r.Check(context.Background(), req)
10791118
want := CheckedRequest{
10801119
IP: "3.3.3.3",
1081-
Action: "deny",
1120+
Action: "error",
10821121
Reason: "decision cache error",
1083-
HTTPStatus: 403,
1122+
HTTPStatus: 500,
10841123
RedirectURL: "",
10851124
Decision: nil,
10861125
ParsedRequest: &ParsedRequest{

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ func initConfig() {
5353
viper.SetDefault("bouncer.metrics", false)
5454
viper.SetDefault("bouncer.tickerInterval", "10s")
5555
viper.SetDefault("bouncer.metricsInterval", "10m")
56+
viper.SetDefault("bouncer.banStatusCode", 403)
5657

5758
viper.SetDefault("waf.enabled", false)
5859
viper.SetDefault("waf.apiKey", "")

config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type Bouncer struct {
4040
MetricsInterval time.Duration `yaml:"metricsInterval" json:"metricsInterval"`
4141
ApiKey string `yaml:"apiKey" json:"apiKey"`
4242
LAPIURL string `yaml:"lapiUrl" json:"lapiUrl"`
43+
BanStatusCode int `yaml:"banStatusCode" json:"banStatusCode"`
4344
}
4445

4546
type WAF struct {

docs/CONFIGURATION.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ bouncer:
3131
metrics: false
3232
tickerInterval: "10s" # How often to fetch decisions from LAPI
3333
metricsInterval: "10m" # How often to report metrics to LAPI
34+
banStatusCode: 403 # HTTP status code for ban responses
3435
lapiUrl: "http://crowdsec:8080"
3536
apiKey: "<lapi-key>"
3637

@@ -79,6 +80,7 @@ export ENVOY_BOUNCER_BOUNCER_LAPIURL=http://crowdsec:8080
7980
export ENVOY_BOUNCER_BOUNCER_TICKERINTERVAL=10s
8081
export ENVOY_BOUNCER_BOUNCER_METRICSINTERVAL=10m
8182
export ENVOY_BOUNCER_BOUNCER_METRICS=false
83+
export ENVOY_BOUNCER_BOUNCER_BANSTATUSCODE=403
8284

8385
# Trusted proxies (comma-separated) - no defaults
8486
export ENVOY_BOUNCER_TRUSTEDPROXIES=192.168.0.1,10.0.0.0/8
@@ -147,6 +149,7 @@ Controls CrowdSec bouncer integration.
147149
| `metrics` | bool | `false` | No | Enable metrics reporting to CrowdSec |
148150
| `tickerInterval` | duration | `"10s"` | No | Interval to fetch decisions from LAPI |
149151
| `metricsInterval` | duration | `"10m"` | No | Interval to report metrics to LAPI |
152+
| `banStatusCode` | int | `403` | No | HTTP status code for ban responses |
150153

151154
**Note**: Generate API key with `cscli bouncers add <name>` on your CrowdSec instance.
152155

@@ -162,6 +165,22 @@ bouncer:
162165

163166
Metrics can be viewed using `cscli metrics` on your CrowdSec instance.
164167

168+
#### Custom Ban Status Codes
169+
170+
By default, the bouncer returns HTTP 403 (Forbidden) for banned IPs. You can customize this to avoid feedback loops when CrowdSec processes Envoy logs:
171+
172+
```yaml
173+
bouncer:
174+
banStatusCode: 418 # Use 418 "I'm a teapot" to distinguish from legitimate 403s
175+
```
176+
177+
This is useful when CrowdSec analyzes Envoy access logs, as it can ignore ban responses (418) while still processing genuine errors (403).
178+
179+
**Common alternatives**:
180+
- `418` - "I'm a teapot" (RFC 2324)
181+
- `429` - "Too Many Requests"
182+
- `444` - Nginx-style "Connection closed without response"
183+
165184
### WAF
166185

167186
Controls CrowdSec AppSec (WAF) integration.

server/server.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ func (s *Server) Check(ctx context.Context, req *auth.CheckRequest) (*auth.Check
304304
return getRedirectResponse(result.RedirectURL), nil
305305
case "deny", "ban":
306306
body, headers := s.renderDeniedResponse(result)
307-
return getDeniedResponse(envoy_type.StatusCode_Forbidden, body, headers), nil
307+
return getDeniedResponse(httpStatusToEnvoyStatus(result.HTTPStatus), body, headers), nil
308308
case "error":
309309
return getDeniedResponse(envoy_type.StatusCode_InternalServerError, result.Reason, map[string]string{"Content-Type": s.config.Templates.DeniedTemplateHeaders}), nil
310310
default:
@@ -385,6 +385,10 @@ func buildHeaderValues(headers map[string]string) []*envoy_core.HeaderValueOptio
385385
return values
386386
}
387387

388+
func httpStatusToEnvoyStatus(httpStatus int) envoy_type.StatusCode {
389+
return envoy_type.StatusCode(httpStatus)
390+
}
391+
388392
func getAllowedResponse() *auth.CheckResponse {
389393
return &auth.CheckResponse{
390394
Status: &status.Status{

0 commit comments

Comments
 (0)