From e28309c3fcfa8c1e91d58d9e2ab82a6659f34eb8 Mon Sep 17 00:00:00 2001 From: sabban Date: Tue, 2 Jun 2026 17:14:54 +0200 Subject: [PATCH 01/18] feat: add WAF challenge mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the waf-challenge-mode feature from lua-cs-bouncer: - Add Challenge remediation type (between Captcha and Ban in severity) - internal/appsec: parse the JSON body on HTTP 403 responses; when action is "challenge" return AppSecChallengeData carrying body, status code, Content-Type, CSP, Cache-Control and cookies instead of discarding the body as before - pkg/spoa: update validateWithAppSec to return (Remediation, *AppSecChallengeData); on Challenge call injectChallengeVars which sets six SPOE transaction vars: challenge_body, challenge_status (int32), challenge_content_type, challenge_csp, challenge_cache_control, challenge_cookies (newline-joined) - lua/crowdsec.lua: handle remediation=="challenge" — reads those vars, sets status/body/Content-Type/CSP/Cache-Control/Set-Cookie and returns early, bypassing the ban/captcha content-negotiation block - config/crowdsec.cfg: raise max-frame-size to 262144 to accommodate the obfuscated JS payload (~150 KB) embedded in the challenge page - config/haproxy.cfg: add lua.crowdsec_handle rule for "challenge" Co-Authored-By: Claude Sonnet 4.6 --- config/crowdsec.cfg | 1 + config/haproxy.cfg | 3 +- internal/appsec/root.go | 97 ++++++++++++++++++++++++++---------- internal/remediation/root.go | 15 ++++-- lua/crowdsec.lua | 43 +++++++++++++++- pkg/spoa/root.go | 43 ++++++++++------ 6 files changed, 153 insertions(+), 49 deletions(-) diff --git a/config/crowdsec.cfg b/config/crowdsec.cfg index 213b0df9..fb8177ca 100644 --- a/config/crowdsec.cfg +++ b/config/crowdsec.cfg @@ -9,6 +9,7 @@ spoe-agent crowdsec-agent option var-prefix crowdsec option set-on-error error + option max-frame-size 262144 timeout hello 200ms timeout idle 55s timeout processing 500ms diff --git a/config/haproxy.cfg b/config/haproxy.cfg index af030022..f9b4552f 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -54,9 +54,10 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } - ## Call lua script only for ban and captcha remediations (performance optimization) + ## Call lua script for ban, captcha, and challenge remediations http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } + http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) diff --git a/internal/appsec/root.go b/internal/appsec/root.go index a6a1cffa..a60c4f2d 100644 --- a/internal/appsec/root.go +++ b/internal/appsec/root.go @@ -3,6 +3,7 @@ package appsec import ( "bytes" "context" + "encoding/json" "fmt" "io" "maps" @@ -14,6 +15,26 @@ import ( log "github.com/sirupsen/logrus" ) +// AppSecChallengeData holds the challenge page content returned by AppSec when +// it issues a JS PoW + fingerprint challenge instead of an outright block. +type AppSecChallengeData struct { + StatusCode int + Body string + ContentType string + CSP string + CacheControl string + Cookies []string +} + +// appsecJSONResponse mirrors the JSON body AppSec sends for HTTP 403 responses. +type appsecJSONResponse struct { + Action string `json:"action"` + HTTPStatus int `json:"http_status"` + UserBodyContent string `json:"user_body_content"` + UserCookies []string `json:"user_cookies"` + UserHeaders map[string][]string `json:"user_headers"` +} + const DefaultRequestTimeout = 200 * time.Millisecond // AppSecRequest represents the HTTP request data to be validated by AppSec @@ -100,36 +121,35 @@ func (a *AppSec) TimeoutOrDefault() time.Duration { return a.Timeout } -// ValidateRequest sends the HTTP request to the AppSec engine and returns the remediation -func (a *AppSec) ValidateRequest(ctx context.Context, req *AppSecRequest) (remediation.Remediation, error) { - // Use IsValid() which checks both Client and URL +// ValidateRequest sends the HTTP request to the AppSec engine and returns the +// resulting remediation. When AppSec issues a challenge, the second return value +// is non-nil and contains the page content to serve to the browser. +func (a *AppSec) ValidateRequest(ctx context.Context, req *AppSecRequest) (remediation.Remediation, *AppSecChallengeData, error) { if !a.IsValid() { a.logger.Debug("AppSec not configured, allowing request") - return remediation.Allow, nil + return remediation.Allow, nil, nil } - // Create HTTP request to AppSec engine httpReq, err := a.createAppSecRequest(req) if err != nil { a.logger.Errorf("Failed to create AppSec request: %v", err) - return remediation.Allow, err + return remediation.Allow, nil, err } - // Send request to AppSec engine resp, err := a.Client.HTTPClient.Do(httpReq.WithContext(ctx)) if err != nil { a.logger.Errorf("Failed to send request to AppSec engine: %v", err) - return remediation.Allow, err + return remediation.Allow, nil, err } - // resp is guaranteed to be non-nil when err is nil (per http.Client.Do contract) defer resp.Body.Close() - // Discard response body for proper connection reuse - // This allows the connection to be reused via keep-alive - _, _ = io.Copy(io.Discard, resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + a.logger.Errorf("Failed to read AppSec response body: %v", err) + return remediation.Allow, nil, err + } - // Process response based on HTTP status code - return a.processAppSecResponse(resp) + return a.processAppSecResponse(resp.StatusCode, body) } func (a *AppSec) createAppSecRequest(req *AppSecRequest) (*http.Request, error) { @@ -182,29 +202,54 @@ func (a *AppSec) createAppSecRequest(req *AppSecRequest) (*http.Request, error) return httpReq, nil } -func (a *AppSec) processAppSecResponse(resp *http.Response) (remediation.Remediation, error) { - switch resp.StatusCode { +func (a *AppSec) processAppSecResponse(statusCode int, body []byte) (remediation.Remediation, *AppSecChallengeData, error) { + switch statusCode { case http.StatusOK: - // Request allowed - return remediation.Allow, nil + return remediation.Allow, nil, nil case http.StatusForbidden: - // Request blocked - return ban remediation - return remediation.Ban, nil + if len(body) == 0 { + return remediation.Ban, nil, nil + } + + var parsed appsecJSONResponse + if err := json.Unmarshal(body, &parsed); err != nil { + a.logger.WithError(err).Warn("failed to parse AppSec JSON response, defaulting to ban") + return remediation.Ban, nil, nil + } + + if parsed.Action != "challenge" { + return remediation.Ban, nil, nil + } + + cd := &AppSecChallengeData{ + StatusCode: parsed.HTTPStatus, + Body: parsed.UserBodyContent, + Cookies: parsed.UserCookies, + } + if vals := parsed.UserHeaders["Content-Type"]; len(vals) > 0 { + cd.ContentType = vals[0] + } + if vals := parsed.UserHeaders["Content-Security-Policy"]; len(vals) > 0 { + cd.CSP = vals[0] + } + if vals := parsed.UserHeaders["Cache-Control"]; len(vals) > 0 { + cd.CacheControl = vals[0] + } + + return remediation.Challenge, cd, nil case http.StatusUnauthorized: - // Authentication failed a.logger.Error("AppSec authentication failed - check API key") - return remediation.Allow, fmt.Errorf("AppSec authentication failed") + return remediation.Allow, nil, fmt.Errorf("AppSec authentication failed") case http.StatusInternalServerError: - // AppSec engine error a.logger.Error("AppSec engine error") - return remediation.Allow, fmt.Errorf("AppSec engine error") + return remediation.Allow, nil, fmt.Errorf("AppSec engine error") default: - a.logger.Warnf("Unexpected AppSec response code: %d", resp.StatusCode) - return remediation.Allow, fmt.Errorf("unexpected AppSec response code: %d", resp.StatusCode) + a.logger.Warnf("Unexpected AppSec response code: %d", statusCode) + return remediation.Allow, nil, fmt.Errorf("unexpected AppSec response code: %d", statusCode) } } diff --git a/internal/remediation/root.go b/internal/remediation/root.go index 888f02c6..3572f361 100644 --- a/internal/remediation/root.go +++ b/internal/remediation/root.go @@ -1,11 +1,12 @@ package remediation -// The order matters since we use slices.Max to get the max value +// The order matters since we use numeric comparison to find the most restrictive remediation. const ( - Allow Remediation = iota // Allow remediation - Unknown // Unknown remediation (Unknown is used to have a value for remediation we don't support EG "MFA") - Captcha // Captcha remediation - Ban // Ban remediation + Allow Remediation = iota // Allow remediation + Unknown // Unknown remediation (Unknown is used to have a value for remediation we don't support EG "MFA") + Captcha // Captcha remediation + Challenge // Challenge remediation (JS PoW + fingerprint, issued by AppSec) + Ban // Ban remediation ) type Remediation uint8 // Remediation type is smallest uint to save space @@ -16,6 +17,8 @@ func (r Remediation) String() string { return "ban" case Captcha: return "captcha" + case Challenge: + return "challenge" case Unknown: return "unknown" default: @@ -29,6 +32,8 @@ func FromString(s string) Remediation { return Ban case "captcha": return Captcha + case "challenge": + return Challenge case "allow": return Allow default: diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index d457f8f8..1fec17c1 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -114,12 +114,52 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-store") -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy - -- This Lua handler is only called for "captcha" and "ban" remediations + -- This Lua handler is only called for "captcha", "ban", and "challenge" remediations if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end + if remediation == "challenge" then + local body = get_txn_var(txn, "crowdsec.challenge_body") + local status = get_txn_var(txn, "crowdsec.challenge_status") + local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") + local csp = get_txn_var(txn, "crowdsec.challenge_csp") + local cache_ctrl = get_txn_var(txn, "crowdsec.challenge_cache_control") + local cookies_raw = get_txn_var(txn, "crowdsec.challenge_cookies") + + -- challenge_status is an int32 SPOE var; handle both number and string + local s = type(status) == "number" and status or tonumber(status) or 200 + reply:set_status(s) + reply:set_body(body ~= "" and body or "") + + if content_type ~= "" then + reply:add_header("Content-Type", content_type) + else + reply:add_header("Content-Type", "text/html") + end + if csp ~= "" then + reply:add_header("Content-Security-Policy", csp) + end + if cache_ctrl ~= "" then + reply:add_header("Cache-Control", cache_ctrl) + end + -- cookies_raw is a newline-joined list of Set-Cookie strings + if cookies_raw ~= "" then + for cookie in cookies_raw:gmatch("[^\n]+") do + reply:add_header("Set-Cookie", cookie) + end + end + + reply:add_header("Content-Length", #reply.body) + txn:done(reply) + return + end + + -- Always disable cache for ban/captcha pages + reply:add_header("cache-control", "no-cache") + reply:add_header("cache-control", "no-store") + if remediation == "captcha" then reply:set_status(200) reply:set_body(runtime.captcha.render({ @@ -137,7 +177,6 @@ function runtime.Handle(txn) })) end - local hdr = txn.http:req_get_headers() if hdr ~= nil and utils.accept_html(hdr) == false then reply:set_body("Forbidden") diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 79595678..227154fa 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -514,7 +514,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) } return } @@ -537,10 +537,15 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) - // If AppSec returns ban, inject ban values - if r == remediation.Ban { + var challengeData *appsec.AppSecChallengeData + r, challengeData = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + switch r { + case remediation.Ban: matchedHost.Ban.InjectKeyValues(writer) + case remediation.Challenge: + if challengeData != nil { + injectChallengeVars(writer, challengeData) + } } } } @@ -566,8 +571,8 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { return r < remediation.Captcha || alwaysSend } -// validateWithAppSec performs AppSec validation and returns the remediation -// Returns the more restrictive remediation between the current remediation and AppSec result +// validateWithAppSec performs AppSec validation and returns the remediation. +// When AppSec issues a challenge the second return value carries the page content. func (s *Spoa) validateWithAppSec( ctx context.Context, msgData *HTTPMessageData, @@ -575,10 +580,9 @@ func (s *Spoa) validateWithAppSec( appSecToUse *appsec.AppSec, currentRemediation remediation.Remediation, requestTimeout time.Duration, -) remediation.Remediation { +) (remediation.Remediation, *appsec.AppSecChallengeData) { appSecReq := msgData.buildAppSecRequest() - // Create logger with host context logger := s.logger if appSecReq.Host != "" { logger = logger.WithField("host", appSecReq.Host) @@ -587,19 +591,17 @@ func (s *Spoa) validateWithAppSec( logger = logger.WithField("matched_host", matchedHost.Host) } - // Validate with AppSec - derive context from handler so requests cancel on shutdown appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") - return currentRemediation + return currentRemediation, nil } logger.WithField("remediation", appSecRemediation.String()).Debug("AppSec validation result") - // Track AppSec block metrics if appSecRemediation > remediation.Allow && appSecReq.RemoteIP != "" { if ipAddr, parseErr := netip.ParseAddr(appSecReq.RemoteIP); parseErr == nil { ipType := "ipv4" @@ -610,14 +612,25 @@ func (s *Spoa) validateWithAppSec( } } - // Return the more restrictive remediation (never downgrade security) if appSecRemediation > currentRemediation { if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } - return appSecRemediation + return appSecRemediation, challengeData } - return currentRemediation + return currentRemediation, nil +} + +// injectChallengeVars sets the SPOE transaction variables HAProxy Lua needs to +// serve a challenge response. The challenge_body may be up to ~200 KB (obfuscated +// JS embedded in HTML); ensure max-frame-size in crowdsec.cfg is at least 262144. +func injectChallengeVars(writer *encoding.ActionWriter, cd *appsec.AppSecChallengeData) { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", cd.Body) + _ = writer.SetInt32(encoding.VarScopeTransaction, "challenge_status", int32(cd.StatusCode)) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", cd.ContentType) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", cd.CSP) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", cd.CacheControl) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookies", strings.Join(cd.Cookies, "\n")) } // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData From 24935fcb73e55c6f4da1eb8b12505a9633e83af4 Mon Sep 17 00:00:00 2001 From: sabban Date: Wed, 3 Jun 2026 10:07:28 +0200 Subject: [PATCH 02/18] fix(challenge): route challenge via dedicated HTTP server, not SPOE vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPOE frame size in dropmorepackets/haproxy-go is capped at 64 KB, but the challenge page (HTML + ~150 KB of obfuscated JS) exceeds that limit. Attempting to write the large body through the ActionWriter silently fills its buffer; subsequent writes (including the small remediation="challenge" update) also fail, leaving HAProxy with the stale "allow" value from the TCP-phase handler. Replace the SPOE-var approach with a dedicated challenge HTTP server: - pkg/spoa/challenge_server.go: new ChallengeServer that listens on a configurable TCP address (challenge_listen in the bouncer YAML). When HAProxy routes a challenge request to it, the server re-sends the original request to the AppSec engine (forwarding all headers + body, using X-Crowdsec-Real-Ip for the client IP), unwraps the JSON response, and writes the challenge page — HTML, JS, cookies, CSP — directly to HAProxy with no size restriction. - pkg/spoa/root.go: SPOE handler only writes remediation="challenge" (9 bytes, well within the frame limit); all challenge body logic is removed from the SPOE path. Challenge server is started as a goroutine inside Serve(). - pkg/cfg/config.go, cmd/root.go: expose challenge_listen config field and wire it through SpoaConfig.ChallengeAddr. - lua/crowdsec.lua: remove the challenge branch from crowdsec_handle (Lua no longer handles challenge; HAProxy routes to the dedicated backend instead). HAProxy config change: add a challenge_backend pointing to the bouncer's challenge HTTP server, and use_backend for remediation=="challenge". Tested end-to-end with CrowdSec waf-challenge-mode branch: - GET /challenge → 200 + CrowdSec challenge HTML (CSP + CT headers) - GET /pow-worker.js → 200 + PoW web worker JS (3.4 KB) - POST /submit (bad) → 200 + {"status":"failed"} - rem=challenge in HAProxy access log confirms SPOE var is set Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 15 ++-- lua/crowdsec.lua | 41 +--------- pkg/cfg/config.go | 3 +- pkg/spoa/challenge_server.go | 149 +++++++++++++++++++++++++++++++++++ pkg/spoa/root.go | 63 ++++++++------- 5 files changed, 193 insertions(+), 78 deletions(-) create mode 100644 pkg/spoa/challenge_server.go diff --git a/cmd/root.go b/cmd/root.go index 8908b863..1bc77f62 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,13 +261,14 @@ func Execute() error { // Create single SPOA directly with minimal configuration spoaConfig := &spoa.SpoaConfig{ - TcpAddr: config.ListenTCP, - UnixAddr: config.ListenUnix, - Dataset: dataSet, - HostManager: HostManager, - GeoDatabase: &config.Geo, - GlobalAppSec: globalAppSec, - Logger: spoaLogger, + TcpAddr: config.ListenTCP, + UnixAddr: config.ListenUnix, + Dataset: dataSet, + HostManager: HostManager, + GeoDatabase: &config.Geo, + GlobalAppSec: globalAppSec, + ChallengeAddr: config.ChallengeAddr, + Logger: spoaLogger, } singleSpoa, err := spoa.New(spoaConfig) diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index 1fec17c1..160d87aa 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -113,49 +113,14 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") - -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy - -- This Lua handler is only called for "captcha", "ban", and "challenge" remediations + -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy. + -- "challenge" is handled by routing to the bouncer challenge HTTP server (no Lua needed). + -- This Lua handler is only called for "captcha" and "ban" remediations. if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end - if remediation == "challenge" then - local body = get_txn_var(txn, "crowdsec.challenge_body") - local status = get_txn_var(txn, "crowdsec.challenge_status") - local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") - local csp = get_txn_var(txn, "crowdsec.challenge_csp") - local cache_ctrl = get_txn_var(txn, "crowdsec.challenge_cache_control") - local cookies_raw = get_txn_var(txn, "crowdsec.challenge_cookies") - - -- challenge_status is an int32 SPOE var; handle both number and string - local s = type(status) == "number" and status or tonumber(status) or 200 - reply:set_status(s) - reply:set_body(body ~= "" and body or "") - - if content_type ~= "" then - reply:add_header("Content-Type", content_type) - else - reply:add_header("Content-Type", "text/html") - end - if csp ~= "" then - reply:add_header("Content-Security-Policy", csp) - end - if cache_ctrl ~= "" then - reply:add_header("Cache-Control", cache_ctrl) - end - -- cookies_raw is a newline-joined list of Set-Cookie strings - if cookies_raw ~= "" then - for cookie in cookies_raw:gmatch("[^\n]+") do - reply:add_header("Set-Cookie", cookie) - end - end - - reply:add_header("Content-Length", #reply.body) - txn:done(reply) - return - end - -- Always disable cache for ban/captcha pages reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 11ca6514..9bee4cd8 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -38,8 +38,9 @@ type BouncerConfig struct { PrometheusConfig PrometheusConfig `yaml:"prometheus"` PprofConfig PprofConfig `yaml:"pprof"` APIKey string `yaml:"api_key"` // LAPI API key (also used for AppSec) - AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL + AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL AppSecTimeout time.Duration `yaml:"appsec_timeout,omitempty"` + ChallengeAddr string `yaml:"challenge_listen,omitempty"` // Challenge HTTP server listen address (e.g. "0.0.0.0:9001") } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go new file mode 100644 index 00000000..06807c79 --- /dev/null +++ b/pkg/spoa/challenge_server.go @@ -0,0 +1,149 @@ +package spoa + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" + log "github.com/sirupsen/logrus" +) + +const ( + // ChallengeRealIPHeader is the header HAProxy adds to identify the real + // client IP when routing a challenge request to the challenge server. + ChallengeRealIPHeader = "X-Crowdsec-Real-Ip" +) + +// ChallengeServer is a plain HTTP server that HAProxy routes to when the SPOE +// agent returns remediation=challenge. It re-sends the request to the AppSec +// engine, unwraps the JSON response, and writes the challenge page (HTML, JS, +// or submit JSON) directly back to the client. +// +// This avoids the 64 KB SPOE frame-size limit: the large challenge body never +// passes through the SPOE protocol; it travels through a normal HTTP connection +// between HAProxy, this server, and the AppSec engine. +type ChallengeServer struct { + appSec *appsec.AppSec + listen string + logger *log.Entry +} + +func newChallengeServer(appSec *appsec.AppSec, addr string, logger *log.Entry) *ChallengeServer { + return &ChallengeServer{ + appSec: appSec, + listen: addr, + logger: logger.WithField("component", "challenge_server"), + } +} + +func (cs *ChallengeServer) Serve(ctx context.Context) error { + srv := &http.Server{ + Addr: cs.listen, + Handler: cs, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + go func() { + <-ctx.Done() + _ = srv.Shutdown(context.Background()) + }() + + cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("challenge server: %w", err) + } + + return nil +} + +// ServeHTTP handles a request forwarded by HAProxy when remediation=challenge. +// It builds an AppSec request from the incoming request, calls the AppSec +// engine, and writes the challenge response (body + headers + cookies) back. +func (cs *ChallengeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + clientIP := r.Header.Get(ChallengeRealIPHeader) + if clientIP == "" { + // Fall back to the connection's remote address (strips port) + if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { + clientIP = r.RemoteAddr[:idx] + } else { + clientIP = r.RemoteAddr + } + } + + // Read the request body (needed for the submit path POST) + var body []byte + if r.Body != nil { + var err error + body, err = io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit + if err != nil { + cs.logger.WithError(err).Warn("challenge server: failed to read request body") + } + } + + // Reconstruct the URL for the AppSec header + reqURL := r.URL.RequestURI() + + // Forward the original client headers to AppSec (minus hop-by-hop and our own headers) + headers := r.Header.Clone() + headers.Del(ChallengeRealIPHeader) + headers.Del("X-Forwarded-For") + + appSecReq := &appsec.AppSecRequest{ + Host: r.Host, + Method: r.Method, + URL: reqURL, + RemoteIP: clientIP, + UserAgent: r.UserAgent(), + Headers: headers, + Body: body, + } + + appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + rem, challengeData, err := cs.appSec.ValidateRequest(appSecCtx, appSecReq) + if err != nil { + cs.logger.WithError(err).Error("challenge server: AppSec request failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if rem != remediation.Challenge || challengeData == nil { + // AppSec didn't return a challenge (e.g. allow after valid cookie). + // Return 200 with an empty body; HAProxy will handle the pass-through. + w.WriteHeader(http.StatusOK) + return + } + + // Write response headers from AppSec + if challengeData.ContentType != "" { + w.Header().Set("Content-Type", challengeData.ContentType) + } + if challengeData.CSP != "" { + w.Header().Set("Content-Security-Policy", challengeData.CSP) + } + if challengeData.CacheControl != "" { + w.Header().Set("Cache-Control", challengeData.CacheControl) + } + for _, cookie := range challengeData.Cookies { + w.Header().Add("Set-Cookie", cookie) + } + + status := challengeData.StatusCode + if status <= 0 { + status = http.StatusOK + } + + w.WriteHeader(status) + if challengeData.Body != "" { + _, _ = io.Copy(w, bytes.NewBufferString(challengeData.Body)) + } +} diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 227154fa..cdc69c79 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -75,23 +75,25 @@ var ( type Spoa struct { ListenAddr net.Listener - ListenSocket net.Listener - logger *log.Entry + ListenSocket net.Listener + logger *log.Entry // Direct access to shared data (no IPC needed) - dataset *dataset.DataSet - hostManager *host.Manager - geoDatabase *geo.GeoDatabase - globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + dataset *dataset.DataSet + hostManager *host.Manager + geoDatabase *geo.GeoDatabase + globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + challengeServer *ChallengeServer } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -121,6 +123,10 @@ func New(config *SpoaConfig) (*Spoa, error) { globalAppSec: config.GlobalAppSec, } + if config.ChallengeAddr != "" && config.GlobalAppSec != nil && config.GlobalAppSec.IsValid() { + s.challengeServer = newChallengeServer(config.GlobalAppSec, config.ChallengeAddr, workerLogger) + } + if config.TcpAddr != "" { addr, err := net.Listen("tcp", config.TcpAddr) if err != nil { @@ -213,6 +219,15 @@ func (s *Spoa) Serve(ctx context.Context) error { return nil } + // Launch the challenge HTTP server if configured + if s.challengeServer != nil { + go func() { + if err := s.challengeServer.Serve(ctx); err != nil { + serverError <- err + } + }() + } + select { case err := <-serverError: return err @@ -515,6 +530,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + // Challenge content is served by the challenge HTTP server, not via SPOE vars. } return } @@ -537,15 +553,9 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - var challengeData *appsec.AppSecChallengeData - r, challengeData = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) - switch r { - case remediation.Ban: + r, _ = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) - case remediation.Challenge: - if challengeData != nil { - injectChallengeVars(writer, challengeData) - } } } } @@ -621,17 +631,6 @@ func (s *Spoa) validateWithAppSec( return currentRemediation, nil } -// injectChallengeVars sets the SPOE transaction variables HAProxy Lua needs to -// serve a challenge response. The challenge_body may be up to ~200 KB (obfuscated -// JS embedded in HTML); ensure max-frame-size in crowdsec.cfg is at least 262144. -func injectChallengeVars(writer *encoding.ActionWriter, cd *appsec.AppSecChallengeData) { - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", cd.Body) - _ = writer.SetInt32(encoding.VarScopeTransaction, "challenge_status", int32(cd.StatusCode)) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", cd.ContentType) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", cd.CSP) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", cd.CacheControl) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookies", strings.Join(cd.Cookies, "\n")) -} // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { From b8401363b26d4088a0e803f7361d573115f249ae Mon Sep 17 00:00:00 2001 From: sabban Date: Wed, 3 Jun 2026 10:15:58 +0200 Subject: [PATCH 03/18] test: add tests for challenge mode (remediation, appsec, challenge server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/remediation/root_test.go (4 tests) - Challenge.String() returns "challenge" - FromString("challenge") round-trips back to Challenge - All Remediation values round-trip through String/FromString - Challenge is numerically between Captcha and Ban (ordering invariant that shouldRunAppSec and the take-the-max logic depend on) internal/appsec/root_test.go (15 tests) processAppSecResponse (white-box, same-package): - 200 → Allow, no challenge data - 403 with action=ban → Ban, no challenge data - 403 with empty body → Ban (safe default) - 403 with invalid JSON → Ban (safe default) - 403 with action=challenge, minimal payload → Challenge + data - 403 with action=challenge, full headers (CT/CSP/CC) + cookies → all fields - 401 → Allow + error - 500 → Allow + error - Unknown status → Allow + error ValidateRequest (httptest server): - AppSec returns 200 → Allow - AppSec returns 403 ban → Ban - AppSec returns 403 challenge → Challenge + all data fields - Client not configured → Allow, no error - Required CrowdSec headers are set on the upstream request - POST body is forwarded to AppSec pkg/spoa/challenge_server_test.go (10 tests, all via httptest.ResponseRecorder) - Challenge HTML page (CT, CSP, Cache-Control headers) - Single Set-Cookie forwarded from AppSec - Multiple Set-Cookie headers forwarded - AppSec returns allow → 200 empty body - X-Crowdsec-Real-Ip used as client IP - Falls back to RemoteAddr when header is absent - POST body forwarded to AppSec - X-Crowdsec-Real-Ip and X-Forwarded-For stripped before forwarding - PoW worker JS response (Content-Type: application/javascript) - Invalid submit response ({"status":"failed"}) Co-Authored-By: Claude Sonnet 4.6 --- internal/appsec/root_test.go | 295 ++++++++++++++++++++++++++++++ internal/remediation/root_test.go | 30 +++ pkg/spoa/challenge_server_test.go | 251 +++++++++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 internal/appsec/root_test.go create mode 100644 internal/remediation/root_test.go create mode 100644 pkg/spoa/challenge_server_test.go diff --git a/internal/appsec/root_test.go b/internal/appsec/root_test.go new file mode 100644 index 00000000..c03d1d72 --- /dev/null +++ b/internal/appsec/root_test.go @@ -0,0 +1,295 @@ +package appsec + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestAppSec returns an AppSec with a client pointed at the given URL. +func newTestAppSec(url, apiKey string) *AppSec { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + a.Client = &AppSecClient{ + HTTPClient: &http.Client{}, + APIKey: apiKey, + URL: url, + logger: a.logger, + } + return a +} + +// ---------- parseResponse unit tests ---------- + +func TestProcessAppSecResponse_Allow(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusOK, nil) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanPlain403(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{"action": "ban", "http_status": 403}) + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanEmptyBody(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, nil) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanInvalidJSON(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, []byte("not json")) + + require.NoError(t, err) // invalid JSON is treated as ban, not an error + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_ChallengeMinimal(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{"Content-Type": {"text/html"}}, + "user_cookies": []string{}, + }) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, 200, cd.StatusCode) + assert.Equal(t, "challenge", cd.Body) + assert.Equal(t, "text/html", cd.ContentType) + assert.Empty(t, cd.Cookies) +} + +func TestProcessAppSecResponse_ChallengeWithAllHeaders(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{ + "Content-Type": {"text/html; charset=utf-8"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, + }, + "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, + }) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, "text/html; charset=utf-8", cd.ContentType) + assert.Equal(t, "default-src 'self'", cd.CSP) + assert.Equal(t, "no-store, no-cache", cd.CacheControl) + assert.Equal(t, []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, cd.Cookies) +} + +func TestProcessAppSecResponse_Unauthorized(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusUnauthorized, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_InternalServerError(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusInternalServerError, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_UnknownStatus(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(418, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +// ---------- ValidateRequest integration tests (httptest server) ---------- + +func TestValidateRequest_Allow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_Ban(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"action": "ban", "http_status": 403}) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/?id=1 OR 1=1", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_Challenge(t *testing.T) { + const challengeHTML = "CrowdSec Challenge" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": challengeHTML, + "user_headers": map[string][]string{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store"}, + }, + "user_cookies": []string{"__crowdsec_challenge=xyz; HttpOnly"}, + }) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/challenge", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, 200, cd.StatusCode) + assert.Equal(t, challengeHTML, cd.Body) + assert.Equal(t, "text/html", cd.ContentType) + assert.Equal(t, "default-src 'self'", cd.CSP) + assert.Equal(t, "no-store", cd.CacheControl) + assert.Equal(t, []string{"__crowdsec_challenge=xyz; HttpOnly"}, cd.Cookies) +} + +func TestValidateRequest_NotConfigured(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + // No Client set → IsValid() == false + + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { + var capturedReq *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "secret-api-key") + _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "myhost.com", + Method: "POST", + URL: "/login", + RemoteIP: "10.0.0.1", + UserAgent: "Mozilla/5.0", + Version: "1.1", + }) + + require.NotNil(t, capturedReq) + assert.Equal(t, "10.0.0.1", capturedReq.Header.Get("X-Crowdsec-Appsec-Ip")) + assert.Equal(t, "/login", capturedReq.Header.Get("X-Crowdsec-Appsec-Uri")) + assert.Equal(t, "myhost.com", capturedReq.Header.Get("X-Crowdsec-Appsec-Host")) + assert.Equal(t, "POST", capturedReq.Header.Get("X-Crowdsec-Appsec-Verb")) + assert.Equal(t, "secret-api-key", capturedReq.Header.Get("X-Crowdsec-Appsec-Api-Key")) + assert.Equal(t, "Mozilla/5.0", capturedReq.Header.Get("X-Crowdsec-Appsec-User-Agent")) + assert.Equal(t, "11", capturedReq.Header.Get("X-Crowdsec-Appsec-Http-Version")) +} + +func TestValidateRequest_PostWithBody(t *testing.T) { + var receivedBody []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + var err error + receivedBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "key") + payload := []byte("t=token&n=nonce&f=fingerprint") + _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "POST", URL: "/submit", RemoteIP: "1.2.3.4", + Body: payload, + }) + + assert.Equal(t, payload, receivedBody) +} diff --git a/internal/remediation/root_test.go b/internal/remediation/root_test.go new file mode 100644 index 00000000..3b57abd8 --- /dev/null +++ b/internal/remediation/root_test.go @@ -0,0 +1,30 @@ +package remediation + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChallengeString(t *testing.T) { + assert.Equal(t, "challenge", Challenge.String()) +} + +func TestFromStringChallenge(t *testing.T) { + assert.Equal(t, Challenge, FromString("challenge")) +} + +func TestFromStringRoundTrip(t *testing.T) { + for _, r := range []Remediation{Allow, Unknown, Captcha, Challenge, Ban} { + assert.Equal(t, r, FromString(r.String()), "round-trip failed for %v", r) + } +} + +func TestChallengeOrdering(t *testing.T) { + // Challenge must be more restrictive than Captcha and less restrictive than Ban + // so that shouldRunAppSec and the "take the max" logic work correctly. + assert.Greater(t, Challenge, Captcha) + assert.Less(t, Challenge, Ban) + assert.Greater(t, Challenge, Allow) + assert.Greater(t, Challenge, Unknown) +} diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go new file mode 100644 index 00000000..bbb64acb --- /dev/null +++ b/pkg/spoa/challenge_server_test.go @@ -0,0 +1,251 @@ +package spoa + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockAppSecHandler returns an http.Handler that responds with the given JSON body +// and status code to simulate the AppSec engine's wire format. +func mockAppSecHandler(statusCode int, payload interface{}) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) + }) +} + +func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*ChallengeServer, *httptest.Server) { + t.Helper() + appSecSrv := httptest.NewServer(appSecHandler) + t.Cleanup(appSecSrv.Close) + + a := &appsec.AppSec{} + a.InitLogger(log.NewEntry(log.New())) + a.Client = &appsec.AppSecClient{ + HTTPClient: &http.Client{}, + APIKey: "test-api-key", + URL: appSecSrv.URL, + // logger exposed through the embedded unexported field; set via Init below + } + + cs := newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) + return cs, appSecSrv +} + +// roundtrip sends req to the ChallengeServer and returns the recorded response. +func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + cs.ServeHTTP(rr, req) + return rr +} + +// ---------- tests ---------- + +func TestChallengeServer_ServesChallengePage(t *testing.T) { + const challengeHTML = "CrowdSec Challenge" + + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": challengeHTML, + "user_headers": map[string][]string{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, + }, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, challengeHTML, rr.Body.String()) + assert.Equal(t, "text/html", rr.Header().Get("Content-Type")) + assert.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) + assert.Equal(t, "no-store, no-cache", rr.Header().Get("Cache-Control")) +} + +func TestChallengeServer_ForwardsCookies(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": `{"status":"ok"}`, + "user_headers": map[string][]string{"Content-Type": {"application/json"}}, + "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax"}, + })) + + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", strings.NewReader("t=tok&n=nonce")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + cookies := rr.Header()["Set-Cookie"] + require.Len(t, cookies, 1) + assert.Equal(t, "__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax", cookies[0]) +} + +func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "ok", + "user_headers": map[string][]string{}, + "user_cookies": []string{ + "__crowdsec_challenge=abc; HttpOnly", + "session=xyz; Secure", + }, + })) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + cookies := rr.Header()["Set-Cookie"] + assert.Len(t, cookies, 2) +} + +func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { + // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Empty(t, rr.Body.String()) +} + +func TestChallengeServer_UsesRealIPHeader(t *testing.T) { + var capturedIP string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") + roundtrip(cs, req) + + assert.Equal(t, "203.0.113.42", capturedIP) +} + +func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { + var capturedIP string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.RemoteAddr = "203.0.113.5:12345" + // No ChallengeRealIPHeader set + roundtrip(cs, req) + + assert.Equal(t, "203.0.113.5", capturedIP) +} + +func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { + var receivedBody []byte + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + receivedBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + // Return a challenge response so ServeHTTP doesn't return early + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "action": "challenge", "http_status": 200, + "user_body_content": "ok", "user_headers": map[string][]string{}, + "user_cookies": []string{}, + }) + }) + cs, _ := newChallengeServerForTest(t, handler) + + payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", + strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + roundtrip(cs, req) + + assert.Equal(t, payload, string(receivedBody)) +} + +func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { + var capturedHeaders http.Header + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + req.Header.Set("X-Forwarded-For", "10.0.0.1") + roundtrip(cs, req) + + assert.Empty(t, capturedHeaders.Get(ChallengeRealIPHeader), + "X-Crowdsec-Real-Ip must not be forwarded to AppSec") + assert.Empty(t, capturedHeaders.Get("X-Forwarded-For"), + "X-Forwarded-For must not be forwarded to AppSec") +} + +func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { + const workerJS = "// pow worker" + + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": workerJS, + "user_headers": map[string][]string{ + "Content-Type": {"application/javascript"}, + "Cache-Control": {"public, max-age=3600"}, + }, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type")) + assert.Equal(t, "public, max-age=3600", rr.Header().Get("Cache-Control")) + assert.Equal(t, workerJS, rr.Body.String()) +} + +func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": `{"status":"failed"}`, + "user_headers": map[string][]string{"Content-Type": {"application/json"}, "Cache-Control": {"no-cache, no-store"}}, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", + strings.NewReader("t=bad&ts=bad&h=bad&n=bad&p=bad&m=bad&f=bad")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), `"status":"failed"`) +} From 5d21e0bef9f20038453b648846570c36b87ddbbd Mon Sep 17 00:00:00 2001 From: sabban Date: Thu, 4 Jun 2026 18:07:33 +0200 Subject: [PATCH 04/18] add some default configuration --- ...ec-spoa-bouncer.no-challenge-listener.yaml | 45 +++++++++++++++++++ config/crowdsec-spoa-bouncer.yaml | 10 +++++ 2 files changed, 55 insertions(+) create mode 100644 config/crowdsec-spoa-bouncer.no-challenge-listener.yaml diff --git a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml new file mode 100644 index 00000000..7ed5951c --- /dev/null +++ b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml @@ -0,0 +1,45 @@ +## Default configuration without the challenge HTTP listener. +## Use this variant when AppSec challenge routing is not deployed yet. + +## Log configuration +log_mode: file +log_dir: /var/log/crowdsec-spoa/ +log_level: info +log_compression: true +log_max_size: 100 +log_max_backups: 3 +log_max_age: 30 + +## LAPI configuration +update_frequency: 10s +api_url: http://127.0.0.1:8080/ +api_key: ${API_KEY} +insecure_skip_verify: false + +## SPOA listener configuration +# Configure TCP and/or Unix socket listeners +listen_tcp: 0.0.0.0:9000 +listen_unix: /run/crowdsec-spoa/spoa.sock +#asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb +#city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb + +## Global AppSec configuration +## AppSec can still be enabled for allow/ban decisions without exposing a challenge listener. +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms + +## Challenge HTTP listener intentionally disabled in this variant. +#challenge_listen: 127.0.0.1:9001 + +prometheus: + enabled: false + listen_addr: 127.0.0.1 + listen_port: 60601 + +## pprof debug endpoint for runtime profiling +## WARNING: Only enable for debugging, exposes internal runtime data +## Endpoints: /debug/pprof/heap, /debug/pprof/profile, /debug/pprof/goroutine, etc. +#pprof: +# enabled: false +# listen_addr: 127.0.0.1 +# listen_port: 6060 diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 9e2f2552..b5bef770 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -20,6 +20,16 @@ listen_unix: /run/crowdsec-spoa/spoa.sock #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb +## Global AppSec configuration +## Used for virtual patching and JavaScript challenge decisions. +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms + +## Challenge HTTP listener +## HAProxy routes remediation=challenge requests to this listener. +## Keep this bound to loopback unless HAProxy reaches the bouncer through a trusted private network. +challenge_listen: 127.0.0.1:9001 + prometheus: enabled: false listen_addr: 127.0.0.1 From d532b8a70490d4ba6b257df34a630fd8ae45cd1e Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 11:55:37 +0200 Subject: [PATCH 05/18] lint --- internal/appsec/root_test.go | 47 +++++++++++++++++----------- pkg/spoa/challenge_server.go | 5 ++- pkg/spoa/challenge_server_test.go | 51 ++++++++++++++++--------------- pkg/spoa/root.go | 36 +++++++++++----------- 4 files changed, 76 insertions(+), 63 deletions(-) diff --git a/internal/appsec/root_test.go b/internal/appsec/root_test.go index c03d1d72..f8fd5e44 100644 --- a/internal/appsec/root_test.go +++ b/internal/appsec/root_test.go @@ -27,6 +27,15 @@ func newTestAppSec(url, apiKey string) *AppSec { return a } +func mustMarshal(t *testing.T, value any) []byte { + t.Helper() + + body, err := json.Marshal(value) + require.NoError(t, err) + + return body +} + // ---------- parseResponse unit tests ---------- func TestProcessAppSecResponse_Allow(t *testing.T) { @@ -44,7 +53,7 @@ func TestProcessAppSecResponse_BanPlain403(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{"action": "ban", "http_status": 403}) + body := mustMarshal(t, map[string]any{"action": "ban", "http_status": 403}) rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) require.NoError(t, err) @@ -78,12 +87,12 @@ func TestProcessAppSecResponse_ChallengeMinimal(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{ - "action": "challenge", - "http_status": 200, - "user_body_content": "challenge", - "user_headers": map[string][]string{"Content-Type": {"text/html"}}, - "user_cookies": []string{}, + body := mustMarshal(t, map[string]any{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{"Content-Type": {"text/html"}}, + "user_cookies": []string{}, }) rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) @@ -101,14 +110,14 @@ func TestProcessAppSecResponse_ChallengeWithAllHeaders(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{ + body := mustMarshal(t, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "challenge", "user_headers": map[string][]string{ - "Content-Type": {"text/html; charset=utf-8"}, - "Content-Security-Policy": {"default-src 'self'"}, - "Cache-Control": {"no-store, no-cache"}, + "Content-Type": {"text/html; charset=utf-8"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, }, "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, }) @@ -179,7 +188,7 @@ func TestValidateRequest_Ban(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"action": "ban", "http_status": 403}) + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"action": "ban", "http_status": 403})) })) defer srv.Close() @@ -199,7 +208,7 @@ func TestValidateRequest_Challenge(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": challengeHTML, @@ -209,7 +218,7 @@ func TestValidateRequest_Challenge(t *testing.T) { "Cache-Control": {"no-store"}, }, "user_cookies": []string{"__crowdsec_challenge=xyz; HttpOnly"}, - }) + })) })) defer srv.Close() @@ -253,7 +262,7 @@ func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { defer srv.Close() a := newTestAppSec(srv.URL, "secret-api-key") - _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + _, _, err := a.ValidateRequest(context.Background(), &AppSecRequest{ Host: "myhost.com", Method: "POST", URL: "/login", @@ -261,6 +270,7 @@ func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { UserAgent: "Mozilla/5.0", Version: "1.1", }) + require.NoError(t, err) require.NotNil(t, capturedReq) assert.Equal(t, "10.0.0.1", capturedReq.Header.Get("X-Crowdsec-Appsec-Ip")) @@ -276,20 +286,21 @@ func TestValidateRequest_PostWithBody(t *testing.T) { var receivedBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, http.MethodPost, r.Method) var err error receivedBody, err = io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) })) defer srv.Close() a := newTestAppSec(srv.URL, "key") payload := []byte("t=token&n=nonce&f=fingerprint") - _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + _, _, err := a.ValidateRequest(context.Background(), &AppSecRequest{ Host: "example.com", Method: "POST", URL: "/submit", RemoteIP: "1.2.3.4", Body: payload, }) + require.NoError(t, err) assert.Equal(t, payload, receivedBody) } diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go index 06807c79..7c61f423 100644 --- a/pkg/spoa/challenge_server.go +++ b/pkg/spoa/challenge_server.go @@ -52,7 +52,10 @@ func (cs *ChallengeServer) Serve(ctx context.Context) error { go func() { <-ctx.Done() - _ = srv.Shutdown(context.Background()) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + + _ = srv.Shutdown(shutdownCtx) }() cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go index bbb64acb..873ba1d1 100644 --- a/pkg/spoa/challenge_server_test.go +++ b/pkg/spoa/challenge_server_test.go @@ -16,15 +16,17 @@ import ( // mockAppSecHandler returns an http.Handler that responds with the given JSON body // and status code to simulate the AppSec engine's wire format. -func mockAppSecHandler(statusCode int, payload interface{}) http.Handler { +func mockAppSecHandler(statusCode int, payload any) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - _ = json.NewEncoder(w).Encode(payload) + if err := json.NewEncoder(w).Encode(payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } }) } -func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*ChallengeServer, *httptest.Server) { +func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) *ChallengeServer { t.Helper() appSecSrv := httptest.NewServer(appSecHandler) t.Cleanup(appSecSrv.Close) @@ -38,8 +40,7 @@ func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*Chall // logger exposed through the embedded unexported field; set via Init below } - cs := newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) - return cs, appSecSrv + return newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) } // roundtrip sends req to the ChallengeServer and returns the recorded response. @@ -54,7 +55,7 @@ func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorde func TestChallengeServer_ServesChallengePage(t *testing.T) { const challengeHTML = "CrowdSec Challenge" - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": challengeHTML, @@ -66,7 +67,7 @@ func TestChallengeServer_ServesChallengePage(t *testing.T) { "user_cookies": []string{}, })) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -78,7 +79,7 @@ func TestChallengeServer_ServesChallengePage(t *testing.T) { } func TestChallengeServer_ForwardsCookies(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": `{"status":"ok"}`, @@ -98,7 +99,7 @@ func TestChallengeServer_ForwardsCookies(t *testing.T) { } func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "ok", @@ -109,7 +110,7 @@ func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { }, })) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -120,9 +121,9 @@ func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -136,9 +137,9 @@ func TestChallengeServer_UsesRealIPHeader(t *testing.T) { capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") roundtrip(cs, req) @@ -151,9 +152,9 @@ func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.RemoteAddr = "203.0.113.5:12345" // No ChallengeRealIPHeader set roundtrip(cs, req) @@ -166,17 +167,17 @@ func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error receivedBody, err = io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) // Return a challenge response so ServeHTTP doesn't return early w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "ok", "user_headers": map[string][]string{}, "user_cookies": []string{}, - }) + })) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", @@ -194,9 +195,9 @@ func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { capturedHeaders = r.Header.Clone() w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") req.Header.Set("X-Forwarded-For", "10.0.0.1") roundtrip(cs, req) @@ -210,7 +211,7 @@ func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { const workerJS = "// pow worker" - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": workerJS, @@ -221,7 +222,7 @@ func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { "user_cookies": []string{}, })) - req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", nil) + req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -232,7 +233,7 @@ func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { } func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": `{"status":"failed"}`, diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 17ab7844..2ed89c9d 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -75,8 +75,8 @@ var ( type Spoa struct { ListenAddr net.Listener - ListenSocket net.Listener - logger *log.Entry + ListenSocket net.Listener + logger *log.Entry // Direct access to shared data (no IPC needed) dataset *dataset.DataSet hostManager *host.Manager @@ -86,14 +86,14 @@ type Spoa struct { } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -531,7 +531,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) // Challenge content is served by the challenge HTTP server, not via SPOE vars. } return @@ -555,7 +555,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r, _ = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) } @@ -584,7 +584,6 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { } // validateWithAppSec performs AppSec validation and returns the remediation. -// When AppSec issues a challenge the second return value carries the page content. func (s *Spoa) validateWithAppSec( ctx context.Context, msgData *HTTPMessageData, @@ -592,7 +591,7 @@ func (s *Spoa) validateWithAppSec( appSecToUse *appsec.AppSec, currentRemediation remediation.Remediation, requestTimeout time.Duration, -) (remediation.Remediation, *appsec.AppSecChallengeData) { +) remediation.Remediation { appSecReq := msgData.buildAppSecRequest() logger := s.logger @@ -606,10 +605,10 @@ func (s *Spoa) validateWithAppSec( appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, _, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") - return currentRemediation, nil + return currentRemediation } logger.WithField("remediation", appSecRemediation.String()).Debug("AppSec validation result") @@ -628,12 +627,11 @@ func (s *Spoa) validateWithAppSec( if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } - return appSecRemediation, challengeData + return appSecRemediation } - return currentRemediation, nil + return currentRemediation } - // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { req := &appsec.AppSecRequest{ From 02c2f92b2a6c402881bc783c6fa68276e7071af3 Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 14:27:23 +0200 Subject: [PATCH 06/18] add some doc --- CHALLENGE.md | 266 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 CHALLENGE.md diff --git a/CHALLENGE.md b/CHALLENGE.md new file mode 100644 index 00000000..2199526f --- /dev/null +++ b/CHALLENGE.md @@ -0,0 +1,266 @@ +# AppSec Challenge Workflow + +This document explains how an AppSec browser challenge travels through HAProxy +and the SPOA bouncer, and which component is responsible for each part of the +flow. + +The challenge is served on the original requested URL, including `/`. A public +`/challenge` endpoint is not required. + +## Responsibility Summary + +| Component | Responsibilities | +| --- | --- | +| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads the returned remediation, and routes `challenge` requests to the bouncer challenge HTTP listener. | +| SPOA bouncer listener | Evaluates CrowdSec decisions and host policy, calls AppSec, and sets `txn.crowdsec.remediation=challenge` when AppSec requests a challenge. | +| Bouncer challenge HTTP listener | Receives requests routed by HAProxy, calls AppSec again, and returns AppSec's challenge body, headers, and cookies to the browser. | +| CrowdSec AppSec | Decides whether a request is allowed, banned, or challenged. It generates the challenge page, assets, submit responses, and challenge cookies. | +| Application backend | Receives the request only when the final remediation is `allow`. | + +HAProxy owns request routing. The bouncer cannot route the browser connection +from its SPOE listener because SPOE only returns actions and transaction +variables to HAProxy. + +The bouncer owns both the CrowdSec decision logic and the internal HTTP listener +that serves AppSec challenge responses. + +## Listeners + +The bouncer exposes two different listeners: + +```text +:9000 SPOA listener +:9001 Challenge HTTP listener +``` + +- The SPOA listener on `:9000` communicates with HAProxy through the SPOE + protocol. It returns the remediation decision, but not the challenge HTML. +- The challenge listener on `:9001` communicates through normal HTTP. HAProxy + routes challenged browser requests to it so large HTML, JavaScript, JSON, and + cookies do not pass through SPOE. + +The challenge listener should only be reachable by HAProxy. + +## Remediation Value + +The bouncer defines `challenge` as a dedicated remediation. Its ordering is: + +```text +allow < unknown < captcha < challenge < ban +``` + +This makes `challenge` more restrictive than captcha and less restrictive than +ban when the bouncer selects the most restrictive remediation. + +## End-to-End Workflow + +```mermaid +sequenceDiagram + participant Browser + participant HAProxy + participant SPOA as Bouncer SPOA listener :9000 + participant Challenge as Bouncer challenge listener :9001 + participant AppSec as CrowdSec AppSec + participant Backend as Application backend + + Browser->>HAProxy: Request original URL, for example / + HAProxy->>SPOA: SPOE HTTP message + SPOA->>AppSec: Validate request + AppSec-->>SPOA: action=challenge + SPOA-->>HAProxy: txn.crowdsec.remediation=challenge + + Note over HAProxy: HAProxy owns the routing decision + HAProxy->>Challenge: Forward the same request and original URL + Challenge->>AppSec: Request challenge response + AppSec-->>Challenge: Challenge body, headers, and cookies + Challenge-->>HAProxy: HTTP challenge response + HAProxy-->>Browser: Challenge body, headers, and cookies + + Browser->>HAProxy: Challenge asset or proof submission + HAProxy->>SPOA: SPOE HTTP message, including body when present + SPOA->>AppSec: Validate request or proof + AppSec-->>SPOA: challenge or allow + + alt AppSec still returns challenge + SPOA-->>HAProxy: remediation=challenge + HAProxy->>Challenge: Forward request + Challenge->>AppSec: Request response + AppSec-->>Challenge: Asset or submit response + Challenge-->>Browser: Response + else AppSec returns allow + SPOA-->>HAProxy: remediation=allow + HAProxy->>Backend: Forward allowed request + Backend-->>Browser: Application response + end +``` + +## Phase 1: Decide Through SPOE + +HAProxy sends each HTTP request to the SPOA bouncer: + +```haproxy +http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found } +http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found } +``` + +The SPOA bouncer: + +1. Checks the current CrowdSec IP decision. +2. Applies host-specific policy. +3. Calls AppSec when AppSec validation is enabled. +4. Selects the most restrictive remediation. +5. Sets the HAProxy transaction remediation. + +When AppSec requests a browser challenge, the bouncer returns: + +```text +txn.crowdsec.remediation = challenge +``` + +The challenge body is intentionally not returned through SPOE because it can be +larger than a SPOE frame. + +The first AppSec call is only used to decide the remediation. Even when AppSec +returns challenge content with that decision, the content is not sent back +through SPOE. + +## Phase 2: Route And Serve The Challenge + +After receiving `remediation=challenge`, HAProxy: + +1. Adds the trusted real-client-IP header. +2. Routes the original request to the challenge backend. +3. Does not invoke the Lua ban/captcha renderer. + +```haproxy +http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } + +use_backend crowdsec_challenge if { var(txn.crowdsec.remediation) -m str "challenge" } +use_backend app + +backend crowdsec_challenge + mode http + server challenge 127.0.0.1:9001 +``` + +This rule is based on the remediation, not the request path. If AppSec challenges +`/`, HAProxy forwards `/` to the challenge listener. Challenge assets and proof +submissions follow the same routing process. + +The bouncer challenge HTTP listener then: + +1. Reads the client IP from `X-Crowdsec-Real-Ip`. +2. Falls back to the connection's `RemoteAddr` when the header is absent. +3. Reads the request body with a 10 MB limit. +4. Clones the original request headers. +5. Removes `X-Crowdsec-Real-Ip` and `X-Forwarded-For`. +6. Reconstructs the AppSec request using the original host, method, URL, + headers, body, user agent, and client IP. +7. Calls AppSec. +8. Writes AppSec's status, body, selected headers, and `Set-Cookie` values back + to HAProxy. + +HAProxy returns that response to the browser. + +If AppSec returns `allow` during this second call, the challenge listener +returns HTTP `200` with an empty body. The next browser request, carrying the +solved challenge cookie, follows the normal SPOE decision flow and can be routed +to the application backend. + +## AppSec Challenge Response + +When AppSec decides to challenge a request, it returns HTTP `403` to the bouncer +with a JSON envelope: + +```json +{ + "action": "challenge", + "http_status": 200, + "user_body_content": "...", + "user_headers": { + "Content-Type": ["text/html"], + "Content-Security-Policy": ["default-src 'self'"], + "Cache-Control": ["no-cache, no-store"] + }, + "user_cookies": [ + "__crowdsec_challenge=...; HttpOnly; Path=/; SameSite=Lax" + ] +} +``` + +The bouncer interprets the fields as follows: + +- `action` must be `challenge`; any other `403` action is treated as `ban`. +- `http_status` is returned to the browser. The challenge listener defaults to + `200` when it is absent or invalid. +- `user_body_content` contains the HTML, JavaScript, or JSON response body. +- `user_headers` contains selected response headers forwarded to the browser. +- `user_cookies` contains individual `Set-Cookie` values forwarded to the + browser. + +An empty or invalid JSON body on an AppSec `403` response defaults to `ban`. + +## Configuration + +Enable AppSec and the challenge HTTP listener in the bouncer configuration: + +```yaml +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms +challenge_listen: 127.0.0.1:9001 +``` + +When HAProxy and the bouncer run in different containers or hosts, bind the +challenge listener to a trusted private interface and point the HAProxy +`crowdsec_challenge` backend to that address. + +## Challenge Assets, Proof Submission, And Cookies + +AppSec may instruct the browser to request challenge assets or submit proof +data, for example: + +```text +/crowdsec-internal/challenge/pow-worker.js +/crowdsec-internal/challenge/submit +``` + +HAProxy must continue sending these requests through SPOE. Proof submissions +usually contain a request body, so the body-capable SPOE group must be used when +the body is within the configured limit. + +AppSec owns challenge cookies and validates solved challenge state. The bouncer +does not interpret these cookies; its challenge listener forwards AppSec's +`Set-Cookie` values to the browser. + +Solved state is visible to AppSec on subsequent requests through the browser's +normal `Cookie` header. When AppSec recognizes a valid challenge cookie, it can +return `allow`. + +## Failure Behavior + +During SPOE validation, AppSec responses are interpreted as follows: + +| AppSec response | Bouncer result | +| --- | --- | +| HTTP `200` | `allow` | +| HTTP `403` with `action=challenge` | `challenge` | +| HTTP `403` with another action | `ban` | +| HTTP `403` with an empty or invalid body | `ban` | +| HTTP `401`, `500`, or another unexpected status | Keep the existing remediation and log an error | + +If the AppSec call made by the challenge HTTP listener fails, the listener +returns HTTP `500`. If AppSec returns `allow` during challenge handling, the +listener returns HTTP `200` with an empty body. + +## Security Requirements + +- Do not expose the challenge HTTP listener directly to the internet. +- Only trust `X-Crowdsec-Real-Ip` when it is added by HAProxy. +- Bind AppSec and both bouncer listeners to loopback or trusted private + interfaces. +- Do not send `challenge` remediation through the Lua ban/captcha renderer. +- Keep challenge response bodies out of SPOE. +- Preserve repeated `Set-Cookie` headers individually. From f0c0a7b0f506a390969c4cbeccb25c2b0a0e1225 Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 15:18:30 +0200 Subject: [PATCH 07/18] improve doc --- CHALLENGE.md | 6 +++--- config/crowdsec-spoa-bouncer.docker.yaml | 10 +++++++--- config/haproxy-upstreamproxy.cfg | 9 +++++++++ config/haproxy.cfg | 12 ++++++++++-- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/CHALLENGE.md b/CHALLENGE.md index 2199526f..f713822a 100644 --- a/CHALLENGE.md +++ b/CHALLENGE.md @@ -138,10 +138,10 @@ http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediat http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } -use_backend crowdsec_challenge if { var(txn.crowdsec.remediation) -m str "challenge" } +use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend app -backend crowdsec_challenge +backend crowdsec-challenge mode http server challenge 127.0.0.1:9001 ``` @@ -215,7 +215,7 @@ challenge_listen: 127.0.0.1:9001 When HAProxy and the bouncer run in different containers or hosts, bind the challenge listener to a trusted private interface and point the HAProxy -`crowdsec_challenge` backend to that address. +`crowdsec-challenge` backend to that address. ## Challenge Assets, Proof Submission, And Cookies diff --git a/config/crowdsec-spoa-bouncer.docker.yaml b/config/crowdsec-spoa-bouncer.docker.yaml index 46de4280..f840c61f 100644 --- a/config/crowdsec-spoa-bouncer.docker.yaml +++ b/config/crowdsec-spoa-bouncer.docker.yaml @@ -21,9 +21,13 @@ listen_tcp: ${LISTEN_TCP} #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb -## Global AppSec configuration (optional) -#appsec_url: ${APPSEC_URL} -#appsec_timeout: ${APPSEC_TIMEOUT} +## Global AppSec configuration +appsec_url: ${APPSEC_URL} +appsec_timeout: ${APPSEC_TIMEOUT} + +## Challenge HTTP listener +## HAProxy routes remediation=challenge requests to this listener. +challenge_listen: ${CHALLENGE_LISTEN} ## Prometheus metrics endpoint prometheus: diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index c691a772..c2b9b8b4 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -70,6 +70,10 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } + ## Route challenge requests through the bouncer challenge HTTP listener. + ## This applies to the original application URL, including /; no public /challenge route is required. + http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + ## Call lua script only for ban and captcha remediations (performance optimization) http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } @@ -80,6 +84,7 @@ frontend test ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -91,3 +96,7 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 + +backend crowdsec-challenge + mode http + server challenge spoa:9001 diff --git a/config/haproxy.cfg b/config/haproxy.cfg index f9b4552f..0f4ceab2 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -54,10 +54,13 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } - ## Call lua script for ban, captcha, and challenge remediations + ## Route challenge requests through the bouncer challenge HTTP listener. + ## This applies to the original application URL, including /; no public /challenge route is required. + http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + + ## Call lua script for ban and captcha remediations http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } - http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) @@ -65,6 +68,7 @@ frontend test ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -76,3 +80,7 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 + +backend crowdsec-challenge + mode http + server challenge spoa:9001 From b813afe52db04263459bd6f0549e644769b39e47 Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 26 Jun 2026 18:24:42 +0200 Subject: [PATCH 08/18] remove the side http server from challenge mode --- CHALLENGE.md | 248 +++++------------ Vagrantfile | 4 +- cmd/root.go | 15 +- config/crowdsec-spoa-bouncer.docker.yaml | 4 - ...ec-spoa-bouncer.no-challenge-listener.yaml | 45 ---- config/crowdsec-spoa-bouncer.yaml | 5 - config/haproxy-upstreamproxy.cfg | 14 +- config/haproxy.cfg | 14 +- go.mod | 2 + go.sum | 4 +- lua/crowdsec.lua | 40 ++- pkg/cfg/config.go | 3 +- pkg/spoa/challenge_server.go | 152 ----------- pkg/spoa/challenge_server_test.go | 252 ------------------ pkg/spoa/root.go | 71 ++--- 15 files changed, 157 insertions(+), 716 deletions(-) delete mode 100644 config/crowdsec-spoa-bouncer.no-challenge-listener.yaml delete mode 100644 pkg/spoa/challenge_server.go delete mode 100644 pkg/spoa/challenge_server_test.go diff --git a/CHALLENGE.md b/CHALLENGE.md index f713822a..8c6de990 100644 --- a/CHALLENGE.md +++ b/CHALLENGE.md @@ -1,47 +1,26 @@ # AppSec Challenge Workflow This document explains how an AppSec browser challenge travels through HAProxy -and the SPOA bouncer, and which component is responsible for each part of the -flow. +and the SPOA bouncer. The challenge is served on the original requested URL, including `/`. A public -`/challenge` endpoint is not required. +`/challenge` endpoint is not required, and the bouncer does not expose a +separate challenge HTTP listener. -## Responsibility Summary +## Components -| Component | Responsibilities | -| --- | --- | -| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads the returned remediation, and routes `challenge` requests to the bouncer challenge HTTP listener. | -| SPOA bouncer listener | Evaluates CrowdSec decisions and host policy, calls AppSec, and sets `txn.crowdsec.remediation=challenge` when AppSec requests a challenge. | -| Bouncer challenge HTTP listener | Receives requests routed by HAProxy, calls AppSec again, and returns AppSec's challenge body, headers, and cookies to the browser. | +| Component | Role | +|---|---| +| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads returned transaction variables, and calls Lua for challenge responses. | +| SPOA bouncer | Evaluates CrowdSec decisions and host policy, calls AppSec, and returns remediation plus challenge response data through SPOE variables. | +| HAProxy Lua handler | Writes the challenge body, status, headers, and cookie returned by the bouncer to the browser. | | CrowdSec AppSec | Decides whether a request is allowed, banned, or challenged. It generates the challenge page, assets, submit responses, and challenge cookies. | -| Application backend | Receives the request only when the final remediation is `allow`. | -HAProxy owns request routing. The bouncer cannot route the browser connection -from its SPOE listener because SPOE only returns actions and transaction -variables to HAProxy. +The bouncer runs one SPOA listener. Challenge content is returned over SPOE; this +assumes the configured HAProxy/SPOE frame size is large enough for the AppSec +challenge responses used in the deployment. -The bouncer owns both the CrowdSec decision logic and the internal HTTP listener -that serves AppSec challenge responses. - -## Listeners - -The bouncer exposes two different listeners: - -```text -:9000 SPOA listener -:9001 Challenge HTTP listener -``` - -- The SPOA listener on `:9000` communicates with HAProxy through the SPOE - protocol. It returns the remediation decision, but not the challenge HTML. -- The challenge listener on `:9001` communicates through normal HTTP. HAProxy - routes challenged browser requests to it so large HTML, JavaScript, JSON, and - cookies do not pass through SPOE. - -The challenge listener should only be reachable by HAProxy. - -## Remediation Value +## Remediation Ordering The bouncer defines `challenge` as a dedicated remediation. Its ordering is: @@ -50,140 +29,85 @@ allow < unknown < captcha < challenge < ban ``` This makes `challenge` more restrictive than captcha and less restrictive than -ban when the bouncer selects the most restrictive remediation. +ban. AppSec may therefore upgrade an allowed request to `challenge`, but a +dataset ban still wins over an AppSec challenge. -## End-to-End Workflow +## Request Flow ```mermaid sequenceDiagram participant Browser participant HAProxy - participant SPOA as Bouncer SPOA listener :9000 - participant Challenge as Bouncer challenge listener :9001 + participant SPOA as SPOA bouncer participant AppSec as CrowdSec AppSec - participant Backend as Application backend - - Browser->>HAProxy: Request original URL, for example / - HAProxy->>SPOA: SPOE HTTP message - SPOA->>AppSec: Validate request - AppSec-->>SPOA: action=challenge - SPOA-->>HAProxy: txn.crowdsec.remediation=challenge + participant Lua as HAProxy Lua - Note over HAProxy: HAProxy owns the routing decision - HAProxy->>Challenge: Forward the same request and original URL - Challenge->>AppSec: Request challenge response - AppSec-->>Challenge: Challenge body, headers, and cookies - Challenge-->>HAProxy: HTTP challenge response - HAProxy-->>Browser: Challenge body, headers, and cookies + Browser->>HAProxy: GET / + HAProxy->>SPOA: crowdsec-http-body or crowdsec-http-no-body + SPOA->>AppSec: Request metadata, headers, body when available + AppSec-->>SPOA: action=challenge + body/headers/cookie + SPOA-->>HAProxy: remediation=challenge + challenge_* vars + HAProxy->>Lua: crowdsec_handle + Lua-->>Browser: Challenge response Browser->>HAProxy: Challenge asset or proof submission - HAProxy->>SPOA: SPOE HTTP message, including body when present - SPOA->>AppSec: Validate request or proof - AppSec-->>SPOA: challenge or allow + HAProxy->>SPOA: Same original URL/path and request data + SPOA->>AppSec: Validate request alt AppSec still returns challenge - SPOA-->>HAProxy: remediation=challenge - HAProxy->>Challenge: Forward request - Challenge->>AppSec: Request response - AppSec-->>Challenge: Asset or submit response - Challenge-->>Browser: Response - else AppSec returns allow + AppSec-->>SPOA: Challenge asset or submit response + SPOA-->>HAProxy: remediation=challenge + challenge_* vars + Lua-->>Browser: AppSec response + else AppSec allows + AppSec-->>SPOA: allow SPOA-->>HAProxy: remediation=allow - HAProxy->>Backend: Forward allowed request - Backend-->>Browser: Application response + HAProxy->>Backend: Forward request end ``` -## Phase 1: Decide Through SPOE +## HAProxy Wiring -HAProxy sends each HTTP request to the SPOA bouncer: +The HTTP SPOE groups must send enough request data for AppSec to make a decision: ```haproxy http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found } http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found } ``` -The SPOA bouncer: - -1. Checks the current CrowdSec IP decision. -2. Applies host-specific policy. -3. Calls AppSec when AppSec validation is enabled. -4. Selects the most restrictive remediation. -5. Sets the HAProxy transaction remediation. - -When AppSec requests a browser challenge, the bouncer returns: - -```text -txn.crowdsec.remediation = challenge -``` - -The challenge body is intentionally not returned through SPOE because it can be -larger than a SPOE frame. - -The first AppSec call is only used to decide the remediation. Even when AppSec -returns challenge content with that decision, the content is not sent back -through SPOE. - -## Phase 2: Route And Serve The Challenge - -After receiving `remediation=challenge`, HAProxy: - -1. Adds the trusted real-client-IP header. -2. Routes the original request to the challenge backend. -3. Does not invoke the Lua ban/captcha renderer. +When AppSec returns `challenge`, the bouncer sets transaction variables and +HAProxy calls the Lua response handler: ```haproxy -http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } - -use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } -use_backend app - -backend crowdsec-challenge - mode http - server challenge 127.0.0.1:9001 ``` -This rule is based on the remediation, not the request path. If AppSec challenges -`/`, HAProxy forwards `/` to the challenge listener. Challenge assets and proof -submissions follow the same routing process. - -The bouncer challenge HTTP listener then: +The Lua handler uses these transaction variables for challenge responses: -1. Reads the client IP from `X-Crowdsec-Real-Ip`. -2. Falls back to the connection's `RemoteAddr` when the header is absent. -3. Reads the request body with a 10 MB limit. -4. Clones the original request headers. -5. Removes `X-Crowdsec-Real-Ip` and `X-Forwarded-For`. -6. Reconstructs the AppSec request using the original host, method, URL, - headers, body, user agent, and client IP. -7. Calls AppSec. -8. Writes AppSec's status, body, selected headers, and `Set-Cookie` values back - to HAProxy. - -HAProxy returns that response to the browser. - -If AppSec returns `allow` during this second call, the challenge listener -returns HTTP `200` with an empty body. The next browser request, carrying the -solved challenge cookie, follows the normal SPOE decision flow and can be routed -to the application backend. +| Variable | Meaning | +|---|---| +| `txn.crowdsec.challenge_status` | HTTP status returned to the browser. Defaults to `200` if missing. | +| `txn.crowdsec.challenge_body` | AppSec response body. | +| `txn.crowdsec.challenge_content_type` | Optional `Content-Type` header. | +| `txn.crowdsec.challenge_csp` | Optional `Content-Security-Policy` header. | +| `txn.crowdsec.challenge_cache_control` | Optional `Cache-Control` header. | +| `txn.crowdsec.challenge_cookie` | Optional `Set-Cookie` header. | ## AppSec Challenge Response When AppSec decides to challenge a request, it returns HTTP `403` to the bouncer -with a JSON envelope: +with a JSON body similar to: ```json { "action": "challenge", "http_status": 200, - "user_body_content": "...", + "user_body_content": "...", "user_headers": { "Content-Type": ["text/html"], "Content-Security-Policy": ["default-src 'self'"], - "Cache-Control": ["no-cache, no-store"] + "Cache-Control": ["no-store"] }, "user_cookies": [ "__crowdsec_challenge=...; HttpOnly; Path=/; SameSite=Lax" @@ -191,76 +115,28 @@ with a JSON envelope: } ``` -The bouncer interprets the fields as follows: +Rules: - `action` must be `challenge`; any other `403` action is treated as `ban`. -- `http_status` is returned to the browser. The challenge listener defaults to - `200` when it is absent or invalid. -- `user_body_content` contains the HTML, JavaScript, or JSON response body. -- `user_headers` contains selected response headers forwarded to the browser. -- `user_cookies` contains individual `Set-Cookie` values forwarded to the - browser. - -An empty or invalid JSON body on an AppSec `403` response defaults to `ban`. +- `http_status` is returned to the browser, defaulting to `200` if omitted. +- `user_body_content` becomes `txn.crowdsec.challenge_body`. +- Selected `user_headers` are forwarded through challenge transaction variables. +- The first `user_cookies` value is forwarded as `Set-Cookie`. ## Configuration -Enable AppSec and the challenge HTTP listener in the bouncer configuration: +Enable AppSec in the bouncer configuration: ```yaml appsec_url: http://127.0.0.1:7422/ appsec_timeout: 200ms -challenge_listen: 127.0.0.1:9001 -``` - -When HAProxy and the bouncer run in different containers or hosts, bind the -challenge listener to a trusted private interface and point the HAProxy -`crowdsec-challenge` backend to that address. - -## Challenge Assets, Proof Submission, And Cookies - -AppSec may instruct the browser to request challenge assets or submit proof -data, for example: - -```text -/crowdsec-internal/challenge/pow-worker.js -/crowdsec-internal/challenge/submit ``` -HAProxy must continue sending these requests through SPOE. Proof submissions -usually contain a request body, so the body-capable SPOE group must be used when -the body is within the configured limit. - -AppSec owns challenge cookies and validates solved challenge state. The bouncer -does not interpret these cookies; its challenge listener forwards AppSec's -`Set-Cookie` values to the browser. - -Solved state is visible to AppSec on subsequent requests through the browser's -normal `Cookie` header. When AppSec recognizes a valid challenge cookie, it can -return `allow`. - -## Failure Behavior - -During SPOE validation, AppSec responses are interpreted as follows: - -| AppSec response | Bouncer result | -| --- | --- | -| HTTP `200` | `allow` | -| HTTP `403` with `action=challenge` | `challenge` | -| HTTP `403` with another action | `ban` | -| HTTP `403` with an empty or invalid body | `ban` | -| HTTP `401`, `500`, or another unexpected status | Keep the existing remediation and log an error | - -If the AppSec call made by the challenge HTTP listener fails, the listener -returns HTTP `500`. If AppSec returns `allow` during challenge handling, the -listener returns HTTP `200` with an empty body. +No `challenge_listen` setting is required. -## Security Requirements +## Notes -- Do not expose the challenge HTTP listener directly to the internet. -- Only trust `X-Crowdsec-Real-Ip` when it is added by HAProxy. -- Bind AppSec and both bouncer listeners to loopback or trusted private - interfaces. -- Do not send `challenge` remediation through the Lua ban/captcha renderer. -- Keep challenge response bodies out of SPOE. -- Preserve repeated `Set-Cookie` headers individually. +- Captcha remediation remains separate from AppSec challenge remediation. +- Challenge assets and proof submissions are ordinary requests inspected by the same SPOE flow. +- If AppSec validation fails, the bouncer keeps the previous remediation. +- Frame size must be configured high enough for the largest challenge response sent through SPOE. diff --git a/Vagrantfile b/Vagrantfile index 1b752ac8..f0e92f59 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -84,8 +84,8 @@ EOF # Copy and configure HAProxy cp /vagrant/config/haproxy.cfg /etc/haproxy/haproxy.cfg 2>/dev/null || true cp /vagrant/config/crowdsec.cfg /etc/haproxy/crowdsec.cfg 2>/dev/null || true - # Update server addresses and remove the second SPOA server (port 9001 doesn't exist) - sed -i 's/whoami:2020/127.0.0.1:4444/g; s/spoa:9000/127.0.0.1:9000/g; /server s3 spoa:9001/d' \ + # Update server addresses for the local VM services. + sed -i 's/whoami:2020/127.0.0.1:4444/g; s/spoa:9000/127.0.0.1:9000/g' \ /etc/haproxy/haproxy.cfg 2>/dev/null || true # Increase SPOA processing timeout to accommodate AppSec calls (AppSec has 5s timeout) sed -i 's/timeout\s\+processing\s\+500ms/timeout processing 6s/' \ diff --git a/cmd/root.go b/cmd/root.go index 0a22ea38..01f76275 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,14 +261,13 @@ func Execute() error { // Create single SPOA directly with minimal configuration spoaConfig := &spoa.SpoaConfig{ - TcpAddr: config.ListenTCP, - UnixAddr: config.ListenUnix, - Dataset: dataSet, - HostManager: HostManager, - GeoDatabase: &config.Geo, - GlobalAppSec: globalAppSec, - ChallengeAddr: config.ChallengeAddr, - Logger: spoaLogger, + TcpAddr: config.ListenTCP, + UnixAddr: config.ListenUnix, + Dataset: dataSet, + HostManager: HostManager, + GeoDatabase: &config.Geo, + GlobalAppSec: globalAppSec, + Logger: spoaLogger, } singleSpoa, err := spoa.New(spoaConfig) diff --git a/config/crowdsec-spoa-bouncer.docker.yaml b/config/crowdsec-spoa-bouncer.docker.yaml index f840c61f..3abe1efd 100644 --- a/config/crowdsec-spoa-bouncer.docker.yaml +++ b/config/crowdsec-spoa-bouncer.docker.yaml @@ -25,10 +25,6 @@ listen_tcp: ${LISTEN_TCP} appsec_url: ${APPSEC_URL} appsec_timeout: ${APPSEC_TIMEOUT} -## Challenge HTTP listener -## HAProxy routes remediation=challenge requests to this listener. -challenge_listen: ${CHALLENGE_LISTEN} - ## Prometheus metrics endpoint prometheus: enabled: ${PROMETHEUS_ENABLED} diff --git a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml deleted file mode 100644 index 7ed5951c..00000000 --- a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml +++ /dev/null @@ -1,45 +0,0 @@ -## Default configuration without the challenge HTTP listener. -## Use this variant when AppSec challenge routing is not deployed yet. - -## Log configuration -log_mode: file -log_dir: /var/log/crowdsec-spoa/ -log_level: info -log_compression: true -log_max_size: 100 -log_max_backups: 3 -log_max_age: 30 - -## LAPI configuration -update_frequency: 10s -api_url: http://127.0.0.1:8080/ -api_key: ${API_KEY} -insecure_skip_verify: false - -## SPOA listener configuration -# Configure TCP and/or Unix socket listeners -listen_tcp: 0.0.0.0:9000 -listen_unix: /run/crowdsec-spoa/spoa.sock -#asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb -#city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb - -## Global AppSec configuration -## AppSec can still be enabled for allow/ban decisions without exposing a challenge listener. -appsec_url: http://127.0.0.1:7422/ -appsec_timeout: 200ms - -## Challenge HTTP listener intentionally disabled in this variant. -#challenge_listen: 127.0.0.1:9001 - -prometheus: - enabled: false - listen_addr: 127.0.0.1 - listen_port: 60601 - -## pprof debug endpoint for runtime profiling -## WARNING: Only enable for debugging, exposes internal runtime data -## Endpoints: /debug/pprof/heap, /debug/pprof/profile, /debug/pprof/goroutine, etc. -#pprof: -# enabled: false -# listen_addr: 127.0.0.1 -# listen_port: 6060 diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index b5bef770..2af5fe9c 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -25,11 +25,6 @@ listen_unix: /run/crowdsec-spoa/spoa.sock appsec_url: http://127.0.0.1:7422/ appsec_timeout: 200ms -## Challenge HTTP listener -## HAProxy routes remediation=challenge requests to this listener. -## Keep this bound to loopback unless HAProxy reaches the bouncer through a trusted private network. -challenge_listen: 127.0.0.1:9001 - prometheus: enabled: false listen_addr: 127.0.0.1 diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index c2b9b8b4..d51e30b9 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -4,7 +4,7 @@ global log stdout format raw local0 - tune.bufsize 65536 # 64KB - increased for WAF body inspection + tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua @@ -70,11 +70,8 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } - ## Route challenge requests through the bouncer challenge HTTP listener. - ## This applies to the original application URL, including /; no public /challenge route is required. - http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - - ## Call lua script only for ban and captcha remediations (performance optimization) + ## Call lua script for challenge, ban and captcha remediations + http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } @@ -84,7 +81,6 @@ frontend test ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } - use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -96,7 +92,3 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 - -backend crowdsec-challenge - mode http - server challenge spoa:9001 diff --git a/config/haproxy.cfg b/config/haproxy.cfg index 0f4ceab2..c6a7bec4 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -1,7 +1,7 @@ # https://www.haproxy.com/documentation/hapee/latest/onepage/#home global log stdout format raw local0 - tune.bufsize 65536 # 64KB - increased for WAF body inspection + tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s lua-prepend-path /usr/lib/crowdsec-haproxy-spoa-bouncer/lua/?.lua @@ -54,11 +54,8 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } - ## Route challenge requests through the bouncer challenge HTTP listener. - ## This applies to the original application URL, including /; no public /challenge route is required. - http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - - ## Call lua script for ban and captcha remediations + ## Call lua script for challenge, ban and captcha remediations + http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } @@ -68,7 +65,6 @@ frontend test ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } - use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -80,7 +76,3 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 - -backend crowdsec-challenge - mode http - server challenge spoa:9001 diff --git a/go.mod b/go.mod index 44a434bf..921ed6c7 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) +replace github.com/dropmorepackets/haproxy-go => github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d + require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index ee220f64..9efc5f75 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dropmorepackets/haproxy-go v0.0.7 h1:atXkB0MSRBZrAgpq+Vj/E4KysQ4CiI0O5QGUr+HvfTw= -github.com/dropmorepackets/haproxy-go v0.0.7/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= @@ -104,6 +102,8 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d h1:OVXewDp/fRc0gVRc0lqV/6t2jDLAREMoS1RcGxcB3ik= +github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index 160d87aa..9855b8dd 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -113,17 +113,45 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") - -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy. - -- "challenge" is handled by routing to the bouncer challenge HTTP server (no Lua needed). - -- This Lua handler is only called for "captcha" and "ban" remediations. if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end - -- Always disable cache for ban/captcha pages - reply:add_header("cache-control", "no-cache") - reply:add_header("cache-control", "no-store") + if remediation == "challenge" then + local status = get_txn_var(txn, "crowdsec.challenge_status") + if status ~= "" then + reply:set_status(tonumber(status)) + else + reply:set_status(200) + end + + reply:set_body(get_txn_var(txn, "crowdsec.challenge_body")) + + local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") + if content_type ~= "" then + reply:add_header("Content-Type", content_type) + end + + local csp = get_txn_var(txn, "crowdsec.challenge_csp") + if csp ~= "" then + reply:add_header("Content-Security-Policy", csp) + end + + local cache_control = get_txn_var(txn, "crowdsec.challenge_cache_control") + if cache_control ~= "" then + reply:add_header("Cache-Control", cache_control) + end + + local cookie = get_txn_var(txn, "crowdsec.challenge_cookie") + if cookie ~= "" then + reply:add_header("Set-Cookie", cookie) + end + + reply:add_header("Content-Length", #reply.body) + txn:done(reply) + return + end if remediation == "captcha" then reply:set_status(200) diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 9bee4cd8..11ca6514 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -38,9 +38,8 @@ type BouncerConfig struct { PrometheusConfig PrometheusConfig `yaml:"prometheus"` PprofConfig PprofConfig `yaml:"pprof"` APIKey string `yaml:"api_key"` // LAPI API key (also used for AppSec) - AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL + AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL AppSecTimeout time.Duration `yaml:"appsec_timeout,omitempty"` - ChallengeAddr string `yaml:"challenge_listen,omitempty"` // Challenge HTTP server listen address (e.g. "0.0.0.0:9001") } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go deleted file mode 100644 index 7c61f423..00000000 --- a/pkg/spoa/challenge_server.go +++ /dev/null @@ -1,152 +0,0 @@ -package spoa - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" - "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" - log "github.com/sirupsen/logrus" -) - -const ( - // ChallengeRealIPHeader is the header HAProxy adds to identify the real - // client IP when routing a challenge request to the challenge server. - ChallengeRealIPHeader = "X-Crowdsec-Real-Ip" -) - -// ChallengeServer is a plain HTTP server that HAProxy routes to when the SPOE -// agent returns remediation=challenge. It re-sends the request to the AppSec -// engine, unwraps the JSON response, and writes the challenge page (HTML, JS, -// or submit JSON) directly back to the client. -// -// This avoids the 64 KB SPOE frame-size limit: the large challenge body never -// passes through the SPOE protocol; it travels through a normal HTTP connection -// between HAProxy, this server, and the AppSec engine. -type ChallengeServer struct { - appSec *appsec.AppSec - listen string - logger *log.Entry -} - -func newChallengeServer(appSec *appsec.AppSec, addr string, logger *log.Entry) *ChallengeServer { - return &ChallengeServer{ - appSec: appSec, - listen: addr, - logger: logger.WithField("component", "challenge_server"), - } -} - -func (cs *ChallengeServer) Serve(ctx context.Context) error { - srv := &http.Server{ - Addr: cs.listen, - Handler: cs, - ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, - } - - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - - _ = srv.Shutdown(shutdownCtx) - }() - - cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) - - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("challenge server: %w", err) - } - - return nil -} - -// ServeHTTP handles a request forwarded by HAProxy when remediation=challenge. -// It builds an AppSec request from the incoming request, calls the AppSec -// engine, and writes the challenge response (body + headers + cookies) back. -func (cs *ChallengeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - clientIP := r.Header.Get(ChallengeRealIPHeader) - if clientIP == "" { - // Fall back to the connection's remote address (strips port) - if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { - clientIP = r.RemoteAddr[:idx] - } else { - clientIP = r.RemoteAddr - } - } - - // Read the request body (needed for the submit path POST) - var body []byte - if r.Body != nil { - var err error - body, err = io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit - if err != nil { - cs.logger.WithError(err).Warn("challenge server: failed to read request body") - } - } - - // Reconstruct the URL for the AppSec header - reqURL := r.URL.RequestURI() - - // Forward the original client headers to AppSec (minus hop-by-hop and our own headers) - headers := r.Header.Clone() - headers.Del(ChallengeRealIPHeader) - headers.Del("X-Forwarded-For") - - appSecReq := &appsec.AppSecRequest{ - Host: r.Host, - Method: r.Method, - URL: reqURL, - RemoteIP: clientIP, - UserAgent: r.UserAgent(), - Headers: headers, - Body: body, - } - - appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - - rem, challengeData, err := cs.appSec.ValidateRequest(appSecCtx, appSecReq) - if err != nil { - cs.logger.WithError(err).Error("challenge server: AppSec request failed") - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - if rem != remediation.Challenge || challengeData == nil { - // AppSec didn't return a challenge (e.g. allow after valid cookie). - // Return 200 with an empty body; HAProxy will handle the pass-through. - w.WriteHeader(http.StatusOK) - return - } - - // Write response headers from AppSec - if challengeData.ContentType != "" { - w.Header().Set("Content-Type", challengeData.ContentType) - } - if challengeData.CSP != "" { - w.Header().Set("Content-Security-Policy", challengeData.CSP) - } - if challengeData.CacheControl != "" { - w.Header().Set("Cache-Control", challengeData.CacheControl) - } - for _, cookie := range challengeData.Cookies { - w.Header().Add("Set-Cookie", cookie) - } - - status := challengeData.StatusCode - if status <= 0 { - status = http.StatusOK - } - - w.WriteHeader(status) - if challengeData.Body != "" { - _, _ = io.Copy(w, bytes.NewBufferString(challengeData.Body)) - } -} diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go deleted file mode 100644 index 873ba1d1..00000000 --- a/pkg/spoa/challenge_server_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package spoa - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockAppSecHandler returns an http.Handler that responds with the given JSON body -// and status code to simulate the AppSec engine's wire format. -func mockAppSecHandler(statusCode int, payload any) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(payload); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) -} - -func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) *ChallengeServer { - t.Helper() - appSecSrv := httptest.NewServer(appSecHandler) - t.Cleanup(appSecSrv.Close) - - a := &appsec.AppSec{} - a.InitLogger(log.NewEntry(log.New())) - a.Client = &appsec.AppSecClient{ - HTTPClient: &http.Client{}, - APIKey: "test-api-key", - URL: appSecSrv.URL, - // logger exposed through the embedded unexported field; set via Init below - } - - return newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) -} - -// roundtrip sends req to the ChallengeServer and returns the recorded response. -func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorder { - rr := httptest.NewRecorder() - cs.ServeHTTP(rr, req) - return rr -} - -// ---------- tests ---------- - -func TestChallengeServer_ServesChallengePage(t *testing.T) { - const challengeHTML = "CrowdSec Challenge" - - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": challengeHTML, - "user_headers": map[string][]string{ - "Content-Type": {"text/html"}, - "Content-Security-Policy": {"default-src 'self'"}, - "Cache-Control": {"no-store, no-cache"}, - }, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, challengeHTML, rr.Body.String()) - assert.Equal(t, "text/html", rr.Header().Get("Content-Type")) - assert.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) - assert.Equal(t, "no-store, no-cache", rr.Header().Get("Cache-Control")) -} - -func TestChallengeServer_ForwardsCookies(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": `{"status":"ok"}`, - "user_headers": map[string][]string{"Content-Type": {"application/json"}}, - "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax"}, - })) - - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", strings.NewReader("t=tok&n=nonce")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - cookies := rr.Header()["Set-Cookie"] - require.Len(t, cookies, 1) - assert.Equal(t, "__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax", cookies[0]) -} - -func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": "ok", - "user_headers": map[string][]string{}, - "user_cookies": []string{ - "__crowdsec_challenge=abc; HttpOnly", - "session=xyz; Secure", - }, - })) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - cookies := rr.Header()["Set-Cookie"] - assert.Len(t, cookies, 2) -} - -func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { - // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) - - req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Empty(t, rr.Body.String()) -} - -func TestChallengeServer_UsesRealIPHeader(t *testing.T) { - var capturedIP string - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") - roundtrip(cs, req) - - assert.Equal(t, "203.0.113.42", capturedIP) -} - -func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { - var capturedIP string - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.RemoteAddr = "203.0.113.5:12345" - // No ChallengeRealIPHeader set - roundtrip(cs, req) - - assert.Equal(t, "203.0.113.5", capturedIP) -} - -func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { - var receivedBody []byte - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var err error - receivedBody, err = io.ReadAll(r.Body) - assert.NoError(t, err) - // Return a challenge response so ServeHTTP doesn't return early - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ - "action": "challenge", "http_status": 200, - "user_body_content": "ok", "user_headers": map[string][]string{}, - "user_cookies": []string{}, - })) - }) - cs := newChallengeServerForTest(t, handler) - - payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", - strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - roundtrip(cs, req) - - assert.Equal(t, payload, string(receivedBody)) -} - -func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { - var capturedHeaders http.Header - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedHeaders = r.Header.Clone() - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - req.Header.Set("X-Forwarded-For", "10.0.0.1") - roundtrip(cs, req) - - assert.Empty(t, capturedHeaders.Get(ChallengeRealIPHeader), - "X-Crowdsec-Real-Ip must not be forwarded to AppSec") - assert.Empty(t, capturedHeaders.Get("X-Forwarded-For"), - "X-Forwarded-For must not be forwarded to AppSec") -} - -func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { - const workerJS = "// pow worker" - - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": workerJS, - "user_headers": map[string][]string{ - "Content-Type": {"application/javascript"}, - "Cache-Control": {"public, max-age=3600"}, - }, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type")) - assert.Equal(t, "public, max-age=3600", rr.Header().Get("Cache-Control")) - assert.Equal(t, workerJS, rr.Body.String()) -} - -func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": `{"status":"failed"}`, - "user_headers": map[string][]string{"Content-Type": {"application/json"}, "Cache-Control": {"no-cache, no-store"}}, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", - strings.NewReader("t=bad&ts=bad&h=bad&n=bad&p=bad&m=bad&f=bad")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), `"status":"failed"`) -} diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 2ed89c9d..bd26fbaa 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -78,22 +78,20 @@ type Spoa struct { ListenSocket net.Listener logger *log.Entry // Direct access to shared data (no IPC needed) - dataset *dataset.DataSet - hostManager *host.Manager - geoDatabase *geo.GeoDatabase - globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - challengeServer *ChallengeServer + dataset *dataset.DataSet + hostManager *host.Manager + geoDatabase *geo.GeoDatabase + globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -123,10 +121,6 @@ func New(config *SpoaConfig) (*Spoa, error) { globalAppSec: config.GlobalAppSec, } - if config.ChallengeAddr != "" && config.GlobalAppSec != nil && config.GlobalAppSec.IsValid() { - s.challengeServer = newChallengeServer(config.GlobalAppSec, config.ChallengeAddr, workerLogger) - } - if config.TcpAddr != "" { addr, err := net.Listen("tcp", config.TcpAddr) if err != nil { @@ -219,15 +213,6 @@ func (s *Spoa) Serve(ctx context.Context) error { return nil } - // Launch the challenge HTTP server if configured - if s.challengeServer != nil { - go func() { - if err := s.challengeServer.Serve(ctx); err != nil { - serverError <- err - } - }() - } - select { case err := <-serverError: return err @@ -531,8 +516,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) - // Challenge content is served by the challenge HTTP server, not via SPOE vars. + r = s.validateWithAppSec(ctx, writer, msgData, nil, appSec, r, timeout) } return } @@ -555,7 +539,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + r = s.validateWithAppSec(ctx, writer, msgData, matchedHost, appSec, r, timeout) if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) } @@ -586,6 +570,7 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { // validateWithAppSec performs AppSec validation and returns the remediation. func (s *Spoa) validateWithAppSec( ctx context.Context, + writer *encoding.ActionWriter, msgData *HTTPMessageData, matchedHost *host.Host, appSecToUse *appsec.AppSec, @@ -605,7 +590,7 @@ func (s *Spoa) validateWithAppSec( appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, _, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") return currentRemediation @@ -627,11 +612,37 @@ func (s *Spoa) validateWithAppSec( if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } + if appSecRemediation == remediation.Challenge && challengeData != nil { + injectChallengeKeyValues(writer, challengeData) + } return appSecRemediation } return currentRemediation } +func injectChallengeKeyValues(writer *encoding.ActionWriter, challengeData *appsec.AppSecChallengeData) { + status := challengeData.StatusCode + if status <= 0 { + status = http.StatusOK + } + + _ = writer.SetInt64(encoding.VarScopeTransaction, "challenge_status", int64(status)) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", challengeData.Body) + + if challengeData.ContentType != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", challengeData.ContentType) + } + if challengeData.CSP != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", challengeData.CSP) + } + if challengeData.CacheControl != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", challengeData.CacheControl) + } + if len(challengeData.Cookies) > 0 { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookie", challengeData.Cookies[0]) + } +} + // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { req := &appsec.AppSecRequest{ From 9589cc8aa72977489363abcdbfe8f06e501c8f31 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:23:03 +0000 Subject: [PATCH 09/18] resolve merge conflicts in haproxy config files --- config/haproxy-upstreamproxy.cfg | 9 ++------- config/haproxy.cfg | 9 ++------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index 37378e56..5034dd30 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -76,13 +76,9 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } -<<<<<<< HEAD - - ## Call lua script for challenge, ban and captcha remediations + ## Call lua script for challenge remediation http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } - http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } - http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } -======= + ## Render ban and captcha remediations with native HAProxy templates. ## Template paths are fixed at /var/lib/crowdsec-haproxy-spoa-bouncer/html/; edit the lf-file ## directives below if you need custom locations (the old CROWDSEC_*_TEMPLATE_PATH env vars @@ -97,7 +93,6 @@ frontend test http-request return status 200 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Captcha required\n" if { var(txn.crowdsec.remediation) -m str "captcha" } html_rejected http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected ->>>>>>> origin/main ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) diff --git a/config/haproxy.cfg b/config/haproxy.cfg index ffe71d19..7239a704 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -60,13 +60,9 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } -<<<<<<< HEAD - - ## Call lua script for challenge, ban and captcha remediations + ## Call lua script for challenge remediation http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } - http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } - http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } -======= + ## Render ban and captcha remediations with native HAProxy templates. ## Template paths are fixed at /var/lib/crowdsec-haproxy-spoa-bouncer/html/; edit the lf-file ## directives below if you need custom locations (the old CROWDSEC_*_TEMPLATE_PATH env vars @@ -81,7 +77,6 @@ frontend test http-request return status 200 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Captcha required\n" if { var(txn.crowdsec.remediation) -m str "captcha" } html_rejected http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected ->>>>>>> origin/main ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) From 3b6663a4157099f8b5d1a11e24407264ef71ceb4 Mon Sep 17 00:00:00 2001 From: sabban Date: Tue, 2 Jun 2026 17:14:54 +0200 Subject: [PATCH 10/18] feat: add WAF challenge mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the waf-challenge-mode feature from lua-cs-bouncer: - Add Challenge remediation type (between Captcha and Ban in severity) - internal/appsec: parse the JSON body on HTTP 403 responses; when action is "challenge" return AppSecChallengeData carrying body, status code, Content-Type, CSP, Cache-Control and cookies instead of discarding the body as before - pkg/spoa: update validateWithAppSec to return (Remediation, *AppSecChallengeData); on Challenge call injectChallengeVars which sets six SPOE transaction vars: challenge_body, challenge_status (int32), challenge_content_type, challenge_csp, challenge_cache_control, challenge_cookies (newline-joined) - lua/crowdsec.lua: handle remediation=="challenge" — reads those vars, sets status/body/Content-Type/CSP/Cache-Control/Set-Cookie and returns early, bypassing the ban/captcha content-negotiation block - config/crowdsec.cfg: raise max-frame-size to 262144 to accommodate the obfuscated JS payload (~150 KB) embedded in the challenge page - config/haproxy.cfg: add lua.crowdsec_handle rule for "challenge" Co-Authored-By: Claude Sonnet 4.6 --- config/crowdsec.cfg | 1 + internal/appsec/root.go | 97 ++++++++++++++++++++++++++---------- internal/remediation/root.go | 15 ++++-- lua/crowdsec.lua | 43 +++++++++++++++- pkg/spoa/root.go | 43 ++++++++++------ 5 files changed, 151 insertions(+), 48 deletions(-) diff --git a/config/crowdsec.cfg b/config/crowdsec.cfg index 213b0df9..fb8177ca 100644 --- a/config/crowdsec.cfg +++ b/config/crowdsec.cfg @@ -9,6 +9,7 @@ spoe-agent crowdsec-agent option var-prefix crowdsec option set-on-error error + option max-frame-size 262144 timeout hello 200ms timeout idle 55s timeout processing 500ms diff --git a/internal/appsec/root.go b/internal/appsec/root.go index a6a1cffa..a60c4f2d 100644 --- a/internal/appsec/root.go +++ b/internal/appsec/root.go @@ -3,6 +3,7 @@ package appsec import ( "bytes" "context" + "encoding/json" "fmt" "io" "maps" @@ -14,6 +15,26 @@ import ( log "github.com/sirupsen/logrus" ) +// AppSecChallengeData holds the challenge page content returned by AppSec when +// it issues a JS PoW + fingerprint challenge instead of an outright block. +type AppSecChallengeData struct { + StatusCode int + Body string + ContentType string + CSP string + CacheControl string + Cookies []string +} + +// appsecJSONResponse mirrors the JSON body AppSec sends for HTTP 403 responses. +type appsecJSONResponse struct { + Action string `json:"action"` + HTTPStatus int `json:"http_status"` + UserBodyContent string `json:"user_body_content"` + UserCookies []string `json:"user_cookies"` + UserHeaders map[string][]string `json:"user_headers"` +} + const DefaultRequestTimeout = 200 * time.Millisecond // AppSecRequest represents the HTTP request data to be validated by AppSec @@ -100,36 +121,35 @@ func (a *AppSec) TimeoutOrDefault() time.Duration { return a.Timeout } -// ValidateRequest sends the HTTP request to the AppSec engine and returns the remediation -func (a *AppSec) ValidateRequest(ctx context.Context, req *AppSecRequest) (remediation.Remediation, error) { - // Use IsValid() which checks both Client and URL +// ValidateRequest sends the HTTP request to the AppSec engine and returns the +// resulting remediation. When AppSec issues a challenge, the second return value +// is non-nil and contains the page content to serve to the browser. +func (a *AppSec) ValidateRequest(ctx context.Context, req *AppSecRequest) (remediation.Remediation, *AppSecChallengeData, error) { if !a.IsValid() { a.logger.Debug("AppSec not configured, allowing request") - return remediation.Allow, nil + return remediation.Allow, nil, nil } - // Create HTTP request to AppSec engine httpReq, err := a.createAppSecRequest(req) if err != nil { a.logger.Errorf("Failed to create AppSec request: %v", err) - return remediation.Allow, err + return remediation.Allow, nil, err } - // Send request to AppSec engine resp, err := a.Client.HTTPClient.Do(httpReq.WithContext(ctx)) if err != nil { a.logger.Errorf("Failed to send request to AppSec engine: %v", err) - return remediation.Allow, err + return remediation.Allow, nil, err } - // resp is guaranteed to be non-nil when err is nil (per http.Client.Do contract) defer resp.Body.Close() - // Discard response body for proper connection reuse - // This allows the connection to be reused via keep-alive - _, _ = io.Copy(io.Discard, resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + a.logger.Errorf("Failed to read AppSec response body: %v", err) + return remediation.Allow, nil, err + } - // Process response based on HTTP status code - return a.processAppSecResponse(resp) + return a.processAppSecResponse(resp.StatusCode, body) } func (a *AppSec) createAppSecRequest(req *AppSecRequest) (*http.Request, error) { @@ -182,29 +202,54 @@ func (a *AppSec) createAppSecRequest(req *AppSecRequest) (*http.Request, error) return httpReq, nil } -func (a *AppSec) processAppSecResponse(resp *http.Response) (remediation.Remediation, error) { - switch resp.StatusCode { +func (a *AppSec) processAppSecResponse(statusCode int, body []byte) (remediation.Remediation, *AppSecChallengeData, error) { + switch statusCode { case http.StatusOK: - // Request allowed - return remediation.Allow, nil + return remediation.Allow, nil, nil case http.StatusForbidden: - // Request blocked - return ban remediation - return remediation.Ban, nil + if len(body) == 0 { + return remediation.Ban, nil, nil + } + + var parsed appsecJSONResponse + if err := json.Unmarshal(body, &parsed); err != nil { + a.logger.WithError(err).Warn("failed to parse AppSec JSON response, defaulting to ban") + return remediation.Ban, nil, nil + } + + if parsed.Action != "challenge" { + return remediation.Ban, nil, nil + } + + cd := &AppSecChallengeData{ + StatusCode: parsed.HTTPStatus, + Body: parsed.UserBodyContent, + Cookies: parsed.UserCookies, + } + if vals := parsed.UserHeaders["Content-Type"]; len(vals) > 0 { + cd.ContentType = vals[0] + } + if vals := parsed.UserHeaders["Content-Security-Policy"]; len(vals) > 0 { + cd.CSP = vals[0] + } + if vals := parsed.UserHeaders["Cache-Control"]; len(vals) > 0 { + cd.CacheControl = vals[0] + } + + return remediation.Challenge, cd, nil case http.StatusUnauthorized: - // Authentication failed a.logger.Error("AppSec authentication failed - check API key") - return remediation.Allow, fmt.Errorf("AppSec authentication failed") + return remediation.Allow, nil, fmt.Errorf("AppSec authentication failed") case http.StatusInternalServerError: - // AppSec engine error a.logger.Error("AppSec engine error") - return remediation.Allow, fmt.Errorf("AppSec engine error") + return remediation.Allow, nil, fmt.Errorf("AppSec engine error") default: - a.logger.Warnf("Unexpected AppSec response code: %d", resp.StatusCode) - return remediation.Allow, fmt.Errorf("unexpected AppSec response code: %d", resp.StatusCode) + a.logger.Warnf("Unexpected AppSec response code: %d", statusCode) + return remediation.Allow, nil, fmt.Errorf("unexpected AppSec response code: %d", statusCode) } } diff --git a/internal/remediation/root.go b/internal/remediation/root.go index 888f02c6..3572f361 100644 --- a/internal/remediation/root.go +++ b/internal/remediation/root.go @@ -1,11 +1,12 @@ package remediation -// The order matters since we use slices.Max to get the max value +// The order matters since we use numeric comparison to find the most restrictive remediation. const ( - Allow Remediation = iota // Allow remediation - Unknown // Unknown remediation (Unknown is used to have a value for remediation we don't support EG "MFA") - Captcha // Captcha remediation - Ban // Ban remediation + Allow Remediation = iota // Allow remediation + Unknown // Unknown remediation (Unknown is used to have a value for remediation we don't support EG "MFA") + Captcha // Captcha remediation + Challenge // Challenge remediation (JS PoW + fingerprint, issued by AppSec) + Ban // Ban remediation ) type Remediation uint8 // Remediation type is smallest uint to save space @@ -16,6 +17,8 @@ func (r Remediation) String() string { return "ban" case Captcha: return "captcha" + case Challenge: + return "challenge" case Unknown: return "unknown" default: @@ -29,6 +32,8 @@ func FromString(s string) Remediation { return Ban case "captcha": return Captcha + case "challenge": + return Challenge case "allow": return Allow default: diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index d457f8f8..1fec17c1 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -114,12 +114,52 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-store") -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy - -- This Lua handler is only called for "captcha" and "ban" remediations + -- This Lua handler is only called for "captcha", "ban", and "challenge" remediations if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end + if remediation == "challenge" then + local body = get_txn_var(txn, "crowdsec.challenge_body") + local status = get_txn_var(txn, "crowdsec.challenge_status") + local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") + local csp = get_txn_var(txn, "crowdsec.challenge_csp") + local cache_ctrl = get_txn_var(txn, "crowdsec.challenge_cache_control") + local cookies_raw = get_txn_var(txn, "crowdsec.challenge_cookies") + + -- challenge_status is an int32 SPOE var; handle both number and string + local s = type(status) == "number" and status or tonumber(status) or 200 + reply:set_status(s) + reply:set_body(body ~= "" and body or "") + + if content_type ~= "" then + reply:add_header("Content-Type", content_type) + else + reply:add_header("Content-Type", "text/html") + end + if csp ~= "" then + reply:add_header("Content-Security-Policy", csp) + end + if cache_ctrl ~= "" then + reply:add_header("Cache-Control", cache_ctrl) + end + -- cookies_raw is a newline-joined list of Set-Cookie strings + if cookies_raw ~= "" then + for cookie in cookies_raw:gmatch("[^\n]+") do + reply:add_header("Set-Cookie", cookie) + end + end + + reply:add_header("Content-Length", #reply.body) + txn:done(reply) + return + end + + -- Always disable cache for ban/captcha pages + reply:add_header("cache-control", "no-cache") + reply:add_header("cache-control", "no-store") + if remediation == "captcha" then reply:set_status(200) reply:set_body(runtime.captcha.render({ @@ -137,7 +177,6 @@ function runtime.Handle(txn) })) end - local hdr = txn.http:req_get_headers() if hdr ~= nil and utils.accept_html(hdr) == false then reply:set_body("Forbidden") diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index caf9ba86..91f693f6 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -516,7 +516,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) } return } @@ -539,10 +539,15 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) - // If AppSec returns ban, inject ban values - if r == remediation.Ban { + var challengeData *appsec.AppSecChallengeData + r, challengeData = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + switch r { + case remediation.Ban: matchedHost.Ban.InjectKeyValues(writer) + case remediation.Challenge: + if challengeData != nil { + injectChallengeVars(writer, challengeData) + } } } } @@ -568,8 +573,8 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { return r < remediation.Captcha || alwaysSend } -// validateWithAppSec performs AppSec validation and returns the remediation -// Returns the more restrictive remediation between the current remediation and AppSec result +// validateWithAppSec performs AppSec validation and returns the remediation. +// When AppSec issues a challenge the second return value carries the page content. func (s *Spoa) validateWithAppSec( ctx context.Context, msgData *HTTPMessageData, @@ -577,10 +582,9 @@ func (s *Spoa) validateWithAppSec( appSecToUse *appsec.AppSec, currentRemediation remediation.Remediation, requestTimeout time.Duration, -) remediation.Remediation { +) (remediation.Remediation, *appsec.AppSecChallengeData) { appSecReq := msgData.buildAppSecRequest() - // Create logger with host context logger := s.logger if appSecReq.Host != "" { logger = logger.WithField("host", appSecReq.Host) @@ -589,19 +593,17 @@ func (s *Spoa) validateWithAppSec( logger = logger.WithField("matched_host", matchedHost.Host) } - // Validate with AppSec - derive context from handler so requests cancel on shutdown appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") - return currentRemediation + return currentRemediation, nil } logger.WithField("remediation", appSecRemediation.String()).Debug("AppSec validation result") - // Track AppSec block metrics if appSecRemediation > remediation.Allow && appSecReq.RemoteIP != "" { if ipAddr, parseErr := netip.ParseAddr(appSecReq.RemoteIP); parseErr == nil { ipType := "ipv4" @@ -612,14 +614,25 @@ func (s *Spoa) validateWithAppSec( } } - // Return the more restrictive remediation (never downgrade security) if appSecRemediation > currentRemediation { if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } - return appSecRemediation + return appSecRemediation, challengeData } - return currentRemediation + return currentRemediation, nil +} + +// injectChallengeVars sets the SPOE transaction variables HAProxy Lua needs to +// serve a challenge response. The challenge_body may be up to ~200 KB (obfuscated +// JS embedded in HTML); ensure max-frame-size in crowdsec.cfg is at least 262144. +func injectChallengeVars(writer *encoding.ActionWriter, cd *appsec.AppSecChallengeData) { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", cd.Body) + _ = writer.SetInt32(encoding.VarScopeTransaction, "challenge_status", int32(cd.StatusCode)) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", cd.ContentType) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", cd.CSP) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", cd.CacheControl) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookies", strings.Join(cd.Cookies, "\n")) } // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData From 13b8edde7eaa45243c3ba002318887495f4b5bf0 Mon Sep 17 00:00:00 2001 From: sabban Date: Wed, 3 Jun 2026 10:07:28 +0200 Subject: [PATCH 11/18] fix(challenge): route challenge via dedicated HTTP server, not SPOE vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPOE frame size in dropmorepackets/haproxy-go is capped at 64 KB, but the challenge page (HTML + ~150 KB of obfuscated JS) exceeds that limit. Attempting to write the large body through the ActionWriter silently fills its buffer; subsequent writes (including the small remediation="challenge" update) also fail, leaving HAProxy with the stale "allow" value from the TCP-phase handler. Replace the SPOE-var approach with a dedicated challenge HTTP server: - pkg/spoa/challenge_server.go: new ChallengeServer that listens on a configurable TCP address (challenge_listen in the bouncer YAML). When HAProxy routes a challenge request to it, the server re-sends the original request to the AppSec engine (forwarding all headers + body, using X-Crowdsec-Real-Ip for the client IP), unwraps the JSON response, and writes the challenge page — HTML, JS, cookies, CSP — directly to HAProxy with no size restriction. - pkg/spoa/root.go: SPOE handler only writes remediation="challenge" (9 bytes, well within the frame limit); all challenge body logic is removed from the SPOE path. Challenge server is started as a goroutine inside Serve(). - pkg/cfg/config.go, cmd/root.go: expose challenge_listen config field and wire it through SpoaConfig.ChallengeAddr. - lua/crowdsec.lua: remove the challenge branch from crowdsec_handle (Lua no longer handles challenge; HAProxy routes to the dedicated backend instead). HAProxy config change: add a challenge_backend pointing to the bouncer's challenge HTTP server, and use_backend for remediation=="challenge". Tested end-to-end with CrowdSec waf-challenge-mode branch: - GET /challenge → 200 + CrowdSec challenge HTML (CSP + CT headers) - GET /pow-worker.js → 200 + PoW web worker JS (3.4 KB) - POST /submit (bad) → 200 + {"status":"failed"} - rem=challenge in HAProxy access log confirms SPOE var is set Co-Authored-By: Claude Sonnet 4.6 --- cmd/root.go | 15 ++-- lua/crowdsec.lua | 41 +--------- pkg/cfg/config.go | 3 +- pkg/spoa/challenge_server.go | 149 +++++++++++++++++++++++++++++++++++ pkg/spoa/root.go | 63 ++++++++------- 5 files changed, 193 insertions(+), 78 deletions(-) create mode 100644 pkg/spoa/challenge_server.go diff --git a/cmd/root.go b/cmd/root.go index 01f76275..0a22ea38 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,13 +261,14 @@ func Execute() error { // Create single SPOA directly with minimal configuration spoaConfig := &spoa.SpoaConfig{ - TcpAddr: config.ListenTCP, - UnixAddr: config.ListenUnix, - Dataset: dataSet, - HostManager: HostManager, - GeoDatabase: &config.Geo, - GlobalAppSec: globalAppSec, - Logger: spoaLogger, + TcpAddr: config.ListenTCP, + UnixAddr: config.ListenUnix, + Dataset: dataSet, + HostManager: HostManager, + GeoDatabase: &config.Geo, + GlobalAppSec: globalAppSec, + ChallengeAddr: config.ChallengeAddr, + Logger: spoaLogger, } singleSpoa, err := spoa.New(spoaConfig) diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index 1fec17c1..160d87aa 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -113,49 +113,14 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") - -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy - -- This Lua handler is only called for "captcha", "ban", and "challenge" remediations + -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy. + -- "challenge" is handled by routing to the bouncer challenge HTTP server (no Lua needed). + -- This Lua handler is only called for "captcha" and "ban" remediations. if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end - if remediation == "challenge" then - local body = get_txn_var(txn, "crowdsec.challenge_body") - local status = get_txn_var(txn, "crowdsec.challenge_status") - local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") - local csp = get_txn_var(txn, "crowdsec.challenge_csp") - local cache_ctrl = get_txn_var(txn, "crowdsec.challenge_cache_control") - local cookies_raw = get_txn_var(txn, "crowdsec.challenge_cookies") - - -- challenge_status is an int32 SPOE var; handle both number and string - local s = type(status) == "number" and status or tonumber(status) or 200 - reply:set_status(s) - reply:set_body(body ~= "" and body or "") - - if content_type ~= "" then - reply:add_header("Content-Type", content_type) - else - reply:add_header("Content-Type", "text/html") - end - if csp ~= "" then - reply:add_header("Content-Security-Policy", csp) - end - if cache_ctrl ~= "" then - reply:add_header("Cache-Control", cache_ctrl) - end - -- cookies_raw is a newline-joined list of Set-Cookie strings - if cookies_raw ~= "" then - for cookie in cookies_raw:gmatch("[^\n]+") do - reply:add_header("Set-Cookie", cookie) - end - end - - reply:add_header("Content-Length", #reply.body) - txn:done(reply) - return - end - -- Always disable cache for ban/captcha pages reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 11ca6514..9bee4cd8 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -38,8 +38,9 @@ type BouncerConfig struct { PrometheusConfig PrometheusConfig `yaml:"prometheus"` PprofConfig PprofConfig `yaml:"pprof"` APIKey string `yaml:"api_key"` // LAPI API key (also used for AppSec) - AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL + AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL AppSecTimeout time.Duration `yaml:"appsec_timeout,omitempty"` + ChallengeAddr string `yaml:"challenge_listen,omitempty"` // Challenge HTTP server listen address (e.g. "0.0.0.0:9001") } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go new file mode 100644 index 00000000..06807c79 --- /dev/null +++ b/pkg/spoa/challenge_server.go @@ -0,0 +1,149 @@ +package spoa + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" + log "github.com/sirupsen/logrus" +) + +const ( + // ChallengeRealIPHeader is the header HAProxy adds to identify the real + // client IP when routing a challenge request to the challenge server. + ChallengeRealIPHeader = "X-Crowdsec-Real-Ip" +) + +// ChallengeServer is a plain HTTP server that HAProxy routes to when the SPOE +// agent returns remediation=challenge. It re-sends the request to the AppSec +// engine, unwraps the JSON response, and writes the challenge page (HTML, JS, +// or submit JSON) directly back to the client. +// +// This avoids the 64 KB SPOE frame-size limit: the large challenge body never +// passes through the SPOE protocol; it travels through a normal HTTP connection +// between HAProxy, this server, and the AppSec engine. +type ChallengeServer struct { + appSec *appsec.AppSec + listen string + logger *log.Entry +} + +func newChallengeServer(appSec *appsec.AppSec, addr string, logger *log.Entry) *ChallengeServer { + return &ChallengeServer{ + appSec: appSec, + listen: addr, + logger: logger.WithField("component", "challenge_server"), + } +} + +func (cs *ChallengeServer) Serve(ctx context.Context) error { + srv := &http.Server{ + Addr: cs.listen, + Handler: cs, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + } + + go func() { + <-ctx.Done() + _ = srv.Shutdown(context.Background()) + }() + + cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) + + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("challenge server: %w", err) + } + + return nil +} + +// ServeHTTP handles a request forwarded by HAProxy when remediation=challenge. +// It builds an AppSec request from the incoming request, calls the AppSec +// engine, and writes the challenge response (body + headers + cookies) back. +func (cs *ChallengeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + clientIP := r.Header.Get(ChallengeRealIPHeader) + if clientIP == "" { + // Fall back to the connection's remote address (strips port) + if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { + clientIP = r.RemoteAddr[:idx] + } else { + clientIP = r.RemoteAddr + } + } + + // Read the request body (needed for the submit path POST) + var body []byte + if r.Body != nil { + var err error + body, err = io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit + if err != nil { + cs.logger.WithError(err).Warn("challenge server: failed to read request body") + } + } + + // Reconstruct the URL for the AppSec header + reqURL := r.URL.RequestURI() + + // Forward the original client headers to AppSec (minus hop-by-hop and our own headers) + headers := r.Header.Clone() + headers.Del(ChallengeRealIPHeader) + headers.Del("X-Forwarded-For") + + appSecReq := &appsec.AppSecRequest{ + Host: r.Host, + Method: r.Method, + URL: reqURL, + RemoteIP: clientIP, + UserAgent: r.UserAgent(), + Headers: headers, + Body: body, + } + + appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + rem, challengeData, err := cs.appSec.ValidateRequest(appSecCtx, appSecReq) + if err != nil { + cs.logger.WithError(err).Error("challenge server: AppSec request failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if rem != remediation.Challenge || challengeData == nil { + // AppSec didn't return a challenge (e.g. allow after valid cookie). + // Return 200 with an empty body; HAProxy will handle the pass-through. + w.WriteHeader(http.StatusOK) + return + } + + // Write response headers from AppSec + if challengeData.ContentType != "" { + w.Header().Set("Content-Type", challengeData.ContentType) + } + if challengeData.CSP != "" { + w.Header().Set("Content-Security-Policy", challengeData.CSP) + } + if challengeData.CacheControl != "" { + w.Header().Set("Cache-Control", challengeData.CacheControl) + } + for _, cookie := range challengeData.Cookies { + w.Header().Add("Set-Cookie", cookie) + } + + status := challengeData.StatusCode + if status <= 0 { + status = http.StatusOK + } + + w.WriteHeader(status) + if challengeData.Body != "" { + _, _ = io.Copy(w, bytes.NewBufferString(challengeData.Body)) + } +} diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 91f693f6..17ab7844 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -75,23 +75,25 @@ var ( type Spoa struct { ListenAddr net.Listener - ListenSocket net.Listener - logger *log.Entry + ListenSocket net.Listener + logger *log.Entry // Direct access to shared data (no IPC needed) - dataset *dataset.DataSet - hostManager *host.Manager - geoDatabase *geo.GeoDatabase - globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + dataset *dataset.DataSet + hostManager *host.Manager + geoDatabase *geo.GeoDatabase + globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + challengeServer *ChallengeServer } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -121,6 +123,10 @@ func New(config *SpoaConfig) (*Spoa, error) { globalAppSec: config.GlobalAppSec, } + if config.ChallengeAddr != "" && config.GlobalAppSec != nil && config.GlobalAppSec.IsValid() { + s.challengeServer = newChallengeServer(config.GlobalAppSec, config.ChallengeAddr, workerLogger) + } + if config.TcpAddr != "" { addr, err := net.Listen("tcp", config.TcpAddr) if err != nil { @@ -213,6 +219,15 @@ func (s *Spoa) Serve(ctx context.Context) error { return nil } + // Launch the challenge HTTP server if configured + if s.challengeServer != nil { + go func() { + if err := s.challengeServer.Serve(ctx); err != nil { + serverError <- err + } + }() + } + select { case err := <-serverError: return err @@ -517,6 +532,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + // Challenge content is served by the challenge HTTP server, not via SPOE vars. } return } @@ -539,15 +555,9 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - var challengeData *appsec.AppSecChallengeData - r, challengeData = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) - switch r { - case remediation.Ban: + r, _ = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) - case remediation.Challenge: - if challengeData != nil { - injectChallengeVars(writer, challengeData) - } } } } @@ -623,17 +633,6 @@ func (s *Spoa) validateWithAppSec( return currentRemediation, nil } -// injectChallengeVars sets the SPOE transaction variables HAProxy Lua needs to -// serve a challenge response. The challenge_body may be up to ~200 KB (obfuscated -// JS embedded in HTML); ensure max-frame-size in crowdsec.cfg is at least 262144. -func injectChallengeVars(writer *encoding.ActionWriter, cd *appsec.AppSecChallengeData) { - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", cd.Body) - _ = writer.SetInt32(encoding.VarScopeTransaction, "challenge_status", int32(cd.StatusCode)) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", cd.ContentType) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", cd.CSP) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", cd.CacheControl) - _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookies", strings.Join(cd.Cookies, "\n")) -} // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { From 8666462752ba42873e8daef31b0769915f28b2ef Mon Sep 17 00:00:00 2001 From: sabban Date: Wed, 3 Jun 2026 10:15:58 +0200 Subject: [PATCH 12/18] test: add tests for challenge mode (remediation, appsec, challenge server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/remediation/root_test.go (4 tests) - Challenge.String() returns "challenge" - FromString("challenge") round-trips back to Challenge - All Remediation values round-trip through String/FromString - Challenge is numerically between Captcha and Ban (ordering invariant that shouldRunAppSec and the take-the-max logic depend on) internal/appsec/root_test.go (15 tests) processAppSecResponse (white-box, same-package): - 200 → Allow, no challenge data - 403 with action=ban → Ban, no challenge data - 403 with empty body → Ban (safe default) - 403 with invalid JSON → Ban (safe default) - 403 with action=challenge, minimal payload → Challenge + data - 403 with action=challenge, full headers (CT/CSP/CC) + cookies → all fields - 401 → Allow + error - 500 → Allow + error - Unknown status → Allow + error ValidateRequest (httptest server): - AppSec returns 200 → Allow - AppSec returns 403 ban → Ban - AppSec returns 403 challenge → Challenge + all data fields - Client not configured → Allow, no error - Required CrowdSec headers are set on the upstream request - POST body is forwarded to AppSec pkg/spoa/challenge_server_test.go (10 tests, all via httptest.ResponseRecorder) - Challenge HTML page (CT, CSP, Cache-Control headers) - Single Set-Cookie forwarded from AppSec - Multiple Set-Cookie headers forwarded - AppSec returns allow → 200 empty body - X-Crowdsec-Real-Ip used as client IP - Falls back to RemoteAddr when header is absent - POST body forwarded to AppSec - X-Crowdsec-Real-Ip and X-Forwarded-For stripped before forwarding - PoW worker JS response (Content-Type: application/javascript) - Invalid submit response ({"status":"failed"}) Co-Authored-By: Claude Sonnet 4.6 --- internal/appsec/root_test.go | 295 ++++++++++++++++++++++++++++++ internal/remediation/root_test.go | 30 +++ pkg/spoa/challenge_server_test.go | 251 +++++++++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 internal/appsec/root_test.go create mode 100644 internal/remediation/root_test.go create mode 100644 pkg/spoa/challenge_server_test.go diff --git a/internal/appsec/root_test.go b/internal/appsec/root_test.go new file mode 100644 index 00000000..c03d1d72 --- /dev/null +++ b/internal/appsec/root_test.go @@ -0,0 +1,295 @@ +package appsec + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestAppSec returns an AppSec with a client pointed at the given URL. +func newTestAppSec(url, apiKey string) *AppSec { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + a.Client = &AppSecClient{ + HTTPClient: &http.Client{}, + APIKey: apiKey, + URL: url, + logger: a.logger, + } + return a +} + +// ---------- parseResponse unit tests ---------- + +func TestProcessAppSecResponse_Allow(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusOK, nil) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanPlain403(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{"action": "ban", "http_status": 403}) + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanEmptyBody(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, nil) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_BanInvalidJSON(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, []byte("not json")) + + require.NoError(t, err) // invalid JSON is treated as ban, not an error + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_ChallengeMinimal(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{"Content-Type": {"text/html"}}, + "user_cookies": []string{}, + }) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, 200, cd.StatusCode) + assert.Equal(t, "challenge", cd.Body) + assert.Equal(t, "text/html", cd.ContentType) + assert.Empty(t, cd.Cookies) +} + +func TestProcessAppSecResponse_ChallengeWithAllHeaders(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + body, _ := json.Marshal(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{ + "Content-Type": {"text/html; charset=utf-8"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, + }, + "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, + }) + + rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, "text/html; charset=utf-8", cd.ContentType) + assert.Equal(t, "default-src 'self'", cd.CSP) + assert.Equal(t, "no-store, no-cache", cd.CacheControl) + assert.Equal(t, []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, cd.Cookies) +} + +func TestProcessAppSecResponse_Unauthorized(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusUnauthorized, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_InternalServerError(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(http.StatusInternalServerError, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestProcessAppSecResponse_UnknownStatus(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + + rem, cd, err := a.processAppSecResponse(418, nil) + + require.Error(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +// ---------- ValidateRequest integration tests (httptest server) ---------- + +func TestValidateRequest_Allow(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_Ban(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{"action": "ban", "http_status": 403}) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/?id=1 OR 1=1", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Ban, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_Challenge(t *testing.T) { + const challengeHTML = "CrowdSec Challenge" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": challengeHTML, + "user_headers": map[string][]string{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store"}, + }, + "user_cookies": []string{"__crowdsec_challenge=xyz; HttpOnly"}, + }) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "test-key") + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/challenge", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Challenge, rem) + require.NotNil(t, cd) + assert.Equal(t, 200, cd.StatusCode) + assert.Equal(t, challengeHTML, cd.Body) + assert.Equal(t, "text/html", cd.ContentType) + assert.Equal(t, "default-src 'self'", cd.CSP) + assert.Equal(t, "no-store", cd.CacheControl) + assert.Equal(t, []string{"__crowdsec_challenge=xyz; HttpOnly"}, cd.Cookies) +} + +func TestValidateRequest_NotConfigured(t *testing.T) { + a := &AppSec{} + a.InitLogger(log.NewEntry(log.New())) + // No Client set → IsValid() == false + + rem, cd, err := a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "GET", URL: "/", RemoteIP: "1.2.3.4", + }) + + require.NoError(t, err) + assert.Equal(t, remediation.Allow, rem) + assert.Nil(t, cd) +} + +func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { + var capturedReq *http.Request + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedReq = r + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "secret-api-key") + _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "myhost.com", + Method: "POST", + URL: "/login", + RemoteIP: "10.0.0.1", + UserAgent: "Mozilla/5.0", + Version: "1.1", + }) + + require.NotNil(t, capturedReq) + assert.Equal(t, "10.0.0.1", capturedReq.Header.Get("X-Crowdsec-Appsec-Ip")) + assert.Equal(t, "/login", capturedReq.Header.Get("X-Crowdsec-Appsec-Uri")) + assert.Equal(t, "myhost.com", capturedReq.Header.Get("X-Crowdsec-Appsec-Host")) + assert.Equal(t, "POST", capturedReq.Header.Get("X-Crowdsec-Appsec-Verb")) + assert.Equal(t, "secret-api-key", capturedReq.Header.Get("X-Crowdsec-Appsec-Api-Key")) + assert.Equal(t, "Mozilla/5.0", capturedReq.Header.Get("X-Crowdsec-Appsec-User-Agent")) + assert.Equal(t, "11", capturedReq.Header.Get("X-Crowdsec-Appsec-Http-Version")) +} + +func TestValidateRequest_PostWithBody(t *testing.T) { + var receivedBody []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + var err error + receivedBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "key") + payload := []byte("t=token&n=nonce&f=fingerprint") + _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + Host: "example.com", Method: "POST", URL: "/submit", RemoteIP: "1.2.3.4", + Body: payload, + }) + + assert.Equal(t, payload, receivedBody) +} diff --git a/internal/remediation/root_test.go b/internal/remediation/root_test.go new file mode 100644 index 00000000..3b57abd8 --- /dev/null +++ b/internal/remediation/root_test.go @@ -0,0 +1,30 @@ +package remediation + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChallengeString(t *testing.T) { + assert.Equal(t, "challenge", Challenge.String()) +} + +func TestFromStringChallenge(t *testing.T) { + assert.Equal(t, Challenge, FromString("challenge")) +} + +func TestFromStringRoundTrip(t *testing.T) { + for _, r := range []Remediation{Allow, Unknown, Captcha, Challenge, Ban} { + assert.Equal(t, r, FromString(r.String()), "round-trip failed for %v", r) + } +} + +func TestChallengeOrdering(t *testing.T) { + // Challenge must be more restrictive than Captcha and less restrictive than Ban + // so that shouldRunAppSec and the "take the max" logic work correctly. + assert.Greater(t, Challenge, Captcha) + assert.Less(t, Challenge, Ban) + assert.Greater(t, Challenge, Allow) + assert.Greater(t, Challenge, Unknown) +} diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go new file mode 100644 index 00000000..bbb64acb --- /dev/null +++ b/pkg/spoa/challenge_server_test.go @@ -0,0 +1,251 @@ +package spoa + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockAppSecHandler returns an http.Handler that responds with the given JSON body +// and status code to simulate the AppSec engine's wire format. +func mockAppSecHandler(statusCode int, payload interface{}) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) + }) +} + +func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*ChallengeServer, *httptest.Server) { + t.Helper() + appSecSrv := httptest.NewServer(appSecHandler) + t.Cleanup(appSecSrv.Close) + + a := &appsec.AppSec{} + a.InitLogger(log.NewEntry(log.New())) + a.Client = &appsec.AppSecClient{ + HTTPClient: &http.Client{}, + APIKey: "test-api-key", + URL: appSecSrv.URL, + // logger exposed through the embedded unexported field; set via Init below + } + + cs := newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) + return cs, appSecSrv +} + +// roundtrip sends req to the ChallengeServer and returns the recorded response. +func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + cs.ServeHTTP(rr, req) + return rr +} + +// ---------- tests ---------- + +func TestChallengeServer_ServesChallengePage(t *testing.T) { + const challengeHTML = "CrowdSec Challenge" + + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": challengeHTML, + "user_headers": map[string][]string{ + "Content-Type": {"text/html"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, + }, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, challengeHTML, rr.Body.String()) + assert.Equal(t, "text/html", rr.Header().Get("Content-Type")) + assert.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) + assert.Equal(t, "no-store, no-cache", rr.Header().Get("Cache-Control")) +} + +func TestChallengeServer_ForwardsCookies(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": `{"status":"ok"}`, + "user_headers": map[string][]string{"Content-Type": {"application/json"}}, + "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax"}, + })) + + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", strings.NewReader("t=tok&n=nonce")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + cookies := rr.Header()["Set-Cookie"] + require.Len(t, cookies, 1) + assert.Equal(t, "__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax", cookies[0]) +} + +func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": "ok", + "user_headers": map[string][]string{}, + "user_cookies": []string{ + "__crowdsec_challenge=abc; HttpOnly", + "session=xyz; Secure", + }, + })) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + cookies := rr.Header()["Set-Cookie"] + assert.Len(t, cookies, 2) +} + +func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { + // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Empty(t, rr.Body.String()) +} + +func TestChallengeServer_UsesRealIPHeader(t *testing.T) { + var capturedIP string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") + roundtrip(cs, req) + + assert.Equal(t, "203.0.113.42", capturedIP) +} + +func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { + var capturedIP string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req.RemoteAddr = "203.0.113.5:12345" + // No ChallengeRealIPHeader set + roundtrip(cs, req) + + assert.Equal(t, "203.0.113.5", capturedIP) +} + +func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { + var receivedBody []byte + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var err error + receivedBody, err = io.ReadAll(r.Body) + require.NoError(t, err) + // Return a challenge response so ServeHTTP doesn't return early + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "action": "challenge", "http_status": 200, + "user_body_content": "ok", "user_headers": map[string][]string{}, + "user_cookies": []string{}, + }) + }) + cs, _ := newChallengeServerForTest(t, handler) + + payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", + strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + roundtrip(cs, req) + + assert.Equal(t, payload, string(receivedBody)) +} + +func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { + var capturedHeaders http.Header + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedHeaders = r.Header.Clone() + w.WriteHeader(http.StatusOK) + }) + cs, _ := newChallengeServerForTest(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + req.Header.Set("X-Forwarded-For", "10.0.0.1") + roundtrip(cs, req) + + assert.Empty(t, capturedHeaders.Get(ChallengeRealIPHeader), + "X-Crowdsec-Real-Ip must not be forwarded to AppSec") + assert.Empty(t, capturedHeaders.Get("X-Forwarded-For"), + "X-Forwarded-For must not be forwarded to AppSec") +} + +func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { + const workerJS = "// pow worker" + + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": workerJS, + "user_headers": map[string][]string{ + "Content-Type": {"application/javascript"}, + "Cache-Control": {"public, max-age=3600"}, + }, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", nil) + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type")) + assert.Equal(t, "public, max-age=3600", rr.Header().Get("Cache-Control")) + assert.Equal(t, workerJS, rr.Body.String()) +} + +func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { + cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + "action": "challenge", + "http_status": 200, + "user_body_content": `{"status":"failed"}`, + "user_headers": map[string][]string{"Content-Type": {"application/json"}, "Cache-Control": {"no-cache, no-store"}}, + "user_cookies": []string{}, + })) + + req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", + strings.NewReader("t=bad&ts=bad&h=bad&n=bad&p=bad&m=bad&f=bad")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") + rr := roundtrip(cs, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), `"status":"failed"`) +} From f0a292468c6f7c345cba6c67645e80f8c7696d09 Mon Sep 17 00:00:00 2001 From: sabban Date: Thu, 4 Jun 2026 18:07:33 +0200 Subject: [PATCH 13/18] add some default configuration --- ...ec-spoa-bouncer.no-challenge-listener.yaml | 45 +++++++++++++++++++ config/crowdsec-spoa-bouncer.yaml | 10 +++++ 2 files changed, 55 insertions(+) create mode 100644 config/crowdsec-spoa-bouncer.no-challenge-listener.yaml diff --git a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml new file mode 100644 index 00000000..7ed5951c --- /dev/null +++ b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml @@ -0,0 +1,45 @@ +## Default configuration without the challenge HTTP listener. +## Use this variant when AppSec challenge routing is not deployed yet. + +## Log configuration +log_mode: file +log_dir: /var/log/crowdsec-spoa/ +log_level: info +log_compression: true +log_max_size: 100 +log_max_backups: 3 +log_max_age: 30 + +## LAPI configuration +update_frequency: 10s +api_url: http://127.0.0.1:8080/ +api_key: ${API_KEY} +insecure_skip_verify: false + +## SPOA listener configuration +# Configure TCP and/or Unix socket listeners +listen_tcp: 0.0.0.0:9000 +listen_unix: /run/crowdsec-spoa/spoa.sock +#asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb +#city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb + +## Global AppSec configuration +## AppSec can still be enabled for allow/ban decisions without exposing a challenge listener. +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms + +## Challenge HTTP listener intentionally disabled in this variant. +#challenge_listen: 127.0.0.1:9001 + +prometheus: + enabled: false + listen_addr: 127.0.0.1 + listen_port: 60601 + +## pprof debug endpoint for runtime profiling +## WARNING: Only enable for debugging, exposes internal runtime data +## Endpoints: /debug/pprof/heap, /debug/pprof/profile, /debug/pprof/goroutine, etc. +#pprof: +# enabled: false +# listen_addr: 127.0.0.1 +# listen_port: 6060 diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 9e2f2552..b5bef770 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -20,6 +20,16 @@ listen_unix: /run/crowdsec-spoa/spoa.sock #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb +## Global AppSec configuration +## Used for virtual patching and JavaScript challenge decisions. +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms + +## Challenge HTTP listener +## HAProxy routes remediation=challenge requests to this listener. +## Keep this bound to loopback unless HAProxy reaches the bouncer through a trusted private network. +challenge_listen: 127.0.0.1:9001 + prometheus: enabled: false listen_addr: 127.0.0.1 From 3411bb2e8d30b6d86b13d1ec82ed332881feeaf5 Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 11:55:37 +0200 Subject: [PATCH 14/18] lint --- internal/appsec/root_test.go | 47 +++++++++++++++++----------- pkg/spoa/challenge_server.go | 5 ++- pkg/spoa/challenge_server_test.go | 51 ++++++++++++++++--------------- pkg/spoa/root.go | 36 +++++++++++----------- 4 files changed, 76 insertions(+), 63 deletions(-) diff --git a/internal/appsec/root_test.go b/internal/appsec/root_test.go index c03d1d72..f8fd5e44 100644 --- a/internal/appsec/root_test.go +++ b/internal/appsec/root_test.go @@ -27,6 +27,15 @@ func newTestAppSec(url, apiKey string) *AppSec { return a } +func mustMarshal(t *testing.T, value any) []byte { + t.Helper() + + body, err := json.Marshal(value) + require.NoError(t, err) + + return body +} + // ---------- parseResponse unit tests ---------- func TestProcessAppSecResponse_Allow(t *testing.T) { @@ -44,7 +53,7 @@ func TestProcessAppSecResponse_BanPlain403(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{"action": "ban", "http_status": 403}) + body := mustMarshal(t, map[string]any{"action": "ban", "http_status": 403}) rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) require.NoError(t, err) @@ -78,12 +87,12 @@ func TestProcessAppSecResponse_ChallengeMinimal(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{ - "action": "challenge", - "http_status": 200, - "user_body_content": "challenge", - "user_headers": map[string][]string{"Content-Type": {"text/html"}}, - "user_cookies": []string{}, + body := mustMarshal(t, map[string]any{ + "action": "challenge", + "http_status": 200, + "user_body_content": "challenge", + "user_headers": map[string][]string{"Content-Type": {"text/html"}}, + "user_cookies": []string{}, }) rem, cd, err := a.processAppSecResponse(http.StatusForbidden, body) @@ -101,14 +110,14 @@ func TestProcessAppSecResponse_ChallengeWithAllHeaders(t *testing.T) { a := &AppSec{} a.InitLogger(log.NewEntry(log.New())) - body, _ := json.Marshal(map[string]interface{}{ + body := mustMarshal(t, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "challenge", "user_headers": map[string][]string{ - "Content-Type": {"text/html; charset=utf-8"}, - "Content-Security-Policy": {"default-src 'self'"}, - "Cache-Control": {"no-store, no-cache"}, + "Content-Type": {"text/html; charset=utf-8"}, + "Content-Security-Policy": {"default-src 'self'"}, + "Cache-Control": {"no-store, no-cache"}, }, "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; SameSite=Lax"}, }) @@ -179,7 +188,7 @@ func TestValidateRequest_Ban(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"action": "ban", "http_status": 403}) + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"action": "ban", "http_status": 403})) })) defer srv.Close() @@ -199,7 +208,7 @@ func TestValidateRequest_Challenge(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": challengeHTML, @@ -209,7 +218,7 @@ func TestValidateRequest_Challenge(t *testing.T) { "Cache-Control": {"no-store"}, }, "user_cookies": []string{"__crowdsec_challenge=xyz; HttpOnly"}, - }) + })) })) defer srv.Close() @@ -253,7 +262,7 @@ func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { defer srv.Close() a := newTestAppSec(srv.URL, "secret-api-key") - _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + _, _, err := a.ValidateRequest(context.Background(), &AppSecRequest{ Host: "myhost.com", Method: "POST", URL: "/login", @@ -261,6 +270,7 @@ func TestValidateRequest_SendsRequiredHeaders(t *testing.T) { UserAgent: "Mozilla/5.0", Version: "1.1", }) + require.NoError(t, err) require.NotNil(t, capturedReq) assert.Equal(t, "10.0.0.1", capturedReq.Header.Get("X-Crowdsec-Appsec-Ip")) @@ -276,20 +286,21 @@ func TestValidateRequest_PostWithBody(t *testing.T) { var receivedBody []byte srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - require.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, http.MethodPost, r.Method) var err error receivedBody, err = io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) })) defer srv.Close() a := newTestAppSec(srv.URL, "key") payload := []byte("t=token&n=nonce&f=fingerprint") - _, _, _ = a.ValidateRequest(context.Background(), &AppSecRequest{ + _, _, err := a.ValidateRequest(context.Background(), &AppSecRequest{ Host: "example.com", Method: "POST", URL: "/submit", RemoteIP: "1.2.3.4", Body: payload, }) + require.NoError(t, err) assert.Equal(t, payload, receivedBody) } diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go index 06807c79..7c61f423 100644 --- a/pkg/spoa/challenge_server.go +++ b/pkg/spoa/challenge_server.go @@ -52,7 +52,10 @@ func (cs *ChallengeServer) Serve(ctx context.Context) error { go func() { <-ctx.Done() - _ = srv.Shutdown(context.Background()) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + + _ = srv.Shutdown(shutdownCtx) }() cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go index bbb64acb..873ba1d1 100644 --- a/pkg/spoa/challenge_server_test.go +++ b/pkg/spoa/challenge_server_test.go @@ -16,15 +16,17 @@ import ( // mockAppSecHandler returns an http.Handler that responds with the given JSON body // and status code to simulate the AppSec engine's wire format. -func mockAppSecHandler(statusCode int, payload interface{}) http.Handler { +func mockAppSecHandler(statusCode int, payload any) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) - _ = json.NewEncoder(w).Encode(payload) + if err := json.NewEncoder(w).Encode(payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } }) } -func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*ChallengeServer, *httptest.Server) { +func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) *ChallengeServer { t.Helper() appSecSrv := httptest.NewServer(appSecHandler) t.Cleanup(appSecSrv.Close) @@ -38,8 +40,7 @@ func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) (*Chall // logger exposed through the embedded unexported field; set via Init below } - cs := newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) - return cs, appSecSrv + return newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) } // roundtrip sends req to the ChallengeServer and returns the recorded response. @@ -54,7 +55,7 @@ func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorde func TestChallengeServer_ServesChallengePage(t *testing.T) { const challengeHTML = "CrowdSec Challenge" - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": challengeHTML, @@ -66,7 +67,7 @@ func TestChallengeServer_ServesChallengePage(t *testing.T) { "user_cookies": []string{}, })) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -78,7 +79,7 @@ func TestChallengeServer_ServesChallengePage(t *testing.T) { } func TestChallengeServer_ForwardsCookies(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": `{"status":"ok"}`, @@ -98,7 +99,7 @@ func TestChallengeServer_ForwardsCookies(t *testing.T) { } func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "ok", @@ -109,7 +110,7 @@ func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { }, })) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -120,9 +121,9 @@ func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -136,9 +137,9 @@ func TestChallengeServer_UsesRealIPHeader(t *testing.T) { capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") roundtrip(cs, req) @@ -151,9 +152,9 @@ func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/challenge", nil) + req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) req.RemoteAddr = "203.0.113.5:12345" // No ChallengeRealIPHeader set roundtrip(cs, req) @@ -166,17 +167,17 @@ func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var err error receivedBody, err = io.ReadAll(r.Body) - require.NoError(t, err) + assert.NoError(t, err) // Return a challenge response so ServeHTTP doesn't return early w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": "ok", "user_headers": map[string][]string{}, "user_cookies": []string{}, - }) + })) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", @@ -194,9 +195,9 @@ func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { capturedHeaders = r.Header.Clone() w.WriteHeader(http.StatusOK) }) - cs, _ := newChallengeServerForTest(t, handler) + cs := newChallengeServerForTest(t, handler) - req := httptest.NewRequest(http.MethodGet, "/", nil) + req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") req.Header.Set("X-Forwarded-For", "10.0.0.1") roundtrip(cs, req) @@ -210,7 +211,7 @@ func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { const workerJS = "// pow worker" - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": workerJS, @@ -221,7 +222,7 @@ func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { "user_cookies": []string{}, })) - req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", nil) + req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", http.NoBody) req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") rr := roundtrip(cs, req) @@ -232,7 +233,7 @@ func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { } func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { - cs, _ := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]interface{}{ + cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ "action": "challenge", "http_status": 200, "user_body_content": `{"status":"failed"}`, diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 17ab7844..2ed89c9d 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -75,8 +75,8 @@ var ( type Spoa struct { ListenAddr net.Listener - ListenSocket net.Listener - logger *log.Entry + ListenSocket net.Listener + logger *log.Entry // Direct access to shared data (no IPC needed) dataset *dataset.DataSet hostManager *host.Manager @@ -86,14 +86,14 @@ type Spoa struct { } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -531,7 +531,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r, _ = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) + r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) // Challenge content is served by the challenge HTTP server, not via SPOE vars. } return @@ -555,7 +555,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r, _ = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) } @@ -584,7 +584,6 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { } // validateWithAppSec performs AppSec validation and returns the remediation. -// When AppSec issues a challenge the second return value carries the page content. func (s *Spoa) validateWithAppSec( ctx context.Context, msgData *HTTPMessageData, @@ -592,7 +591,7 @@ func (s *Spoa) validateWithAppSec( appSecToUse *appsec.AppSec, currentRemediation remediation.Remediation, requestTimeout time.Duration, -) (remediation.Remediation, *appsec.AppSecChallengeData) { +) remediation.Remediation { appSecReq := msgData.buildAppSecRequest() logger := s.logger @@ -606,10 +605,10 @@ func (s *Spoa) validateWithAppSec( appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, _, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") - return currentRemediation, nil + return currentRemediation } logger.WithField("remediation", appSecRemediation.String()).Debug("AppSec validation result") @@ -628,12 +627,11 @@ func (s *Spoa) validateWithAppSec( if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } - return appSecRemediation, challengeData + return appSecRemediation } - return currentRemediation, nil + return currentRemediation } - // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { req := &appsec.AppSecRequest{ From e461b5ade6cb957e29cd79e4790bc97ad5cb806b Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 14:27:23 +0200 Subject: [PATCH 15/18] add some doc --- CHALLENGE.md | 266 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 CHALLENGE.md diff --git a/CHALLENGE.md b/CHALLENGE.md new file mode 100644 index 00000000..2199526f --- /dev/null +++ b/CHALLENGE.md @@ -0,0 +1,266 @@ +# AppSec Challenge Workflow + +This document explains how an AppSec browser challenge travels through HAProxy +and the SPOA bouncer, and which component is responsible for each part of the +flow. + +The challenge is served on the original requested URL, including `/`. A public +`/challenge` endpoint is not required. + +## Responsibility Summary + +| Component | Responsibilities | +| --- | --- | +| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads the returned remediation, and routes `challenge` requests to the bouncer challenge HTTP listener. | +| SPOA bouncer listener | Evaluates CrowdSec decisions and host policy, calls AppSec, and sets `txn.crowdsec.remediation=challenge` when AppSec requests a challenge. | +| Bouncer challenge HTTP listener | Receives requests routed by HAProxy, calls AppSec again, and returns AppSec's challenge body, headers, and cookies to the browser. | +| CrowdSec AppSec | Decides whether a request is allowed, banned, or challenged. It generates the challenge page, assets, submit responses, and challenge cookies. | +| Application backend | Receives the request only when the final remediation is `allow`. | + +HAProxy owns request routing. The bouncer cannot route the browser connection +from its SPOE listener because SPOE only returns actions and transaction +variables to HAProxy. + +The bouncer owns both the CrowdSec decision logic and the internal HTTP listener +that serves AppSec challenge responses. + +## Listeners + +The bouncer exposes two different listeners: + +```text +:9000 SPOA listener +:9001 Challenge HTTP listener +``` + +- The SPOA listener on `:9000` communicates with HAProxy through the SPOE + protocol. It returns the remediation decision, but not the challenge HTML. +- The challenge listener on `:9001` communicates through normal HTTP. HAProxy + routes challenged browser requests to it so large HTML, JavaScript, JSON, and + cookies do not pass through SPOE. + +The challenge listener should only be reachable by HAProxy. + +## Remediation Value + +The bouncer defines `challenge` as a dedicated remediation. Its ordering is: + +```text +allow < unknown < captcha < challenge < ban +``` + +This makes `challenge` more restrictive than captcha and less restrictive than +ban when the bouncer selects the most restrictive remediation. + +## End-to-End Workflow + +```mermaid +sequenceDiagram + participant Browser + participant HAProxy + participant SPOA as Bouncer SPOA listener :9000 + participant Challenge as Bouncer challenge listener :9001 + participant AppSec as CrowdSec AppSec + participant Backend as Application backend + + Browser->>HAProxy: Request original URL, for example / + HAProxy->>SPOA: SPOE HTTP message + SPOA->>AppSec: Validate request + AppSec-->>SPOA: action=challenge + SPOA-->>HAProxy: txn.crowdsec.remediation=challenge + + Note over HAProxy: HAProxy owns the routing decision + HAProxy->>Challenge: Forward the same request and original URL + Challenge->>AppSec: Request challenge response + AppSec-->>Challenge: Challenge body, headers, and cookies + Challenge-->>HAProxy: HTTP challenge response + HAProxy-->>Browser: Challenge body, headers, and cookies + + Browser->>HAProxy: Challenge asset or proof submission + HAProxy->>SPOA: SPOE HTTP message, including body when present + SPOA->>AppSec: Validate request or proof + AppSec-->>SPOA: challenge or allow + + alt AppSec still returns challenge + SPOA-->>HAProxy: remediation=challenge + HAProxy->>Challenge: Forward request + Challenge->>AppSec: Request response + AppSec-->>Challenge: Asset or submit response + Challenge-->>Browser: Response + else AppSec returns allow + SPOA-->>HAProxy: remediation=allow + HAProxy->>Backend: Forward allowed request + Backend-->>Browser: Application response + end +``` + +## Phase 1: Decide Through SPOE + +HAProxy sends each HTTP request to the SPOA bouncer: + +```haproxy +http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found } +http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found } +``` + +The SPOA bouncer: + +1. Checks the current CrowdSec IP decision. +2. Applies host-specific policy. +3. Calls AppSec when AppSec validation is enabled. +4. Selects the most restrictive remediation. +5. Sets the HAProxy transaction remediation. + +When AppSec requests a browser challenge, the bouncer returns: + +```text +txn.crowdsec.remediation = challenge +``` + +The challenge body is intentionally not returned through SPOE because it can be +larger than a SPOE frame. + +The first AppSec call is only used to decide the remediation. Even when AppSec +returns challenge content with that decision, the content is not sent back +through SPOE. + +## Phase 2: Route And Serve The Challenge + +After receiving `remediation=challenge`, HAProxy: + +1. Adds the trusted real-client-IP header. +2. Routes the original request to the challenge backend. +3. Does not invoke the Lua ban/captcha renderer. + +```haproxy +http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } + +use_backend crowdsec_challenge if { var(txn.crowdsec.remediation) -m str "challenge" } +use_backend app + +backend crowdsec_challenge + mode http + server challenge 127.0.0.1:9001 +``` + +This rule is based on the remediation, not the request path. If AppSec challenges +`/`, HAProxy forwards `/` to the challenge listener. Challenge assets and proof +submissions follow the same routing process. + +The bouncer challenge HTTP listener then: + +1. Reads the client IP from `X-Crowdsec-Real-Ip`. +2. Falls back to the connection's `RemoteAddr` when the header is absent. +3. Reads the request body with a 10 MB limit. +4. Clones the original request headers. +5. Removes `X-Crowdsec-Real-Ip` and `X-Forwarded-For`. +6. Reconstructs the AppSec request using the original host, method, URL, + headers, body, user agent, and client IP. +7. Calls AppSec. +8. Writes AppSec's status, body, selected headers, and `Set-Cookie` values back + to HAProxy. + +HAProxy returns that response to the browser. + +If AppSec returns `allow` during this second call, the challenge listener +returns HTTP `200` with an empty body. The next browser request, carrying the +solved challenge cookie, follows the normal SPOE decision flow and can be routed +to the application backend. + +## AppSec Challenge Response + +When AppSec decides to challenge a request, it returns HTTP `403` to the bouncer +with a JSON envelope: + +```json +{ + "action": "challenge", + "http_status": 200, + "user_body_content": "...", + "user_headers": { + "Content-Type": ["text/html"], + "Content-Security-Policy": ["default-src 'self'"], + "Cache-Control": ["no-cache, no-store"] + }, + "user_cookies": [ + "__crowdsec_challenge=...; HttpOnly; Path=/; SameSite=Lax" + ] +} +``` + +The bouncer interprets the fields as follows: + +- `action` must be `challenge`; any other `403` action is treated as `ban`. +- `http_status` is returned to the browser. The challenge listener defaults to + `200` when it is absent or invalid. +- `user_body_content` contains the HTML, JavaScript, or JSON response body. +- `user_headers` contains selected response headers forwarded to the browser. +- `user_cookies` contains individual `Set-Cookie` values forwarded to the + browser. + +An empty or invalid JSON body on an AppSec `403` response defaults to `ban`. + +## Configuration + +Enable AppSec and the challenge HTTP listener in the bouncer configuration: + +```yaml +appsec_url: http://127.0.0.1:7422/ +appsec_timeout: 200ms +challenge_listen: 127.0.0.1:9001 +``` + +When HAProxy and the bouncer run in different containers or hosts, bind the +challenge listener to a trusted private interface and point the HAProxy +`crowdsec_challenge` backend to that address. + +## Challenge Assets, Proof Submission, And Cookies + +AppSec may instruct the browser to request challenge assets or submit proof +data, for example: + +```text +/crowdsec-internal/challenge/pow-worker.js +/crowdsec-internal/challenge/submit +``` + +HAProxy must continue sending these requests through SPOE. Proof submissions +usually contain a request body, so the body-capable SPOE group must be used when +the body is within the configured limit. + +AppSec owns challenge cookies and validates solved challenge state. The bouncer +does not interpret these cookies; its challenge listener forwards AppSec's +`Set-Cookie` values to the browser. + +Solved state is visible to AppSec on subsequent requests through the browser's +normal `Cookie` header. When AppSec recognizes a valid challenge cookie, it can +return `allow`. + +## Failure Behavior + +During SPOE validation, AppSec responses are interpreted as follows: + +| AppSec response | Bouncer result | +| --- | --- | +| HTTP `200` | `allow` | +| HTTP `403` with `action=challenge` | `challenge` | +| HTTP `403` with another action | `ban` | +| HTTP `403` with an empty or invalid body | `ban` | +| HTTP `401`, `500`, or another unexpected status | Keep the existing remediation and log an error | + +If the AppSec call made by the challenge HTTP listener fails, the listener +returns HTTP `500`. If AppSec returns `allow` during challenge handling, the +listener returns HTTP `200` with an empty body. + +## Security Requirements + +- Do not expose the challenge HTTP listener directly to the internet. +- Only trust `X-Crowdsec-Real-Ip` when it is added by HAProxy. +- Bind AppSec and both bouncer listeners to loopback or trusted private + interfaces. +- Do not send `challenge` remediation through the Lua ban/captcha renderer. +- Keep challenge response bodies out of SPOE. +- Preserve repeated `Set-Cookie` headers individually. From 7deeb22393bb485219a736a5f2a1aae59ad4efba Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 5 Jun 2026 15:18:30 +0200 Subject: [PATCH 16/18] improve doc --- CHALLENGE.md | 6 +++--- config/crowdsec-spoa-bouncer.docker.yaml | 10 +++++++--- config/haproxy-upstreamproxy.cfg | 9 +++++++++ config/haproxy.cfg | 9 +++++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/CHALLENGE.md b/CHALLENGE.md index 2199526f..f713822a 100644 --- a/CHALLENGE.md +++ b/CHALLENGE.md @@ -138,10 +138,10 @@ http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediat http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } -use_backend crowdsec_challenge if { var(txn.crowdsec.remediation) -m str "challenge" } +use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend app -backend crowdsec_challenge +backend crowdsec-challenge mode http server challenge 127.0.0.1:9001 ``` @@ -215,7 +215,7 @@ challenge_listen: 127.0.0.1:9001 When HAProxy and the bouncer run in different containers or hosts, bind the challenge listener to a trusted private interface and point the HAProxy -`crowdsec_challenge` backend to that address. +`crowdsec-challenge` backend to that address. ## Challenge Assets, Proof Submission, And Cookies diff --git a/config/crowdsec-spoa-bouncer.docker.yaml b/config/crowdsec-spoa-bouncer.docker.yaml index 46de4280..f840c61f 100644 --- a/config/crowdsec-spoa-bouncer.docker.yaml +++ b/config/crowdsec-spoa-bouncer.docker.yaml @@ -21,9 +21,13 @@ listen_tcp: ${LISTEN_TCP} #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb -## Global AppSec configuration (optional) -#appsec_url: ${APPSEC_URL} -#appsec_timeout: ${APPSEC_TIMEOUT} +## Global AppSec configuration +appsec_url: ${APPSEC_URL} +appsec_timeout: ${APPSEC_TIMEOUT} + +## Challenge HTTP listener +## HAProxy routes remediation=challenge requests to this listener. +challenge_listen: ${CHALLENGE_LISTEN} ## Prometheus metrics endpoint prometheus: diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index 11b33f7d..a7c10252 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -91,12 +91,17 @@ frontend test http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected + ## Route challenge requests through the bouncer challenge HTTP listener. + ## This applies to the original application URL, including /; no public /challenge route is required. + http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -108,3 +113,7 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 + +backend crowdsec-challenge + mode http + server challenge spoa:9001 diff --git a/config/haproxy.cfg b/config/haproxy.cfg index 62f5a3f8..e1e58c19 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -75,12 +75,17 @@ frontend test http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected + ## Route challenge requests through the bouncer challenge HTTP listener. + ## This applies to the original application URL, including /; no public /challenge route is required. + http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } + ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } + use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -92,3 +97,7 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 + +backend crowdsec-challenge + mode http + server challenge spoa:9001 From 04b873b560e1a129b32305fa971a39aab380600c Mon Sep 17 00:00:00 2001 From: sabban Date: Fri, 26 Jun 2026 18:24:42 +0200 Subject: [PATCH 17/18] remove the side http server from challenge mode --- CHALLENGE.md | 248 +++++------------ Vagrantfile | 4 +- cmd/root.go | 15 +- config/crowdsec-spoa-bouncer.docker.yaml | 4 - ...ec-spoa-bouncer.no-challenge-listener.yaml | 45 ---- config/crowdsec-spoa-bouncer.yaml | 5 - config/haproxy-upstreamproxy.cfg | 14 +- config/haproxy.cfg | 14 +- go.mod | 2 + go.sum | 4 +- lua/crowdsec.lua | 40 ++- pkg/cfg/config.go | 3 +- pkg/spoa/challenge_server.go | 152 ----------- pkg/spoa/challenge_server_test.go | 252 ------------------ pkg/spoa/root.go | 71 ++--- 15 files changed, 159 insertions(+), 714 deletions(-) delete mode 100644 config/crowdsec-spoa-bouncer.no-challenge-listener.yaml delete mode 100644 pkg/spoa/challenge_server.go delete mode 100644 pkg/spoa/challenge_server_test.go diff --git a/CHALLENGE.md b/CHALLENGE.md index f713822a..8c6de990 100644 --- a/CHALLENGE.md +++ b/CHALLENGE.md @@ -1,47 +1,26 @@ # AppSec Challenge Workflow This document explains how an AppSec browser challenge travels through HAProxy -and the SPOA bouncer, and which component is responsible for each part of the -flow. +and the SPOA bouncer. The challenge is served on the original requested URL, including `/`. A public -`/challenge` endpoint is not required. +`/challenge` endpoint is not required, and the bouncer does not expose a +separate challenge HTTP listener. -## Responsibility Summary +## Components -| Component | Responsibilities | -| --- | --- | -| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads the returned remediation, and routes `challenge` requests to the bouncer challenge HTTP listener. | -| SPOA bouncer listener | Evaluates CrowdSec decisions and host policy, calls AppSec, and sets `txn.crowdsec.remediation=challenge` when AppSec requests a challenge. | -| Bouncer challenge HTTP listener | Receives requests routed by HAProxy, calls AppSec again, and returns AppSec's challenge body, headers, and cookies to the browser. | +| Component | Role | +|---|---| +| HAProxy | Receives the browser request, sends request data to the SPOA bouncer, reads returned transaction variables, and calls Lua for challenge responses. | +| SPOA bouncer | Evaluates CrowdSec decisions and host policy, calls AppSec, and returns remediation plus challenge response data through SPOE variables. | +| HAProxy Lua handler | Writes the challenge body, status, headers, and cookie returned by the bouncer to the browser. | | CrowdSec AppSec | Decides whether a request is allowed, banned, or challenged. It generates the challenge page, assets, submit responses, and challenge cookies. | -| Application backend | Receives the request only when the final remediation is `allow`. | -HAProxy owns request routing. The bouncer cannot route the browser connection -from its SPOE listener because SPOE only returns actions and transaction -variables to HAProxy. +The bouncer runs one SPOA listener. Challenge content is returned over SPOE; this +assumes the configured HAProxy/SPOE frame size is large enough for the AppSec +challenge responses used in the deployment. -The bouncer owns both the CrowdSec decision logic and the internal HTTP listener -that serves AppSec challenge responses. - -## Listeners - -The bouncer exposes two different listeners: - -```text -:9000 SPOA listener -:9001 Challenge HTTP listener -``` - -- The SPOA listener on `:9000` communicates with HAProxy through the SPOE - protocol. It returns the remediation decision, but not the challenge HTML. -- The challenge listener on `:9001` communicates through normal HTTP. HAProxy - routes challenged browser requests to it so large HTML, JavaScript, JSON, and - cookies do not pass through SPOE. - -The challenge listener should only be reachable by HAProxy. - -## Remediation Value +## Remediation Ordering The bouncer defines `challenge` as a dedicated remediation. Its ordering is: @@ -50,140 +29,85 @@ allow < unknown < captcha < challenge < ban ``` This makes `challenge` more restrictive than captcha and less restrictive than -ban when the bouncer selects the most restrictive remediation. +ban. AppSec may therefore upgrade an allowed request to `challenge`, but a +dataset ban still wins over an AppSec challenge. -## End-to-End Workflow +## Request Flow ```mermaid sequenceDiagram participant Browser participant HAProxy - participant SPOA as Bouncer SPOA listener :9000 - participant Challenge as Bouncer challenge listener :9001 + participant SPOA as SPOA bouncer participant AppSec as CrowdSec AppSec - participant Backend as Application backend - - Browser->>HAProxy: Request original URL, for example / - HAProxy->>SPOA: SPOE HTTP message - SPOA->>AppSec: Validate request - AppSec-->>SPOA: action=challenge - SPOA-->>HAProxy: txn.crowdsec.remediation=challenge + participant Lua as HAProxy Lua - Note over HAProxy: HAProxy owns the routing decision - HAProxy->>Challenge: Forward the same request and original URL - Challenge->>AppSec: Request challenge response - AppSec-->>Challenge: Challenge body, headers, and cookies - Challenge-->>HAProxy: HTTP challenge response - HAProxy-->>Browser: Challenge body, headers, and cookies + Browser->>HAProxy: GET / + HAProxy->>SPOA: crowdsec-http-body or crowdsec-http-no-body + SPOA->>AppSec: Request metadata, headers, body when available + AppSec-->>SPOA: action=challenge + body/headers/cookie + SPOA-->>HAProxy: remediation=challenge + challenge_* vars + HAProxy->>Lua: crowdsec_handle + Lua-->>Browser: Challenge response Browser->>HAProxy: Challenge asset or proof submission - HAProxy->>SPOA: SPOE HTTP message, including body when present - SPOA->>AppSec: Validate request or proof - AppSec-->>SPOA: challenge or allow + HAProxy->>SPOA: Same original URL/path and request data + SPOA->>AppSec: Validate request alt AppSec still returns challenge - SPOA-->>HAProxy: remediation=challenge - HAProxy->>Challenge: Forward request - Challenge->>AppSec: Request response - AppSec-->>Challenge: Asset or submit response - Challenge-->>Browser: Response - else AppSec returns allow + AppSec-->>SPOA: Challenge asset or submit response + SPOA-->>HAProxy: remediation=challenge + challenge_* vars + Lua-->>Browser: AppSec response + else AppSec allows + AppSec-->>SPOA: allow SPOA-->>HAProxy: remediation=allow - HAProxy->>Backend: Forward allowed request - Backend-->>Browser: Application response + HAProxy->>Backend: Forward request end ``` -## Phase 1: Decide Through SPOE +## HAProxy Wiring -HAProxy sends each HTTP request to the SPOA bouncer: +The HTTP SPOE groups must send enough request data for AppSec to make a decision: ```haproxy http-request send-spoe-group crowdsec crowdsec-http-body if body_within_limit || !{ req.body_size -m found } http-request send-spoe-group crowdsec crowdsec-http-no-body if !body_within_limit { req.body_size -m found } ``` -The SPOA bouncer: - -1. Checks the current CrowdSec IP decision. -2. Applies host-specific policy. -3. Calls AppSec when AppSec validation is enabled. -4. Selects the most restrictive remediation. -5. Sets the HAProxy transaction remediation. - -When AppSec requests a browser challenge, the bouncer returns: - -```text -txn.crowdsec.remediation = challenge -``` - -The challenge body is intentionally not returned through SPOE because it can be -larger than a SPOE frame. - -The first AppSec call is only used to decide the remediation. Even when AppSec -returns challenge content with that decision, the content is not sent back -through SPOE. - -## Phase 2: Route And Serve The Challenge - -After receiving `remediation=challenge`, HAProxy: - -1. Adds the trusted real-client-IP header. -2. Routes the original request to the challenge backend. -3. Does not invoke the Lua ban/captcha renderer. +When AppSec returns `challenge`, the bouncer sets transaction variables and +HAProxy calls the Lua response handler: ```haproxy -http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - +http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "captcha" } http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "ban" } - -use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } -use_backend app - -backend crowdsec-challenge - mode http - server challenge 127.0.0.1:9001 ``` -This rule is based on the remediation, not the request path. If AppSec challenges -`/`, HAProxy forwards `/` to the challenge listener. Challenge assets and proof -submissions follow the same routing process. - -The bouncer challenge HTTP listener then: +The Lua handler uses these transaction variables for challenge responses: -1. Reads the client IP from `X-Crowdsec-Real-Ip`. -2. Falls back to the connection's `RemoteAddr` when the header is absent. -3. Reads the request body with a 10 MB limit. -4. Clones the original request headers. -5. Removes `X-Crowdsec-Real-Ip` and `X-Forwarded-For`. -6. Reconstructs the AppSec request using the original host, method, URL, - headers, body, user agent, and client IP. -7. Calls AppSec. -8. Writes AppSec's status, body, selected headers, and `Set-Cookie` values back - to HAProxy. - -HAProxy returns that response to the browser. - -If AppSec returns `allow` during this second call, the challenge listener -returns HTTP `200` with an empty body. The next browser request, carrying the -solved challenge cookie, follows the normal SPOE decision flow and can be routed -to the application backend. +| Variable | Meaning | +|---|---| +| `txn.crowdsec.challenge_status` | HTTP status returned to the browser. Defaults to `200` if missing. | +| `txn.crowdsec.challenge_body` | AppSec response body. | +| `txn.crowdsec.challenge_content_type` | Optional `Content-Type` header. | +| `txn.crowdsec.challenge_csp` | Optional `Content-Security-Policy` header. | +| `txn.crowdsec.challenge_cache_control` | Optional `Cache-Control` header. | +| `txn.crowdsec.challenge_cookie` | Optional `Set-Cookie` header. | ## AppSec Challenge Response When AppSec decides to challenge a request, it returns HTTP `403` to the bouncer -with a JSON envelope: +with a JSON body similar to: ```json { "action": "challenge", "http_status": 200, - "user_body_content": "...", + "user_body_content": "...", "user_headers": { "Content-Type": ["text/html"], "Content-Security-Policy": ["default-src 'self'"], - "Cache-Control": ["no-cache, no-store"] + "Cache-Control": ["no-store"] }, "user_cookies": [ "__crowdsec_challenge=...; HttpOnly; Path=/; SameSite=Lax" @@ -191,76 +115,28 @@ with a JSON envelope: } ``` -The bouncer interprets the fields as follows: +Rules: - `action` must be `challenge`; any other `403` action is treated as `ban`. -- `http_status` is returned to the browser. The challenge listener defaults to - `200` when it is absent or invalid. -- `user_body_content` contains the HTML, JavaScript, or JSON response body. -- `user_headers` contains selected response headers forwarded to the browser. -- `user_cookies` contains individual `Set-Cookie` values forwarded to the - browser. - -An empty or invalid JSON body on an AppSec `403` response defaults to `ban`. +- `http_status` is returned to the browser, defaulting to `200` if omitted. +- `user_body_content` becomes `txn.crowdsec.challenge_body`. +- Selected `user_headers` are forwarded through challenge transaction variables. +- The first `user_cookies` value is forwarded as `Set-Cookie`. ## Configuration -Enable AppSec and the challenge HTTP listener in the bouncer configuration: +Enable AppSec in the bouncer configuration: ```yaml appsec_url: http://127.0.0.1:7422/ appsec_timeout: 200ms -challenge_listen: 127.0.0.1:9001 -``` - -When HAProxy and the bouncer run in different containers or hosts, bind the -challenge listener to a trusted private interface and point the HAProxy -`crowdsec-challenge` backend to that address. - -## Challenge Assets, Proof Submission, And Cookies - -AppSec may instruct the browser to request challenge assets or submit proof -data, for example: - -```text -/crowdsec-internal/challenge/pow-worker.js -/crowdsec-internal/challenge/submit ``` -HAProxy must continue sending these requests through SPOE. Proof submissions -usually contain a request body, so the body-capable SPOE group must be used when -the body is within the configured limit. - -AppSec owns challenge cookies and validates solved challenge state. The bouncer -does not interpret these cookies; its challenge listener forwards AppSec's -`Set-Cookie` values to the browser. - -Solved state is visible to AppSec on subsequent requests through the browser's -normal `Cookie` header. When AppSec recognizes a valid challenge cookie, it can -return `allow`. - -## Failure Behavior - -During SPOE validation, AppSec responses are interpreted as follows: - -| AppSec response | Bouncer result | -| --- | --- | -| HTTP `200` | `allow` | -| HTTP `403` with `action=challenge` | `challenge` | -| HTTP `403` with another action | `ban` | -| HTTP `403` with an empty or invalid body | `ban` | -| HTTP `401`, `500`, or another unexpected status | Keep the existing remediation and log an error | - -If the AppSec call made by the challenge HTTP listener fails, the listener -returns HTTP `500`. If AppSec returns `allow` during challenge handling, the -listener returns HTTP `200` with an empty body. +No `challenge_listen` setting is required. -## Security Requirements +## Notes -- Do not expose the challenge HTTP listener directly to the internet. -- Only trust `X-Crowdsec-Real-Ip` when it is added by HAProxy. -- Bind AppSec and both bouncer listeners to loopback or trusted private - interfaces. -- Do not send `challenge` remediation through the Lua ban/captcha renderer. -- Keep challenge response bodies out of SPOE. -- Preserve repeated `Set-Cookie` headers individually. +- Captcha remediation remains separate from AppSec challenge remediation. +- Challenge assets and proof submissions are ordinary requests inspected by the same SPOE flow. +- If AppSec validation fails, the bouncer keeps the previous remediation. +- Frame size must be configured high enough for the largest challenge response sent through SPOE. diff --git a/Vagrantfile b/Vagrantfile index 1b752ac8..f0e92f59 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -84,8 +84,8 @@ EOF # Copy and configure HAProxy cp /vagrant/config/haproxy.cfg /etc/haproxy/haproxy.cfg 2>/dev/null || true cp /vagrant/config/crowdsec.cfg /etc/haproxy/crowdsec.cfg 2>/dev/null || true - # Update server addresses and remove the second SPOA server (port 9001 doesn't exist) - sed -i 's/whoami:2020/127.0.0.1:4444/g; s/spoa:9000/127.0.0.1:9000/g; /server s3 spoa:9001/d' \ + # Update server addresses for the local VM services. + sed -i 's/whoami:2020/127.0.0.1:4444/g; s/spoa:9000/127.0.0.1:9000/g' \ /etc/haproxy/haproxy.cfg 2>/dev/null || true # Increase SPOA processing timeout to accommodate AppSec calls (AppSec has 5s timeout) sed -i 's/timeout\s\+processing\s\+500ms/timeout processing 6s/' \ diff --git a/cmd/root.go b/cmd/root.go index 0a22ea38..01f76275 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -261,14 +261,13 @@ func Execute() error { // Create single SPOA directly with minimal configuration spoaConfig := &spoa.SpoaConfig{ - TcpAddr: config.ListenTCP, - UnixAddr: config.ListenUnix, - Dataset: dataSet, - HostManager: HostManager, - GeoDatabase: &config.Geo, - GlobalAppSec: globalAppSec, - ChallengeAddr: config.ChallengeAddr, - Logger: spoaLogger, + TcpAddr: config.ListenTCP, + UnixAddr: config.ListenUnix, + Dataset: dataSet, + HostManager: HostManager, + GeoDatabase: &config.Geo, + GlobalAppSec: globalAppSec, + Logger: spoaLogger, } singleSpoa, err := spoa.New(spoaConfig) diff --git a/config/crowdsec-spoa-bouncer.docker.yaml b/config/crowdsec-spoa-bouncer.docker.yaml index f840c61f..3abe1efd 100644 --- a/config/crowdsec-spoa-bouncer.docker.yaml +++ b/config/crowdsec-spoa-bouncer.docker.yaml @@ -25,10 +25,6 @@ listen_tcp: ${LISTEN_TCP} appsec_url: ${APPSEC_URL} appsec_timeout: ${APPSEC_TIMEOUT} -## Challenge HTTP listener -## HAProxy routes remediation=challenge requests to this listener. -challenge_listen: ${CHALLENGE_LISTEN} - ## Prometheus metrics endpoint prometheus: enabled: ${PROMETHEUS_ENABLED} diff --git a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml b/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml deleted file mode 100644 index 7ed5951c..00000000 --- a/config/crowdsec-spoa-bouncer.no-challenge-listener.yaml +++ /dev/null @@ -1,45 +0,0 @@ -## Default configuration without the challenge HTTP listener. -## Use this variant when AppSec challenge routing is not deployed yet. - -## Log configuration -log_mode: file -log_dir: /var/log/crowdsec-spoa/ -log_level: info -log_compression: true -log_max_size: 100 -log_max_backups: 3 -log_max_age: 30 - -## LAPI configuration -update_frequency: 10s -api_url: http://127.0.0.1:8080/ -api_key: ${API_KEY} -insecure_skip_verify: false - -## SPOA listener configuration -# Configure TCP and/or Unix socket listeners -listen_tcp: 0.0.0.0:9000 -listen_unix: /run/crowdsec-spoa/spoa.sock -#asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb -#city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb - -## Global AppSec configuration -## AppSec can still be enabled for allow/ban decisions without exposing a challenge listener. -appsec_url: http://127.0.0.1:7422/ -appsec_timeout: 200ms - -## Challenge HTTP listener intentionally disabled in this variant. -#challenge_listen: 127.0.0.1:9001 - -prometheus: - enabled: false - listen_addr: 127.0.0.1 - listen_port: 60601 - -## pprof debug endpoint for runtime profiling -## WARNING: Only enable for debugging, exposes internal runtime data -## Endpoints: /debug/pprof/heap, /debug/pprof/profile, /debug/pprof/goroutine, etc. -#pprof: -# enabled: false -# listen_addr: 127.0.0.1 -# listen_port: 6060 diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index b5bef770..2af5fe9c 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -25,11 +25,6 @@ listen_unix: /run/crowdsec-spoa/spoa.sock appsec_url: http://127.0.0.1:7422/ appsec_timeout: 200ms -## Challenge HTTP listener -## HAProxy routes remediation=challenge requests to this listener. -## Keep this bound to loopback unless HAProxy reaches the bouncer through a trusted private network. -challenge_listen: 127.0.0.1:9001 - prometheus: enabled: false listen_addr: 127.0.0.1 diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index a7c10252..5034dd30 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -4,7 +4,7 @@ global log stdout format raw local0 - tune.bufsize 65536 # 64KB - increased for WAF body inspection + tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s @@ -76,6 +76,9 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } + ## Call lua script for challenge remediation + http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } + ## Render ban and captcha remediations with native HAProxy templates. ## Template paths are fixed at /var/lib/crowdsec-haproxy-spoa-bouncer/html/; edit the lf-file ## directives below if you need custom locations (the old CROWDSEC_*_TEMPLATE_PATH env vars @@ -91,17 +94,12 @@ frontend test http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected - ## Route challenge requests through the bouncer challenge HTTP listener. - ## This applies to the original application URL, including /; no public /challenge route is required. - http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } - use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -113,7 +111,3 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 - -backend crowdsec-challenge - mode http - server challenge spoa:9001 diff --git a/config/haproxy.cfg b/config/haproxy.cfg index e1e58c19..7239a704 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -1,7 +1,7 @@ # https://www.haproxy.com/documentation/hapee/latest/onepage/#home global log stdout format raw local0 - tune.bufsize 65536 # 64KB - increased for WAF body inspection + tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s @@ -60,6 +60,9 @@ frontend test ## Handle 302 redirect for successful captcha validation (redirect to current request URL) http-request redirect code 302 location %[url] if { var(txn.crowdsec.remediation) -m str "allow" } { var(txn.crowdsec.redirect) -m found } + ## Call lua script for challenge remediation + http-request lua.crowdsec_handle if { var(txn.crowdsec.remediation) -m str "challenge" } + ## Render ban and captcha remediations with native HAProxy templates. ## Template paths are fixed at /var/lib/crowdsec-haproxy-spoa-bouncer/html/; edit the lf-file ## directives below if you need custom locations (the old CROWDSEC_*_TEMPLATE_PATH env vars @@ -75,17 +78,12 @@ frontend test http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } !render_html http-request return status 403 content-type text/plain hdr Cache-Control "no-cache, no-store" string "Forbidden\n" if { var(txn.crowdsec.remediation) -m str "ban" } html_rejected - ## Route challenge requests through the bouncer challenge HTTP listener. - ## This applies to the original application URL, including /; no public /challenge route is required. - http-request set-header X-Crowdsec-Real-Ip %[src] if { var(txn.crowdsec.remediation) -m str "challenge" } - ## Handle captcha cookie management via HAProxy (new approach) ## Set captcha cookie when SPOA provides captcha_status (pending or valid) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_status) -m found } { var(txn.crowdsec.captcha_cookie) -m found } ## Clear captcha cookie when cookie exists but no captcha_status (Allow decision) http-after-response set-header Set-Cookie %[var(txn.crowdsec.captcha_cookie)] if { var(txn.crowdsec.captcha_cookie) -m found } !{ var(txn.crowdsec.captcha_status) -m found } - use_backend crowdsec-challenge if { var(txn.crowdsec.remediation) -m str "challenge" } use_backend test_backend backend test_backend @@ -97,7 +95,3 @@ backend crowdsec-spoa timeout connect 2s timeout server 60s server s2 spoa:9000 - -backend crowdsec-challenge - mode http - server challenge spoa:9001 diff --git a/go.mod b/go.mod index 44a434bf..921ed6c7 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,8 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) +replace github.com/dropmorepackets/haproxy-go => github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d + require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index ee220f64..9efc5f75 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dropmorepackets/haproxy-go v0.0.7 h1:atXkB0MSRBZrAgpq+Vj/E4KysQ4CiI0O5QGUr+HvfTw= -github.com/dropmorepackets/haproxy-go v0.0.7/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= @@ -104,6 +102,8 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d h1:OVXewDp/fRc0gVRc0lqV/6t2jDLAREMoS1RcGxcB3ik= +github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/lua/crowdsec.lua b/lua/crowdsec.lua index 160d87aa..9855b8dd 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -113,17 +113,45 @@ function runtime.Handle(txn) reply:add_header("cache-control", "no-cache") reply:add_header("cache-control", "no-store") - -- NOTE: "allow" remediation with redirects is now handled natively by HAProxy. - -- "challenge" is handled by routing to the bouncer challenge HTTP server (no Lua needed). - -- This Lua handler is only called for "captcha" and "ban" remediations. if remediation == "allow" then runtime.logger.warning("Lua handler called for 'allow' remediation - this should not happen with native redirects") return end - -- Always disable cache for ban/captcha pages - reply:add_header("cache-control", "no-cache") - reply:add_header("cache-control", "no-store") + if remediation == "challenge" then + local status = get_txn_var(txn, "crowdsec.challenge_status") + if status ~= "" then + reply:set_status(tonumber(status)) + else + reply:set_status(200) + end + + reply:set_body(get_txn_var(txn, "crowdsec.challenge_body")) + + local content_type = get_txn_var(txn, "crowdsec.challenge_content_type") + if content_type ~= "" then + reply:add_header("Content-Type", content_type) + end + + local csp = get_txn_var(txn, "crowdsec.challenge_csp") + if csp ~= "" then + reply:add_header("Content-Security-Policy", csp) + end + + local cache_control = get_txn_var(txn, "crowdsec.challenge_cache_control") + if cache_control ~= "" then + reply:add_header("Cache-Control", cache_control) + end + + local cookie = get_txn_var(txn, "crowdsec.challenge_cookie") + if cookie ~= "" then + reply:add_header("Set-Cookie", cookie) + end + + reply:add_header("Content-Length", #reply.body) + txn:done(reply) + return + end if remediation == "captcha" then reply:set_status(200) diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index 9bee4cd8..11ca6514 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -38,9 +38,8 @@ type BouncerConfig struct { PrometheusConfig PrometheusConfig `yaml:"prometheus"` PprofConfig PprofConfig `yaml:"pprof"` APIKey string `yaml:"api_key"` // LAPI API key (also used for AppSec) - AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL + AppSecURL string `yaml:"appsec_url,omitempty"` // Global AppSec URL AppSecTimeout time.Duration `yaml:"appsec_timeout,omitempty"` - ChallengeAddr string `yaml:"challenge_listen,omitempty"` // Challenge HTTP server listen address (e.g. "0.0.0.0:9001") } // MergedConfig() returns the byte content of the patched configuration file (with .yaml.local). diff --git a/pkg/spoa/challenge_server.go b/pkg/spoa/challenge_server.go deleted file mode 100644 index 7c61f423..00000000 --- a/pkg/spoa/challenge_server.go +++ /dev/null @@ -1,152 +0,0 @@ -package spoa - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" - "github.com/crowdsecurity/crowdsec-spoa/internal/remediation" - log "github.com/sirupsen/logrus" -) - -const ( - // ChallengeRealIPHeader is the header HAProxy adds to identify the real - // client IP when routing a challenge request to the challenge server. - ChallengeRealIPHeader = "X-Crowdsec-Real-Ip" -) - -// ChallengeServer is a plain HTTP server that HAProxy routes to when the SPOE -// agent returns remediation=challenge. It re-sends the request to the AppSec -// engine, unwraps the JSON response, and writes the challenge page (HTML, JS, -// or submit JSON) directly back to the client. -// -// This avoids the 64 KB SPOE frame-size limit: the large challenge body never -// passes through the SPOE protocol; it travels through a normal HTTP connection -// between HAProxy, this server, and the AppSec engine. -type ChallengeServer struct { - appSec *appsec.AppSec - listen string - logger *log.Entry -} - -func newChallengeServer(appSec *appsec.AppSec, addr string, logger *log.Entry) *ChallengeServer { - return &ChallengeServer{ - appSec: appSec, - listen: addr, - logger: logger.WithField("component", "challenge_server"), - } -} - -func (cs *ChallengeServer) Serve(ctx context.Context) error { - srv := &http.Server{ - Addr: cs.listen, - Handler: cs, - ReadTimeout: 10 * time.Second, - WriteTimeout: 30 * time.Second, - } - - go func() { - <-ctx.Done() - shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - - _ = srv.Shutdown(shutdownCtx) - }() - - cs.logger.Infof("Challenge HTTP server listening on %s", cs.listen) - - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("challenge server: %w", err) - } - - return nil -} - -// ServeHTTP handles a request forwarded by HAProxy when remediation=challenge. -// It builds an AppSec request from the incoming request, calls the AppSec -// engine, and writes the challenge response (body + headers + cookies) back. -func (cs *ChallengeServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - clientIP := r.Header.Get(ChallengeRealIPHeader) - if clientIP == "" { - // Fall back to the connection's remote address (strips port) - if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { - clientIP = r.RemoteAddr[:idx] - } else { - clientIP = r.RemoteAddr - } - } - - // Read the request body (needed for the submit path POST) - var body []byte - if r.Body != nil { - var err error - body, err = io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10 MB limit - if err != nil { - cs.logger.WithError(err).Warn("challenge server: failed to read request body") - } - } - - // Reconstruct the URL for the AppSec header - reqURL := r.URL.RequestURI() - - // Forward the original client headers to AppSec (minus hop-by-hop and our own headers) - headers := r.Header.Clone() - headers.Del(ChallengeRealIPHeader) - headers.Del("X-Forwarded-For") - - appSecReq := &appsec.AppSecRequest{ - Host: r.Host, - Method: r.Method, - URL: reqURL, - RemoteIP: clientIP, - UserAgent: r.UserAgent(), - Headers: headers, - Body: body, - } - - appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) - defer cancel() - - rem, challengeData, err := cs.appSec.ValidateRequest(appSecCtx, appSecReq) - if err != nil { - cs.logger.WithError(err).Error("challenge server: AppSec request failed") - http.Error(w, "internal error", http.StatusInternalServerError) - return - } - - if rem != remediation.Challenge || challengeData == nil { - // AppSec didn't return a challenge (e.g. allow after valid cookie). - // Return 200 with an empty body; HAProxy will handle the pass-through. - w.WriteHeader(http.StatusOK) - return - } - - // Write response headers from AppSec - if challengeData.ContentType != "" { - w.Header().Set("Content-Type", challengeData.ContentType) - } - if challengeData.CSP != "" { - w.Header().Set("Content-Security-Policy", challengeData.CSP) - } - if challengeData.CacheControl != "" { - w.Header().Set("Cache-Control", challengeData.CacheControl) - } - for _, cookie := range challengeData.Cookies { - w.Header().Add("Set-Cookie", cookie) - } - - status := challengeData.StatusCode - if status <= 0 { - status = http.StatusOK - } - - w.WriteHeader(status) - if challengeData.Body != "" { - _, _ = io.Copy(w, bytes.NewBufferString(challengeData.Body)) - } -} diff --git a/pkg/spoa/challenge_server_test.go b/pkg/spoa/challenge_server_test.go deleted file mode 100644 index 873ba1d1..00000000 --- a/pkg/spoa/challenge_server_test.go +++ /dev/null @@ -1,252 +0,0 @@ -package spoa - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/crowdsecurity/crowdsec-spoa/internal/appsec" - log "github.com/sirupsen/logrus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockAppSecHandler returns an http.Handler that responds with the given JSON body -// and status code to simulate the AppSec engine's wire format. -func mockAppSecHandler(statusCode int, payload any) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(statusCode) - if err := json.NewEncoder(w).Encode(payload); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } - }) -} - -func newChallengeServerForTest(t *testing.T, appSecHandler http.Handler) *ChallengeServer { - t.Helper() - appSecSrv := httptest.NewServer(appSecHandler) - t.Cleanup(appSecSrv.Close) - - a := &appsec.AppSec{} - a.InitLogger(log.NewEntry(log.New())) - a.Client = &appsec.AppSecClient{ - HTTPClient: &http.Client{}, - APIKey: "test-api-key", - URL: appSecSrv.URL, - // logger exposed through the embedded unexported field; set via Init below - } - - return newChallengeServer(a, "unused-addr", log.WithField("test", t.Name())) -} - -// roundtrip sends req to the ChallengeServer and returns the recorded response. -func roundtrip(cs *ChallengeServer, req *http.Request) *httptest.ResponseRecorder { - rr := httptest.NewRecorder() - cs.ServeHTTP(rr, req) - return rr -} - -// ---------- tests ---------- - -func TestChallengeServer_ServesChallengePage(t *testing.T) { - const challengeHTML = "CrowdSec Challenge" - - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": challengeHTML, - "user_headers": map[string][]string{ - "Content-Type": {"text/html"}, - "Content-Security-Policy": {"default-src 'self'"}, - "Cache-Control": {"no-store, no-cache"}, - }, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, challengeHTML, rr.Body.String()) - assert.Equal(t, "text/html", rr.Header().Get("Content-Type")) - assert.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) - assert.Equal(t, "no-store, no-cache", rr.Header().Get("Cache-Control")) -} - -func TestChallengeServer_ForwardsCookies(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": `{"status":"ok"}`, - "user_headers": map[string][]string{"Content-Type": {"application/json"}}, - "user_cookies": []string{"__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax"}, - })) - - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", strings.NewReader("t=tok&n=nonce")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - cookies := rr.Header()["Set-Cookie"] - require.Len(t, cookies, 1) - assert.Equal(t, "__crowdsec_challenge=abc123; HttpOnly; Path=/; SameSite=Lax", cookies[0]) -} - -func TestChallengeServer_MultipleSetCookieHeaders(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": "ok", - "user_headers": map[string][]string{}, - "user_cookies": []string{ - "__crowdsec_challenge=abc; HttpOnly", - "session=xyz; Secure", - }, - })) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - cookies := rr.Header()["Set-Cookie"] - assert.Len(t, cookies, 2) -} - -func TestChallengeServer_ReturnsOKWhenAppSecAllows(t *testing.T) { - // AppSec returns 200 (allow) — the challenge server should pass through with 200 and empty body. - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusOK, nil)) - - req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Empty(t, rr.Body.String()) -} - -func TestChallengeServer_UsesRealIPHeader(t *testing.T) { - var capturedIP string - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "203.0.113.42") - roundtrip(cs, req) - - assert.Equal(t, "203.0.113.42", capturedIP) -} - -func TestChallengeServer_FallsBackToRemoteAddrWhenNoRealIPHeader(t *testing.T) { - var capturedIP string - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedIP = r.Header.Get("X-Crowdsec-Appsec-Ip") - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/challenge", http.NoBody) - req.RemoteAddr = "203.0.113.5:12345" - // No ChallengeRealIPHeader set - roundtrip(cs, req) - - assert.Equal(t, "203.0.113.5", capturedIP) -} - -func TestChallengeServer_ForwardsRequestBodyToAppSec(t *testing.T) { - var receivedBody []byte - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var err error - receivedBody, err = io.ReadAll(r.Body) - assert.NoError(t, err) - // Return a challenge response so ServeHTTP doesn't return early - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{ - "action": "challenge", "http_status": 200, - "user_body_content": "ok", "user_headers": map[string][]string{}, - "user_cookies": []string{}, - })) - }) - cs := newChallengeServerForTest(t, handler) - - payload := "t=ticket&n=nonce&p=salt&m=mac&f=fp&h=hmac&ts=123" - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", - strings.NewReader(payload)) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - roundtrip(cs, req) - - assert.Equal(t, payload, string(receivedBody)) -} - -func TestChallengeServer_StripsRealIPHeaderBeforeForwarding(t *testing.T) { - var capturedHeaders http.Header - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - capturedHeaders = r.Header.Clone() - w.WriteHeader(http.StatusOK) - }) - cs := newChallengeServerForTest(t, handler) - - req := httptest.NewRequest(http.MethodGet, "/", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - req.Header.Set("X-Forwarded-For", "10.0.0.1") - roundtrip(cs, req) - - assert.Empty(t, capturedHeaders.Get(ChallengeRealIPHeader), - "X-Crowdsec-Real-Ip must not be forwarded to AppSec") - assert.Empty(t, capturedHeaders.Get("X-Forwarded-For"), - "X-Forwarded-For must not be forwarded to AppSec") -} - -func TestChallengeServer_PowWorkerJSResponse(t *testing.T) { - const workerJS = "// pow worker" - - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": workerJS, - "user_headers": map[string][]string{ - "Content-Type": {"application/javascript"}, - "Cache-Control": {"public, max-age=3600"}, - }, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodGet, "/crowdsec-internal/challenge/pow-worker.js", http.NoBody) - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "application/javascript", rr.Header().Get("Content-Type")) - assert.Equal(t, "public, max-age=3600", rr.Header().Get("Cache-Control")) - assert.Equal(t, workerJS, rr.Body.String()) -} - -func TestChallengeServer_InvalidSubmitResponse(t *testing.T) { - cs := newChallengeServerForTest(t, mockAppSecHandler(http.StatusForbidden, map[string]any{ - "action": "challenge", - "http_status": 200, - "user_body_content": `{"status":"failed"}`, - "user_headers": map[string][]string{"Content-Type": {"application/json"}, "Cache-Control": {"no-cache, no-store"}}, - "user_cookies": []string{}, - })) - - req := httptest.NewRequest(http.MethodPost, "/crowdsec-internal/challenge/submit", - strings.NewReader("t=bad&ts=bad&h=bad&n=bad&p=bad&m=bad&f=bad")) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set(ChallengeRealIPHeader, "1.2.3.4") - rr := roundtrip(cs, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.Contains(t, rr.Body.String(), `"status":"failed"`) -} diff --git a/pkg/spoa/root.go b/pkg/spoa/root.go index 2ed89c9d..bd26fbaa 100644 --- a/pkg/spoa/root.go +++ b/pkg/spoa/root.go @@ -78,22 +78,20 @@ type Spoa struct { ListenSocket net.Listener logger *log.Entry // Direct access to shared data (no IPC needed) - dataset *dataset.DataSet - hostManager *host.Manager - geoDatabase *geo.GeoDatabase - globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - challengeServer *ChallengeServer + dataset *dataset.DataSet + hostManager *host.Manager + geoDatabase *geo.GeoDatabase + globalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) } type SpoaConfig struct { - TcpAddr string - UnixAddr string - Dataset *dataset.DataSet - HostManager *host.Manager - GeoDatabase *geo.GeoDatabase - GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) - ChallengeAddr string // TCP address for the challenge HTTP server (e.g. "0.0.0.0:9001") - Logger *log.Entry // Parent logger to inherit from + TcpAddr string + UnixAddr string + Dataset *dataset.DataSet + HostManager *host.Manager + GeoDatabase *geo.GeoDatabase + GlobalAppSec *appsec.AppSec // Global AppSec config (used when no host matched) + Logger *log.Entry // Parent logger to inherit from } func New(config *SpoaConfig) (*Spoa, error) { @@ -123,10 +121,6 @@ func New(config *SpoaConfig) (*Spoa, error) { globalAppSec: config.GlobalAppSec, } - if config.ChallengeAddr != "" && config.GlobalAppSec != nil && config.GlobalAppSec.IsValid() { - s.challengeServer = newChallengeServer(config.GlobalAppSec, config.ChallengeAddr, workerLogger) - } - if config.TcpAddr != "" { addr, err := net.Listen("tcp", config.TcpAddr) if err != nil { @@ -219,15 +213,6 @@ func (s *Spoa) Serve(ctx context.Context) error { return nil } - // Launch the challenge HTTP server if configured - if s.challengeServer != nil { - go func() { - if err := s.challengeServer.Serve(ctx); err != nil { - serverError <- err - } - }() - } - select { case err := <-serverError: return err @@ -531,8 +516,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri if matchedHost == nil { appSec, timeout, alwaysSend := s.getAppSecConfig(nil) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, nil, appSec, r, timeout) - // Challenge content is served by the challenge HTTP server, not via SPOE vars. + r = s.validateWithAppSec(ctx, writer, msgData, nil, appSec, r, timeout) } return } @@ -555,7 +539,7 @@ func (s *Spoa) handleHTTPRequest(ctx context.Context, writer *encoding.ActionWri // Validate with AppSec if configured appSec, timeout, alwaysSend := s.getAppSecConfig(matchedHost) if appSec != nil && shouldRunAppSec(r, alwaysSend) { - r = s.validateWithAppSec(ctx, msgData, matchedHost, appSec, r, timeout) + r = s.validateWithAppSec(ctx, writer, msgData, matchedHost, appSec, r, timeout) if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) } @@ -586,6 +570,7 @@ func shouldRunAppSec(r remediation.Remediation, alwaysSend bool) bool { // validateWithAppSec performs AppSec validation and returns the remediation. func (s *Spoa) validateWithAppSec( ctx context.Context, + writer *encoding.ActionWriter, msgData *HTTPMessageData, matchedHost *host.Host, appSecToUse *appsec.AppSec, @@ -605,7 +590,7 @@ func (s *Spoa) validateWithAppSec( appSecCtx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - appSecRemediation, _, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) + appSecRemediation, challengeData, err := appSecToUse.ValidateRequest(appSecCtx, appSecReq) if err != nil { logger.WithError(err).Warn("AppSec validation failed, using original remediation") return currentRemediation @@ -627,11 +612,37 @@ func (s *Spoa) validateWithAppSec( if appSecRemediation == remediation.Ban && matchedHost == nil { logger.Warn("AppSec returned ban but no host matched - remediation set but ban values not injected") } + if appSecRemediation == remediation.Challenge && challengeData != nil { + injectChallengeKeyValues(writer, challengeData) + } return appSecRemediation } return currentRemediation } +func injectChallengeKeyValues(writer *encoding.ActionWriter, challengeData *appsec.AppSecChallengeData) { + status := challengeData.StatusCode + if status <= 0 { + status = http.StatusOK + } + + _ = writer.SetInt64(encoding.VarScopeTransaction, "challenge_status", int64(status)) + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_body", challengeData.Body) + + if challengeData.ContentType != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_content_type", challengeData.ContentType) + } + if challengeData.CSP != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_csp", challengeData.CSP) + } + if challengeData.CacheControl != "" { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cache_control", challengeData.CacheControl) + } + if len(challengeData.Cookies) > 0 { + _ = writer.SetString(encoding.VarScopeTransaction, "challenge_cookie", challengeData.Cookies[0]) + } +} + // buildAppSecRequest constructs an AppSecRequest from HTTPMessageData func (d *HTTPMessageData) buildAppSecRequest() *appsec.AppSecRequest { req := &appsec.AppSecRequest{ From fe2e4ed4c7bc1ad7b98e0d66b3e9084875a805ff Mon Sep 17 00:00:00 2001 From: sabban Date: Thu, 9 Jul 2026 17:12:35 +0200 Subject: [PATCH 18/18] multiple fixes --- config/crowdsec-spoa-bouncer.docker.yaml | 6 ++--- config/crowdsec-spoa-bouncer.yaml | 6 ++--- config/crowdsec.cfg | 2 +- config/haproxy-upstreamproxy.cfg | 2 +- config/haproxy.cfg | 2 +- go.mod | 2 -- go.sum | 4 +-- internal/appsec/root.go | 34 +++++++++++++++++------- 8 files changed, 35 insertions(+), 23 deletions(-) diff --git a/config/crowdsec-spoa-bouncer.docker.yaml b/config/crowdsec-spoa-bouncer.docker.yaml index 3abe1efd..46de4280 100644 --- a/config/crowdsec-spoa-bouncer.docker.yaml +++ b/config/crowdsec-spoa-bouncer.docker.yaml @@ -21,9 +21,9 @@ listen_tcp: ${LISTEN_TCP} #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb -## Global AppSec configuration -appsec_url: ${APPSEC_URL} -appsec_timeout: ${APPSEC_TIMEOUT} +## Global AppSec configuration (optional) +#appsec_url: ${APPSEC_URL} +#appsec_timeout: ${APPSEC_TIMEOUT} ## Prometheus metrics endpoint prometheus: diff --git a/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 2af5fe9c..abbd5b49 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -20,10 +20,10 @@ listen_unix: /run/crowdsec-spoa/spoa.sock #asn_database_path: /var/lib/crowdsec/data/GeoLite2-ASN.mmdb #city_database_path: /var/lib/crowdsec/data/GeoLite2-City.mmdb -## Global AppSec configuration +## Global AppSec configuration (optional) ## Used for virtual patching and JavaScript challenge decisions. -appsec_url: http://127.0.0.1:7422/ -appsec_timeout: 200ms +#appsec_url: http://127.0.0.1:7422/ +#appsec_timeout: 200ms prometheus: enabled: false diff --git a/config/crowdsec.cfg b/config/crowdsec.cfg index fb8177ca..38cd2cd2 100644 --- a/config/crowdsec.cfg +++ b/config/crowdsec.cfg @@ -9,7 +9,7 @@ spoe-agent crowdsec-agent option var-prefix crowdsec option set-on-error error - option max-frame-size 262144 + option max-frame-size 65532 timeout hello 200ms timeout idle 55s timeout processing 500ms diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index 5034dd30..29ba16de 100644 --- a/config/haproxy-upstreamproxy.cfg +++ b/config/haproxy-upstreamproxy.cfg @@ -4,7 +4,7 @@ global log stdout format raw local0 - tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads + tune.bufsize 65536 # 64KB - must stay above crowdsec.cfg's SPOE max-frame-size stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s diff --git a/config/haproxy.cfg b/config/haproxy.cfg index 7239a704..e2b226f8 100644 --- a/config/haproxy.cfg +++ b/config/haproxy.cfg @@ -1,7 +1,7 @@ # https://www.haproxy.com/documentation/hapee/latest/onepage/#home global log stdout format raw local0 - tune.bufsize 131072 # 128KB - increased for WAF body inspection and SPOE payloads + tune.bufsize 65536 # 64KB - must stay above crowdsec.cfg's SPOE max-frame-size stats socket /tmp/haproxy.sock mode 660 level admin stats timeout 30s diff --git a/go.mod b/go.mod index 921ed6c7..44a434bf 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,6 @@ require ( gopkg.in/yaml.v2 v2.4.0 ) -replace github.com/dropmorepackets/haproxy-go => github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d - require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 9efc5f75..ee220f64 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dropmorepackets/haproxy-go v0.0.7 h1:atXkB0MSRBZrAgpq+Vj/E4KysQ4CiI0O5QGUr+HvfTw= +github.com/dropmorepackets/haproxy-go v0.0.7/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k= @@ -102,8 +104,6 @@ github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7D github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d h1:OVXewDp/fRc0gVRc0lqV/6t2jDLAREMoS1RcGxcB3ik= -github.com/sabban/haproxy-go v0.0.0-20260626150208-b7fef065491d/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= diff --git a/internal/appsec/root.go b/internal/appsec/root.go index a60c4f2d..30a9fb23 100644 --- a/internal/appsec/root.go +++ b/internal/appsec/root.go @@ -37,6 +37,11 @@ type appsecJSONResponse struct { const DefaultRequestTimeout = 200 * time.Millisecond +// maxAppSecResponseBodySize caps how much of an AppSec response body we'll +// buffer. AppSec is a trusted local service, but this is cheap insurance +// against a misbehaving or compromised instance sending an oversized body. +const maxAppSecResponseBodySize = 1 << 20 // 1 MiB + // AppSecRequest represents the HTTP request data to be validated by AppSec type AppSecRequest struct { Host string @@ -143,11 +148,15 @@ func (a *AppSec) ValidateRequest(ctx context.Context, req *AppSecRequest) (remed } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAppSecResponseBodySize+1)) if err != nil { a.logger.Errorf("Failed to read AppSec response body: %v", err) return remediation.Allow, nil, err } + if len(body) > maxAppSecResponseBodySize { + a.logger.Errorf("AppSec response body exceeds %d bytes, rejecting", maxAppSecResponseBodySize) + return remediation.Allow, nil, fmt.Errorf("AppSec response body too large") + } return a.processAppSecResponse(resp.StatusCode, body) } @@ -227,15 +236,9 @@ func (a *AppSec) processAppSecResponse(statusCode int, body []byte) (remediation Body: parsed.UserBodyContent, Cookies: parsed.UserCookies, } - if vals := parsed.UserHeaders["Content-Type"]; len(vals) > 0 { - cd.ContentType = vals[0] - } - if vals := parsed.UserHeaders["Content-Security-Policy"]; len(vals) > 0 { - cd.CSP = vals[0] - } - if vals := parsed.UserHeaders["Cache-Control"]; len(vals) > 0 { - cd.CacheControl = vals[0] - } + cd.ContentType = firstHeaderValue(parsed.UserHeaders, "Content-Type") + cd.CSP = firstHeaderValue(parsed.UserHeaders, "Content-Security-Policy") + cd.CacheControl = firstHeaderValue(parsed.UserHeaders, "Cache-Control") return remediation.Challenge, cd, nil @@ -253,6 +256,17 @@ func (a *AppSec) processAppSecResponse(statusCode int, body []byte) (remediation } } +// firstHeaderValue returns the first value for key in headers, matching the +// key case-insensitively since AppSec's header casing is not guaranteed. +func firstHeaderValue(headers map[string][]string, key string) string { + for k, vals := range headers { + if strings.EqualFold(k, key) && len(vals) > 0 { + return vals[0] + } + } + return "" +} + func normalizeHTTPVersion(raw string) string { if raw == "" { return "11"