Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions CHALLENGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# AppSec Challenge Workflow

This document explains how an AppSec browser challenge travels through HAProxy
and the SPOA bouncer.

The challenge is served on the original requested URL, including `/`. A public
`/challenge` endpoint is not required, and the bouncer does not expose a
separate challenge HTTP listener.

## Components

| 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. |

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.

## Remediation Ordering

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. AppSec may therefore upgrade an allowed request to `challenge`, but a
dataset ban still wins over an AppSec challenge.

## Request Flow

```mermaid
sequenceDiagram
participant Browser
participant HAProxy
participant SPOA as SPOA bouncer
participant AppSec as CrowdSec AppSec
participant Lua as HAProxy Lua

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: Same original URL/path and request data
SPOA->>AppSec: Validate request

alt AppSec still returns challenge
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 request
end
```

## HAProxy Wiring

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 }
```

When AppSec returns `challenge`, the bouncer sets transaction variables and
HAProxy calls the Lua response handler:

```haproxy
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" }
```

The Lua handler uses these transaction variables for challenge responses:

| 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 body similar to:

```json
{
"action": "challenge",
"http_status": 200,
"user_body_content": "<html>...</html>",
"user_headers": {
"Content-Type": ["text/html"],
"Content-Security-Policy": ["default-src 'self'"],
"Cache-Control": ["no-store"]
},
"user_cookies": [
"__crowdsec_challenge=...; HttpOnly; Path=/; SameSite=Lax"
]
}
```

Rules:

- `action` must be `challenge`; any other `403` action is treated as `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 in the bouncer configuration:

```yaml
appsec_url: http://127.0.0.1:7422/
appsec_timeout: 200ms
```

No `challenge_listen` setting is required.

## Notes

- 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.
4 changes: 2 additions & 2 deletions Vagrantfile
Original file line number Diff line number Diff line change
Expand Up @@ -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/' \
Expand Down
5 changes: 5 additions & 0 deletions config/crowdsec-spoa-bouncer.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ 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 (optional)
## Used for virtual patching and JavaScript challenge decisions.
#appsec_url: http://127.0.0.1:7422/
#appsec_timeout: 200ms

prometheus:
enabled: false
listen_addr: 127.0.0.1
Expand Down
1 change: 1 addition & 0 deletions config/crowdsec.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ spoe-agent crowdsec-agent

option var-prefix crowdsec
option set-on-error error
option max-frame-size 65532
timeout hello 200ms
timeout idle 55s
timeout processing 500ms
Expand Down
5 changes: 4 additions & 1 deletion config/haproxy-upstreamproxy.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

global
log stdout format raw local0
tune.bufsize 65536 # 64KB - increased for WAF body inspection
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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion config/haproxy.cfg
Original file line number Diff line number Diff line change
@@ -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 65536 # 64KB - must stay above crowdsec.cfg's SPOE max-frame-size
stats socket /tmp/haproxy.sock mode 660 level admin
stats timeout 30s

Expand Down Expand Up @@ -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
Expand Down
111 changes: 85 additions & 26 deletions internal/appsec/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package appsec
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"maps"
Expand All @@ -14,8 +15,33 @@ 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

// 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
Expand Down Expand Up @@ -100,36 +126,39 @@ 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(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")
}

// 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) {
Expand Down Expand Up @@ -182,30 +211,60 @@ 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,
}
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

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)
}
}

// 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 {
Expand Down
Loading
Loading