Waf challenge mode#162
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…rver)
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 <noreply@anthropic.com>
blotus
left a comment
There was a problem hiding this comment.
The approach with the 2nd HTTP server seems really fragile. This will cause multiple requests to the challenge page for the same initial request, which might trigger false positives because we have scenarios that track how many times a challenge page is requested.
If we absolutely have to keep this 2nd server because of the SPOA frame limitation, then we should find a way to make sure we only query the appsec only once.
|
|
||
| option var-prefix crowdsec | ||
| option set-on-error error | ||
| option max-frame-size 262144 |
There was a problem hiding this comment.
This option does not seem to exist according to haproxy doc ?
| #appsec_url: ${APPSEC_URL} | ||
| #appsec_timeout: ${APPSEC_TIMEOUT} | ||
| ## Global AppSec configuration | ||
| appsec_url: ${APPSEC_URL} |
There was a problem hiding this comment.
Don't set an optional config here. Unset env vars are not replaced, so this will prevent the bouncer from starting.
| #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} |
| Body: parsed.UserBodyContent, | ||
| Cookies: parsed.UserCookies, | ||
| } | ||
| if vals := parsed.UserHeaders["Content-Type"]; len(vals) > 0 { |
There was a problem hiding this comment.
Why single out specific headers ? The appsec can return any number of custom headers, just set everything that was returned by crowdsec
| end | ||
|
|
||
| -- Always disable cache for ban/captcha pages | ||
| reply:add_header("cache-control", "no-cache") |
There was a problem hiding this comment.
Those 2 calls are already done just before
| Body: body, | ||
| } | ||
|
|
||
| appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second) |
There was a problem hiding this comment.
Why hardcode to 5s ?
Timeout from the config should be reused here.
| return | ||
| } | ||
|
|
||
| if rem != remediation.Challenge || challengeData == nil { |
There was a problem hiding this comment.
Because the request is actually forwarded twice to crowdsec, there's a chance the 2nd call returns a different remediation (eg, ban). In this case, we would just return a 200 to haproxy, which would just let the request passthrough (it would be blocked again afterwards, but we would still process it multiple times)
| } | ||
|
|
||
| // Write response headers from AppSec | ||
| if challengeData.ContentType != "" { |
There was a problem hiding this comment.
Again, we shouldn't single out specific headers, everything that was returned by crowdsec should be set.
Waf challenge mode