Skip to content

Waf challenge mode#162

Open
sabban wants to merge 8 commits into
mainfrom
waf-challenge-mode
Open

Waf challenge mode#162
sabban wants to merge 8 commits into
mainfrom
waf-challenge-mode

Conversation

@sabban

@sabban sabban commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Waf challenge mode

sabban and others added 8 commits June 2, 2026 17:14
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 blotus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread config/crowdsec.cfg

option var-prefix crowdsec
option set-on-error error
option max-frame-size 262144

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same


## Challenge HTTP listener
## HAProxy routes remediation=challenge requests to this listener.
challenge_listen: ${CHALLENGE_LISTEN}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread internal/appsec/root.go
Body: parsed.UserBodyContent,
Cookies: parsed.UserCookies,
}
if vals := parsed.UserHeaders["Content-Type"]; len(vals) > 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why single out specific headers ? The appsec can return any number of custom headers, just set everything that was returned by crowdsec

Comment thread lua/crowdsec.lua
end

-- Always disable cache for ban/captcha pages
reply:add_header("cache-control", "no-cache")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Those 2 calls are already done just before

Body: body,
}

appSecCtx, cancel := context.WithTimeout(r.Context(), 5*time.Second)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why hardcode to 5s ?
Timeout from the config should be reused here.

return
}

if rem != remediation.Challenge || challengeData == nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, we shouldn't single out specific headers, everything that was returned by crowdsec should be set.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants