mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-07-19 18:52:20 +02:00
* 🐛 fix appsec silently 403-ing gRPC streams with unreadable body A bidirectional gRPC stream is an HTTP/2 request with no Content-Length whose body never reaches EOF. Since #321 removed the ContentLength guard, appsecQuery buffered it with io.ReadAll, which blocked until the request timed out and was turned into a 403 (issue #323). The backend was never reached (OriginStatus:0). Mirror the reference lua-cs-bouncer behaviour: detect an unreadable body (ProtoMajor >= 2 && ContentLength < 0) and, instead of buffering it, forward the request to Appsec with headers only. Add a new CrowdsecAppsecDropUnreadableBody option (default false) that mirrors the reference APPSEC_DROP_UNREADABLE_BODY: when true, such requests are blocked outright instead of forwarded without their body. Readable HTTP/1.1 bodies are still buffered and inspected, so the bypass closed by #321 stays closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🚨 appsec: satisfy linters (gocritic ifElseChain, misspell) Rewrite the body-handling if/else chain in appsecQuery as a switch (gocritic) and use US spelling "behavior" (misspell). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🔇 appsec: drop redundant unreadable-body debug log Address review on #332: the caller (handleNextServeHTTP) already logs the returned error with the request IP, so the inner Debug line duplicated it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🍱 increase gocyclo * 🍱 fix lint --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: maxlerebourg <maxlerebourg@gmail.com>
516 lines
15 KiB
Go
516 lines
15 KiB
Go
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"testing"
|
|
"text/template"
|
|
"time"
|
|
|
|
cache "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/cache"
|
|
configuration "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/configuration"
|
|
ip "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/ip"
|
|
logger "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin/pkg/logger"
|
|
)
|
|
|
|
func TestServeHTTP(t *testing.T) {
|
|
cfg := CreateConfig()
|
|
cfg.CrowdsecLapiKey = "test"
|
|
|
|
ctx := context.Background()
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})
|
|
|
|
handler, err := New(ctx, next, cfg, "demo-plugin")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
handler.ServeHTTP(recorder, req)
|
|
}
|
|
|
|
func TestNew(t *testing.T) {
|
|
type args struct {
|
|
ctx context.Context //nolint:containedctx
|
|
next http.Handler
|
|
config *configuration.Config
|
|
name string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want http.Handler
|
|
wantErr bool
|
|
}{
|
|
// TODO: Add test cases.
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := New(tt.args.ctx, tt.args.next, tt.args.config, tt.args.name)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("New() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBouncer_ServeHTTP(t *testing.T) {
|
|
type fields struct {
|
|
next http.Handler
|
|
name string
|
|
template *template.Template
|
|
enabled bool
|
|
crowdsecScheme string
|
|
crowdsecHost string
|
|
crowdsecKey string
|
|
crowdsecMode string
|
|
updateInterval int64
|
|
defaultDecisionTimeout int64
|
|
forwardedCustomHeader string
|
|
clientPoolStrategy *ip.PoolStrategy
|
|
serverPoolStrategy *ip.PoolStrategy
|
|
httpClient *http.Client
|
|
cacheClient *cache.Client
|
|
}
|
|
type args struct {
|
|
rw http.ResponseWriter
|
|
req *http.Request
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
fields fields
|
|
args args
|
|
}{
|
|
// TODO: Add test cases.
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(_ *testing.T) {
|
|
bouncer := &Bouncer{
|
|
next: tt.fields.next,
|
|
name: tt.fields.name,
|
|
template: tt.fields.template,
|
|
enabled: tt.fields.enabled,
|
|
crowdsecScheme: tt.fields.crowdsecScheme,
|
|
crowdsecHost: tt.fields.crowdsecHost,
|
|
crowdsecKey: tt.fields.crowdsecKey,
|
|
crowdsecMode: tt.fields.crowdsecMode,
|
|
updateInterval: tt.fields.updateInterval,
|
|
defaultDecisionTimeout: tt.fields.defaultDecisionTimeout,
|
|
forwardedCustomHeader: tt.fields.forwardedCustomHeader,
|
|
clientPoolStrategy: tt.fields.clientPoolStrategy,
|
|
serverPoolStrategy: tt.fields.serverPoolStrategy,
|
|
httpClient: tt.fields.httpClient,
|
|
cacheClient: tt.fields.cacheClient,
|
|
}
|
|
bouncer.ServeHTTP(tt.args.rw, tt.args.req)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_handleNoStreamCache(t *testing.T) {
|
|
type args struct {
|
|
bouncer *Bouncer
|
|
remoteIP string
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantErr bool
|
|
}{
|
|
// TODO: Add test cases.
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if _, err := handleNoStreamCache(tt.args.bouncer, tt.args.remoteIP); (err != nil) != tt.wantErr {
|
|
t.Errorf("handleNoStreamCache() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_handleStreamCache(t *testing.T) {
|
|
type args struct {
|
|
bouncer *Bouncer
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
wantErr bool
|
|
}{
|
|
// TODO: Add test cases.
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := handleStreamCache(tt.args.bouncer)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("handleStreamCache() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_crowdsecQuery(t *testing.T) {
|
|
type args struct {
|
|
bouncer *Bouncer
|
|
stringURL string
|
|
data []byte
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
args args
|
|
want []byte
|
|
wantErr bool
|
|
}{
|
|
// TODO: Add test cases.
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := crowdsecQuery(tt.args.bouncer, tt.args.stringURL, tt.args.data)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("crowdsecQuery() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !reflect.DeepEqual(got, tt.want) {
|
|
t.Errorf("crowdsecQuery() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleBanServeHTTPWithDifferentMethods(t *testing.T) {
|
|
html := "<html>You are banned</html>"
|
|
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
banTemplate *template.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,
|
|
banTemplateContentType: "text/html; charset=utf-8",
|
|
}
|
|
|
|
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 TestHandleBanServeHTTPContentType(t *testing.T) {
|
|
html := "<html>You are banned</html>"
|
|
banTemplate, _ := template.New("html").Delims("{{", "}}").Parse(html)
|
|
tests := []struct {
|
|
name string
|
|
banTemplate *template.Template
|
|
banTemplateContentType string
|
|
}{
|
|
{
|
|
name: "Default HTML content type",
|
|
banTemplate: banTemplate,
|
|
banTemplateContentType: "text/html; charset=utf-8",
|
|
},
|
|
{
|
|
name: "Custom JSON content type",
|
|
banTemplate: banTemplate,
|
|
banTemplateContentType: "application/json",
|
|
},
|
|
{
|
|
name: "Content type set even when banTemplate is nil",
|
|
banTemplate: nil,
|
|
banTemplateContentType: "application/json",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
bouncer := &Bouncer{
|
|
remediationStatusCode: http.StatusForbidden,
|
|
banTemplate: tt.banTemplate,
|
|
banTemplateContentType: tt.banTemplateContentType,
|
|
}
|
|
|
|
rw := httptest.NewRecorder()
|
|
req := &http.Request{Method: http.MethodGet}
|
|
bouncer.handleBanServeHTTP(rw, req, "0.0.0.0", "TEST")
|
|
|
|
if got := rw.Header().Get("Content-Type"); got != tt.banTemplateContentType {
|
|
t.Errorf("Expected Content-Type %q, got %q", tt.banTemplateContentType, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// blockingBody simulates a request body that never reaches EOF, like a
|
|
// bidirectional gRPC stream that keeps its body open for the whole life of
|
|
// the connection. Reading from it blocks until the test is done.
|
|
type blockingBody struct {
|
|
done <-chan struct{}
|
|
}
|
|
|
|
func (b blockingBody) Read(_ []byte) (int, error) {
|
|
<-b.done
|
|
return 0, io.EOF
|
|
}
|
|
|
|
func (blockingBody) Close() error { return nil }
|
|
|
|
func Test_isBodyUnreadable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
protoMajor int
|
|
contentLength int64
|
|
hasBody bool
|
|
want bool
|
|
}{
|
|
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, hasBody: true, want: true},
|
|
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, hasBody: true, want: true},
|
|
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, hasBody: true, want: false},
|
|
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, hasBody: true, want: false},
|
|
{name: "http2 without body", protoMajor: 2, contentLength: -1, hasBody: false, want: false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodPost, "http://localhost", nil)
|
|
req.ProtoMajor = tt.protoMajor
|
|
req.ContentLength = tt.contentLength
|
|
if tt.hasBody {
|
|
req.Body = http.NoBody
|
|
} else {
|
|
req.Body = nil
|
|
}
|
|
if got := isBodyUnreadable(req); got != tt.want {
|
|
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newStreamingRequest builds an HTTP/2 request whose body never reaches EOF,
|
|
// like a bidirectional gRPC stream (issue #323).
|
|
func newStreamingRequest(done <-chan struct{}) *http.Request {
|
|
req, _ := http.NewRequest(http.MethodPost, "http://localhost/signalexchange.SignalExchange/ConnectStream", blockingBody{done: done})
|
|
req.Header.Set("Content-Type", "application/grpc")
|
|
req.ProtoMajor = 2
|
|
req.ContentLength = -1
|
|
return req
|
|
}
|
|
|
|
// Test_appsecQuery_streamingDoesNotBlock is a regression test for issue #323:
|
|
// a gRPC streaming request whose body never reaches EOF must not be buffered
|
|
// (io.ReadAll would block until timeout and wrongly produce a 403). The appsec
|
|
// query must complete promptly, inspecting headers only.
|
|
func Test_appsecQuery_streamingDoesNotBlock(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, _ := url.Parse(appsecServer.URL)
|
|
bouncer := &Bouncer{
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
appsecBodyLimit: 10485760,
|
|
appsecUnreachableBlock: true,
|
|
appsecFailureBlock: true,
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
|
|
finished := make(chan error, 1)
|
|
go func() {
|
|
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
|
}()
|
|
|
|
select {
|
|
case err := <-finished:
|
|
if err != nil {
|
|
t.Errorf("appsecQuery() on streaming request returned error: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
|
|
}
|
|
}
|
|
|
|
// Test_appsecQuery_dropUnreadableBody verifies that, when configured to do so,
|
|
// a request with an unreadable body is dropped (blocked) instead of forwarded
|
|
// without its body, mirroring the reference APPSEC_DROP_UNREADABLE_BODY option.
|
|
func Test_appsecQuery_dropUnreadableBody(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, _ := url.Parse(appsecServer.URL)
|
|
bouncer := &Bouncer{
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
appsecBodyLimit: 10485760,
|
|
appsecUnreadableBodyBlock: true,
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
|
|
finished := make(chan error, 1)
|
|
go func() {
|
|
finished <- appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
|
}()
|
|
|
|
select {
|
|
case err := <-finished:
|
|
if err == nil {
|
|
t.Error("appsecQuery() expected an error to block the request, got nil")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("appsecQuery() blocked on a streaming request body (issue #323 regression)")
|
|
}
|
|
}
|