mirror of
https://github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin.git
synced 2026-10-17 23:19:37 +02:00
* 🐛 ban LAPI decisions of an unknown type, such as challenge In stream mode a decision whose type is neither ban nor captcha was cached as an empty value, which reads back as a cache miss, so the request was allowed. The challenge protocol says a challenge decision from the LAPI or a blocklist carries nothing to serve and must fail closed to ban, and CrowdSec's own bouncer falls back to ban for any type it does not support. Unknown types are now cached as a ban in both modes. Live mode already banned them, but it never cached them, so it queried the LAPI again on every request. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 🐛 log unexpected AppSec statuses at INFO A 401 or any other unexpected AppSec status was turned into a ban and only logged at DEBUG, in handleNextServeHTTP. AppSec answers 401 when it rejects the bouncer key, and also when it cannot reach the LAPI to validate a key it has not cached yet. With the default INFO logs, every visitor was then banned with no trace in the plugin logs. The status is now logged at INFO, as a 500 already is. The behaviour does not change, and the README now states it: such a status always blocks, whatever crowdsecAppsecFailureBlock says, and a 502, 503 or 504 counts as unreachable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 🐛 serve the captcha when AppSec answers with a captcha action Any AppSec action other than allow or challenge was turned into a ban, so a captcha remediation from AppSec (set by a rule or a hook) banned the visitor. The challenge protocol says to apply the action AppSec returns, and CrowdSec's own bouncer serves the captcha. A captcha action now uses the configured captcha the same way a LAPI captcha decision does: an IP that already solved it goes through, and other requests get the captcha page. The form posts back to the same URL, so it is validated when AppSec answers that post with a captcha again. Without a provider, or for a HEAD request, it falls back to ban as before. The decision handling moves to its own method so that it does not loop back into the AppSec query. In crowdsecMode appsec, New returned before setting up the cache and the captcha client, so the captcha could never be served there. It now returns after them. Without captchaProvider nothing changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 🐛 never forward /crowdsec-internal/challenge/* to the backend The plugin relied on AppSec to answer the internal challenge paths. When AppSec did not, the request went on to the backend: AppSec allows the custom script path when no custom script is configured, and every path passes when the plugin fails open because AppSec is unreachable or failing. The challenge protocol requires the remediation component to terminate these paths and never pass them to the origin. With AppSec enabled, a request under that prefix that AppSec did not answer now gets a 404. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 🐛 count a challenge once in the blocked requests, not its assets Every challenge response counted as a dropped request in the metrics pushed to the LAPI: the page, the fingerprint and proof-of-work scripts, and the proof submission, even a successful one. A visitor who passed the challenge showed up as four blocked requests. Responses on /crowdsec-internal/challenge/* are part of a challenge that was already counted, so only the challenge page counts now. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * 📝 document that bot detection needs the challenge submission body The challenge proof is posted to /crowdsec-internal/challenge/submit, and AppSec only accepts it if it receives the whole form. With crowdsecAppsecBodyLimit set to 0 the plugin sends no body, and with a smaller limit the form is cut short. In both cases the proof is rejected and the visitor is challenged forever, with nothing in the logs to explain it. The default of 10MB is enough. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1123 lines
35 KiB
Go
1123 lines
35 KiB
Go
package crowdsec_bouncer_traefik_plugin //nolint:revive,stylecheck
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"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) {
|
|
newBouncer := func(mode string, updateMaxFailure int64) *Bouncer {
|
|
log := logger.New("ERROR", "")
|
|
cacheClient := &cache.Client{}
|
|
cacheClient.New(log, false, "", nil, "", "")
|
|
return &Bouncer{
|
|
crowdsecScheme: "http",
|
|
crowdsecHost: "127.0.0.1:1",
|
|
crowdsecPath: "/",
|
|
crowdsecMode: mode,
|
|
updateMaxFailure: updateMaxFailure,
|
|
defaultDecisionTimeout: 60,
|
|
httpClient: &http.Client{Timeout: time.Second},
|
|
cacheClient: cacheClient,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mode string
|
|
updateMaxFailure int64
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "live mode blocks by default when crowdsec is unreachable",
|
|
mode: configuration.LiveMode,
|
|
updateMaxFailure: 0,
|
|
want: cache.BannedValue,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "live mode blocks for any positive updateMaxFailure",
|
|
mode: configuration.LiveMode,
|
|
updateMaxFailure: 3,
|
|
want: cache.BannedValue,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "live mode lets the request through when updateMaxFailure is -1",
|
|
mode: configuration.LiveMode,
|
|
updateMaxFailure: -1,
|
|
want: cache.NoBannedValue,
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "none mode lets the request through when updateMaxFailure is -1",
|
|
mode: configuration.NoneMode,
|
|
updateMaxFailure: -1,
|
|
want: cache.NoBannedValue,
|
|
wantErr: true,
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got, err := handleNoStreamCache(newBouncer(tt.mode, tt.updateMaxFailure), "1.2.3.4")
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("handleNoStreamCache() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
if got != tt.want {
|
|
t.Errorf("handleNoStreamCache() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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) {
|
|
realBody := func() io.ReadCloser { return io.NopCloser(strings.NewReader("data")) }
|
|
tests := []struct {
|
|
name string
|
|
protoMajor int
|
|
contentLength int64
|
|
body io.ReadCloser
|
|
want bool
|
|
}{
|
|
{name: "http2 grpc stream without content-length", protoMajor: 2, contentLength: -1, body: realBody(), want: true},
|
|
{name: "http3 stream without content-length", protoMajor: 3, contentLength: -1, body: realBody(), want: true},
|
|
{name: "http2 with content-length", protoMajor: 2, contentLength: 42, body: realBody(), want: false},
|
|
{name: "http1.1 chunked without content-length", protoMajor: 1, contentLength: -1, body: realBody(), want: false},
|
|
{name: "http2 without body", protoMajor: 2, contentLength: -1, body: nil, want: false},
|
|
{name: "http2 with http.NoBody", protoMajor: 2, contentLength: -1, body: http.NoBody, 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
|
|
req.Body = tt.body
|
|
if got := isBodyUnreadable(req); got != tt.want {
|
|
t.Errorf("isBodyUnreadable() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newUnreadableRequest(method string, done <-chan struct{}) *http.Request {
|
|
req, _ := http.NewRequest(method, "http://localhost/api/admin/reservations/8fff14a2", blockingBody{done: done})
|
|
req.ProtoMajor = 3
|
|
req.ContentLength = -1
|
|
return req
|
|
}
|
|
|
|
// 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() {
|
|
_, err := appsecQuery(bouncer, "1.2.3.4", newStreamingRequest(done))
|
|
finished <- err
|
|
}()
|
|
|
|
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", ""),
|
|
}
|
|
|
|
type bodyReq func(done <-chan struct{}) *http.Request
|
|
tests := []struct {
|
|
name string
|
|
req bodyReq
|
|
wantErr bool
|
|
}{
|
|
// a streaming body that never reaches EOF is unreadable: the request is dropped.
|
|
{name: "streaming request is dropped", req: newStreamingRequest, wantErr: true},
|
|
// issue #351 regression: an HTTP/3 GET without content-length has no body to read, so it must not be dropped.
|
|
{name: "bodyless HTTP/3 GET is not dropped", req: func(done <-chan struct{}) *http.Request { return newUnreadableRequest(http.MethodGet, done) }},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
done := make(chan struct{})
|
|
defer close(done)
|
|
|
|
finished := make(chan error, 1)
|
|
go func() {
|
|
_, err := appsecQuery(bouncer, "1.2.3.4", tc.req(done))
|
|
finished <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-finished:
|
|
if tc.wantErr && err == nil {
|
|
t.Error("appsecQuery() expected the request to be dropped, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Errorf("appsecQuery() returned error: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("appsecQuery() blocked while handling the request body")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_appsecQuery_oversizedResponse(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
statusCode int
|
|
wantErr bool
|
|
}{
|
|
{name: "OK passes", statusCode: http.StatusOK, wantErr: false},
|
|
{name: "Forbidden blocks", statusCode: http.StatusForbidden, wantErr: true},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(tc.statusCode)
|
|
_, _ = io.WriteString(rw, strings.Repeat("x", int(appsecResponseBodyLimit)+1))
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, _ := url.Parse(appsecServer.URL)
|
|
bouncer := &Bouncer{
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
appsecBodyLimit: 1000,
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
decision, err := appsecQuery(bouncer, "1.2.3.4", httptest.NewRequest(http.MethodGet, "http://localhost/", nil))
|
|
if tc.wantErr && err == nil {
|
|
t.Fatal("appsecQuery() expected error, got nil")
|
|
}
|
|
if !tc.wantErr && err != nil {
|
|
t.Fatalf("appsecQuery() returned error: %v", err)
|
|
}
|
|
if decision != nil {
|
|
t.Fatalf("appsecQuery() returned decision: %v", decision)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleNextServeHTTPRelaysStructuredAppsecChallenge(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{
|
|
"action":"challenge",
|
|
"http_status":200,
|
|
"user_body_content":"<html>challenge</html>",
|
|
"user_cookies":["__crowdsec_challenge=value; Path=/; HttpOnly"],
|
|
"user_headers":{
|
|
"Content-Type":["text/html"],
|
|
"Cache-Control":["no-store"]
|
|
}
|
|
}`))
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
nextCalled := false
|
|
bouncer := &Bouncer{
|
|
next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}),
|
|
appsecEnabled: true,
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
httpAppsecClient: appsecServer.Client(),
|
|
remediationStatusCode: http.StatusForbidden,
|
|
remediationCustomHeader: "X-Remediation",
|
|
log: logger.New("DEBUG", ""),
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil)
|
|
bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10")
|
|
|
|
if nextCalled {
|
|
t.Fatal("next handler should not be called for appsec challenge")
|
|
}
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("expected challenge status 200, got %d", recorder.Code)
|
|
}
|
|
if got := recorder.Body.String(); got != "<html>challenge</html>" {
|
|
t.Fatalf("expected appsec challenge body, got %q", got)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); got != "text/html" {
|
|
t.Fatalf("expected Content-Type relayed, got %q", got)
|
|
}
|
|
if got := recorder.Header().Get("Set-Cookie"); got != "__crowdsec_challenge=value; Path=/; HttpOnly" {
|
|
t.Fatalf("expected Set-Cookie relayed, got %q", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Remediation"); got != "challenge" {
|
|
t.Fatalf("expected custom remediation header challenge, got %q", got)
|
|
}
|
|
}
|
|
|
|
func serveAppsecEnvelope(t *testing.T, envelope string, recorder *httptest.ResponseRecorder) bool {
|
|
t.Helper()
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(envelope))
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
nextCalled := false
|
|
bouncer := &Bouncer{
|
|
next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}),
|
|
appsecEnabled: true,
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
httpAppsecClient: appsecServer.Client(),
|
|
remediationStatusCode: http.StatusForbidden,
|
|
remediationCustomHeader: "X-Remediation",
|
|
log: logger.New("DEBUG", ""),
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil)
|
|
bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10")
|
|
return nextCalled
|
|
}
|
|
|
|
func TestHandleNextServeHTTPAppsecChallengeWithoutBodyFallsBackToBan(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
envelope string
|
|
}{
|
|
{name: "missing body", envelope: `{"action":"challenge","http_status":200}`},
|
|
{name: "empty body", envelope: `{"action":"challenge","http_status":200,"user_body_content":""}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
if serveAppsecEnvelope(t, tt.envelope, recorder) {
|
|
t.Fatal("next handler should not be called for an appsec challenge without body")
|
|
}
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected fallback ban status 403, got %d", recorder.Code)
|
|
}
|
|
if got := recorder.Header().Get("X-Remediation"); got != "ban" {
|
|
t.Fatalf("expected fallback remediation header ban, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleNextServeHTTPAppsecChallengeReplacesHeaders(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
recorder.Header().Set("Content-Security-Policy", "default-src 'none'")
|
|
recorder.Header().Set("Cache-Control", "public, max-age=3600")
|
|
envelope := `{
|
|
"action":"challenge",
|
|
"http_status":200,
|
|
"user_body_content":"<html>challenge</html>",
|
|
"user_cookies":["first=1; Path=/","second=2; Path=/"],
|
|
"user_headers":{
|
|
"Content-Security-Policy":["script-src 'self' 'unsafe-inline'"],
|
|
"Cache-Control":["no-store"]
|
|
}
|
|
}`
|
|
if serveAppsecEnvelope(t, envelope, recorder) {
|
|
t.Fatal("next handler should not be called for appsec challenge")
|
|
}
|
|
if got := recorder.Header().Values("Content-Security-Policy"); len(got) != 1 || got[0] != "script-src 'self' 'unsafe-inline'" {
|
|
t.Fatalf("expected only the challenge Content-Security-Policy, got %q", got)
|
|
}
|
|
if got := recorder.Header().Values("Cache-Control"); len(got) != 1 || got[0] != "no-store" {
|
|
t.Fatalf("expected only the challenge Cache-Control, got %q", got)
|
|
}
|
|
if got := recorder.Header().Values("Set-Cookie"); len(got) != 2 {
|
|
t.Fatalf("expected two separate Set-Cookie headers, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleNextServeHTTPLegacyAppsecForbiddenFallsBackToBan(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
nextCalled := false
|
|
bouncer := &Bouncer{
|
|
next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}),
|
|
appsecEnabled: true,
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
httpAppsecClient: appsecServer.Client(),
|
|
remediationStatusCode: http.StatusForbidden,
|
|
remediationCustomHeader: "X-Remediation",
|
|
log: logger.New("DEBUG", ""),
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil)
|
|
bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10")
|
|
|
|
if nextCalled {
|
|
t.Fatal("next handler should not be called for appsec forbidden")
|
|
}
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected fallback ban status 403, got %d", recorder.Code)
|
|
}
|
|
if got := recorder.Header().Get("X-Remediation"); got != "ban" {
|
|
t.Fatalf("expected fallback remediation header ban, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Test_appsecQuery_reusesConnection is a regression test for issue #384: the appsec response
|
|
// body must be drained for non-200 status response so net/http can return the conn to the idle pool.
|
|
func Test_appsecQuery_reusesConnection(t *testing.T) {
|
|
for _, status := range []int{http.StatusOK, http.StatusForbidden, http.StatusInternalServerError} {
|
|
t.Run(http.StatusText(status), func(t *testing.T) {
|
|
var mu sync.Mutex
|
|
conns := map[string]bool{}
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
mu.Lock()
|
|
conns[r.RemoteAddr] = true
|
|
mu.Unlock()
|
|
rw.WriteHeader(status)
|
|
if _, errWrite := rw.Write([]byte(`{"action":"allow"}`)); errWrite != nil {
|
|
t.Errorf("appsec stub write: %v", errWrite)
|
|
}
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, _ := url.Parse(appsecServer.URL)
|
|
bouncer := &Bouncer{
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
appsecBodyLimit: 10485760,
|
|
appsecFailureBlock: false,
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
const calls = 10
|
|
for i := 0; i < calls; i++ { //nolint:intrange
|
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", nil)
|
|
_, _ = appsecQuery(bouncer, "1.2.3.4", req)
|
|
}
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if len(conns) != 1 {
|
|
t.Errorf("appsecQuery() opened %d connections for %d calls, want 1 (response body not drained?)", len(conns), calls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandleNextServeHTTPStructuredBanKeepsBanTemplate(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"action":"ban","http_status":403,"user_body_content":"appsec default page"}`))
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
banTemplate, err := template.New("ban").Parse("<html>BAN ip={{.ClientIP}} reason={{.RemediationReason}}</html>")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
nextCalled := false
|
|
bouncer := &Bouncer{
|
|
next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}),
|
|
appsecEnabled: true,
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
httpAppsecClient: appsecServer.Client(),
|
|
remediationStatusCode: http.StatusForbidden,
|
|
remediationCustomHeader: "X-Remediation",
|
|
banTemplate: banTemplate,
|
|
banTemplateContentType: "text/html; charset=utf-8",
|
|
log: logger.New("ERROR", ""),
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil)
|
|
bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10")
|
|
|
|
if nextCalled {
|
|
t.Fatal("next handler should not be called for an appsec ban")
|
|
}
|
|
if recorder.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403, got %d", recorder.Code)
|
|
}
|
|
want := "<html>BAN ip=192.0.2.10 reason=APPSEC</html>"
|
|
if got := recorder.Body.String(); got != want {
|
|
t.Fatalf("appsec ban must keep the configured ban template, got %q want %q", got, want)
|
|
}
|
|
if got := recorder.Header().Get("Content-Type"); got != "text/html; charset=utf-8" {
|
|
t.Fatalf("expected banTemplateContentType, got %q", got)
|
|
}
|
|
if got := recorder.Header().Get("X-Remediation"); got != "ban" {
|
|
t.Fatalf("expected remediation header ban, got %q", got)
|
|
}
|
|
}
|
|
|
|
func Test_appsecQuery_unreadableBodyMethods(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)
|
|
|
|
tests := []struct {
|
|
method string
|
|
wantDropped bool
|
|
}{
|
|
{method: http.MethodGet, wantDropped: false},
|
|
{method: http.MethodHead, wantDropped: false},
|
|
{method: http.MethodOptions, wantDropped: false},
|
|
{method: http.MethodDelete, wantDropped: false},
|
|
{method: http.MethodPost, wantDropped: true},
|
|
{method: http.MethodPut, wantDropped: true},
|
|
{method: http.MethodPatch, wantDropped: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.method, func(t *testing.T) {
|
|
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() {
|
|
_, err := appsecQuery(bouncer, "1.2.3.4", newUnreadableRequest(tt.method, done))
|
|
finished <- err
|
|
}()
|
|
|
|
select {
|
|
case err := <-finished:
|
|
if tt.wantDropped && err == nil {
|
|
t.Errorf("appsecQuery() on an unreadable-body %s: expected the request to be dropped, got nil", tt.method)
|
|
}
|
|
if !tt.wantDropped && err != nil {
|
|
t.Errorf("appsecQuery() on a bodyless HTTP/3 %s returned error: %v", tt.method, err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("appsecQuery() blocked on an unreadable %s body", tt.method)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test_appsecQuery_forwardsHTTPVersion verifies that the client HTTP version
|
|
// is forwarded to CrowdSec AppSec via X-Crowdsec-Appsec-Http-Version.
|
|
func Test_appsecQuery_forwardsHTTPVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
protoMajor int
|
|
protoMinor int
|
|
expectedHdr string
|
|
}{
|
|
{
|
|
name: "HTTP/1.0",
|
|
protoMajor: 1,
|
|
protoMinor: 0,
|
|
expectedHdr: "10",
|
|
},
|
|
{
|
|
name: "HTTP/1.1",
|
|
protoMajor: 1,
|
|
protoMinor: 1,
|
|
expectedHdr: "11",
|
|
},
|
|
{
|
|
name: "HTTP/2.0",
|
|
protoMajor: 2,
|
|
protoMinor: 0,
|
|
expectedHdr: "20",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var capturedVersion string
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
capturedVersion = r.Header.Get(crowdsecAppsecHTTPVersionHeader)
|
|
rw.WriteHeader(http.StatusOK)
|
|
if _, err := rw.Write([]byte(`{"action":"allow"}`)); err != nil {
|
|
t.Errorf("write response: %v", err)
|
|
}
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, _ := url.Parse(appsecServer.URL)
|
|
bouncer := &Bouncer{
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
appsecBodyLimit: 10485760,
|
|
appsecFailureBlock: false,
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "http://localhost/", nil)
|
|
req.ProtoMajor = tt.protoMajor
|
|
req.ProtoMinor = tt.protoMinor
|
|
|
|
_, err := appsecQuery(bouncer, "1.2.3.4", req)
|
|
if err != nil {
|
|
t.Fatalf("appsecQuery() unexpected error: %v", err)
|
|
}
|
|
|
|
if capturedVersion != tt.expectedHdr {
|
|
t.Errorf("expected %s header %q, got %q", crowdsecAppsecHTTPVersionHeader, tt.expectedHdr, capturedVersion)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeHTTPServesAppsecCaptchaInAppsecMode(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(http.StatusForbidden)
|
|
_, _ = rw.Write([]byte(`{"action":"captcha","http_status":403}`))
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := CreateConfig()
|
|
cfg.Enabled = true
|
|
cfg.CrowdsecMode = configuration.AppsecMode
|
|
cfg.CrowdsecLapiKey = "test"
|
|
cfg.CrowdsecAppsecEnabled = true
|
|
cfg.CrowdsecAppsecHost = appsecURL.Host
|
|
cfg.CaptchaProvider = configuration.HcaptchaProvider
|
|
cfg.CaptchaSiteKey = "test-site-key"
|
|
cfg.CaptchaSecretKey = "secret"
|
|
cfg.CaptchaFilePath = "captcha.html"
|
|
nextCalled := false
|
|
handler, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}), cfg, "test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cacheClient := &cache.Client{}
|
|
cacheClient.New(logger.New("INFO", ""), false, "", nil, "", "")
|
|
t.Cleanup(func() { cacheClient.Delete("192.0.2.30_captcha") })
|
|
|
|
for _, solved := range []bool{false, true} {
|
|
nextCalled = false
|
|
if solved {
|
|
cacheClient.Set("192.0.2.30_captcha", cache.CaptchaDoneValue, 60)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
|
|
req.RemoteAddr = "192.0.2.30:1234"
|
|
recorder := httptest.NewRecorder()
|
|
handler.ServeHTTP(recorder, req)
|
|
|
|
if nextCalled != solved {
|
|
t.Errorf("solved=%t: expected next handler called=%t", solved, solved)
|
|
}
|
|
if !solved && (recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), "test-site-key")) {
|
|
t.Errorf("expected the captcha page, got status %d", recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleNextServeHTTPKeepsChallengePathsFromTheBackend(t *testing.T) {
|
|
appsecServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, _ *http.Request) {
|
|
rw.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer appsecServer.Close()
|
|
|
|
appsecURL, err := url.Parse(appsecServer.URL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
nextCalled := false
|
|
bouncer := &Bouncer{
|
|
next: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
nextCalled = true
|
|
}),
|
|
appsecEnabled: true,
|
|
appsecScheme: appsecURL.Scheme,
|
|
appsecHost: appsecURL.Host,
|
|
appsecPath: "/",
|
|
httpAppsecClient: appsecServer.Client(),
|
|
log: logger.New("INFO", ""),
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "http://example.com/crowdsec-internal/challenge/custom.js", nil)
|
|
bouncer.handleNextServeHTTP(recorder, req, "192.0.2.10")
|
|
|
|
if nextCalled || recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("expected a 404 without reaching the backend, got status %d (next called: %t)", recorder.Code, nextCalled)
|
|
}
|
|
}
|