diff --git a/CHALLENGE.md b/CHALLENGE.md new file mode 100644 index 00000000..8c6de990 --- /dev/null +++ b/CHALLENGE.md @@ -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": "...", + "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. 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/config/crowdsec-spoa-bouncer.yaml b/config/crowdsec-spoa-bouncer.yaml index 9e2f2552..abbd5b49 100644 --- a/config/crowdsec-spoa-bouncer.yaml +++ b/config/crowdsec-spoa-bouncer.yaml @@ -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 diff --git a/config/crowdsec.cfg b/config/crowdsec.cfg index 213b0df9..38cd2cd2 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 65532 timeout hello 200ms timeout idle 55s timeout processing 500ms diff --git a/config/haproxy-upstreamproxy.cfg b/config/haproxy-upstreamproxy.cfg index 11b33f7d..29ba16de 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 65536 # 64KB - must stay above crowdsec.cfg's SPOE max-frame-size 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 diff --git a/config/haproxy.cfg b/config/haproxy.cfg index 62f5a3f8..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 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 @@ -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 diff --git a/internal/appsec/root.go b/internal/appsec/root.go index a6a1cffa..30a9fb23 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,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 @@ -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) { @@ -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 { diff --git a/internal/appsec/root_test.go b/internal/appsec/root_test.go new file mode 100644 index 00000000..f8fd5e44 --- /dev/null +++ b/internal/appsec/root_test.go @@ -0,0 +1,306 @@ +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 +} + +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) { + 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 := mustMarshal(t, map[string]any{"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 := 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) + + 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 := 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"}, + }, + "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) + assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"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) + assert.NoError(t, json.NewEncoder(w).Encode(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"}, + }, + "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") + _, _, err := 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.NoError(t, err) + + 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) { + assert.Equal(t, http.MethodPost, r.Method) + var err error + receivedBody, err = io.ReadAll(r.Body) + assert.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + a := newTestAppSec(srv.URL, "key") + payload := []byte("t=token&n=nonce&f=fingerprint") + _, _, 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/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/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/lua/crowdsec.lua b/lua/crowdsec.lua index d457f8f8..9855b8dd 100644 --- a/lua/crowdsec.lua +++ b/lua/crowdsec.lua @@ -113,13 +113,46 @@ 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" 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 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) reply:set_body(runtime.captcha.render({ @@ -137,7 +170,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..bd26fbaa 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, writer, msgData, nil, appSec, r, timeout) } return } @@ -539,8 +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) - // If AppSec returns ban, inject ban values + r = s.validateWithAppSec(ctx, writer, msgData, matchedHost, appSec, r, timeout) if r == remediation.Ban { matchedHost.Ban.InjectKeyValues(writer) } @@ -568,10 +567,10 @@ 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. func (s *Spoa) validateWithAppSec( ctx context.Context, + writer *encoding.ActionWriter, msgData *HTTPMessageData, matchedHost *host.Host, appSecToUse *appsec.AppSec, @@ -580,7 +579,6 @@ func (s *Spoa) validateWithAppSec( ) remediation.Remediation { appSecReq := msgData.buildAppSecRequest() - // Create logger with host context logger := s.logger if appSecReq.Host != "" { logger = logger.WithField("host", appSecReq.Host) @@ -589,11 +587,10 @@ 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 @@ -601,7 +598,6 @@ func (s *Spoa) validateWithAppSec( 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,16 +608,41 @@ 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") } + 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{