Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ecaa94b
waf: wip challenge mode response
blotus Feb 1, 2026
f0ba5aa
wip
blotus Feb 2, 2026
8f0afd8
wip
blotus Feb 2, 2026
6496750
wip
blotus Feb 5, 2026
e123758
wip
blotus Feb 17, 2026
f0ab2b8
wip
blotus Feb 19, 2026
efe226d
update UI
blotus Feb 19, 2026
a0b6389
start WASM VM only if needed
blotus Feb 19, 2026
0f3ca44
wip
blotus Mar 3, 2026
87ce002
wip
blotus Mar 23, 2026
27575d6
wip
blotus Mar 23, 2026
5d7783a
wip
blotus Mar 24, 2026
3cf155f
wip
blotus Mar 25, 2026
c2387fe
fancy boiiii
buixor Mar 26, 2026
243fa67
increase sleep
buixor Mar 26, 2026
c49bc38
up
buixor Mar 26, 2026
d983cb7
Merge branch 'master' into waf-challenge-mode
blotus Mar 30, 2026
5bdbdca
fix tests
blotus Mar 30, 2026
f2e127f
wip
blotus Apr 11, 2026
b782b61
only process coraza logging phase if request is not dropped by pre_eval
blotus Apr 16, 2026
eca58e4
merge
blotus Apr 16, 2026
0e18165
pow: add impossible difficulty
blotus Apr 16, 2026
9e7203e
on_challenge hook
blotus Apr 16, 2026
6045068
store PoW difficulty in cookie
blotus Apr 16, 2026
bb3f865
add Bool to FlexBool and Int to FlexInt
blotus Apr 17, 2026
53569b3
expr lol
blotus Apr 17, 2026
8980392
update fpscanner + go/pb structs
blotus Apr 20, 2026
b6d92fb
add metrics for mismatchs
buixor Apr 21, 2026
d010d36
mod
buixor Apr 21, 2026
f4739f1
add EvaluateMismatches: produces a 'report' of mismatches and caches it
buixor Apr 21, 2026
ec621b8
add EvaluateMismatches helper
buixor Apr 21, 2026
b7b7a64
add a simpler iptocountry helper
buixor Apr 21, 2026
9259720
add a simpler iptocountry helper
buixor Apr 21, 2026
3738d84
early metrics for mismatches
buixor Apr 21, 2026
c90a7b7
helpers
buixor Apr 21, 2026
f40a21d
tighten how we link detection to labels and severities
buixor Apr 21, 2026
3ae7248
custom mismatch rules
buixor Apr 21, 2026
689ff28
add code / country match
buixor Apr 21, 2026
92d20cb
tests
buixor Apr 21, 2026
ea157a5
use a default CSP header for challenge page
blotus May 4, 2026
247f5c2
add support for allowlists + fix CSP (#4452)
buixor May 5, 2026
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
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ require (
github.com/crowdsecurity/grokky v0.2.2
github.com/crowdsecurity/machineid v1.0.3
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/evanw/esbuild v0.27.2
github.com/expr-lang/expr v1.17.8
github.com/fatih/color v1.19.0
github.com/fsnotify/fsnotify v1.9.0
Expand Down Expand Up @@ -79,6 +80,7 @@ require (
github.com/slack-go/slack v0.21.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.11.0
github.com/umahmood/haversine v0.0.0-20151105152445-808ab04add26
github.com/wasilibs/go-re2 v1.10.0
github.com/xhit/go-simple-mail/v2 v2.16.0
Expand Down Expand Up @@ -190,6 +192,7 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/magefile/mage v1.17.1 // indirect
Expand Down Expand Up @@ -219,7 +222,6 @@ require (
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/evanw/esbuild v0.27.2 h1:3xBEws9y/JosfewXMM2qIyHAi+xRo8hVx475hVkJfNg=
github.com/evanw/esbuild v0.27.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
Expand Down Expand Up @@ -712,6 +714,7 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
190 changes: 190 additions & 0 deletions pkg/acquisition/modules/appsec/appsec_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/crowdsecurity/crowdsec/pkg/appsec"
"github.com/crowdsecurity/crowdsec/pkg/appsec/appsec_rule"
"github.com/crowdsecurity/crowdsec/pkg/appsec/challenge"
"github.com/crowdsecurity/crowdsec/pkg/pipeline"
)

Expand Down Expand Up @@ -1339,3 +1340,192 @@ func TestAppsecPhaseScopedHooks(t *testing.T) {

runTests(t, tests)
}

func TestAppsecOnChallengeHooks(t *testing.T) {
powWorkerURL, err := url.Parse(challenge.ChallengePowWorkerPath)
require.NoError(t, err)

tests := []appsecRuleTest{
{
name: "pre_eval issues a challenge when no cookie is present",
expected_load_ok: true,
pre_eval: []appsec.Hook{
{Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.Equal(t, appsec.ChallengeRemediation, responses[0].Action)
require.NotEmpty(t, responses[0].UserHTTPBodyContent)
require.Contains(t, responses[0].UserHeaders["Content-Type"], "text/html")
},
},
{
name: "on_challenge: no cookie → user hooks skipped (filter would nil-deref)",
expected_load_ok: true,
// This filter would nil-deref if it ran without a fingerprint. The
// dispatcher must skip it when no cookie is present.
on_challenge: []appsec.Hook{
{Filter: "fingerprint.Bot.MismatchWebGLInWorker", Apply: []string{"DropRequest('bot')"}},
},
// Must reference SendChallenge() somewhere to force ChallengeRuntime init.
pre_eval: []appsec.Hook{
{Filter: "false", Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.False(t, responses[0].InBandInterrupt, "on_challenge must not fire without a fingerprint")
},
},
{
name: "on_challenge: PoW worker path served, WAF evaluation skipped",
expected_load_ok: true,
inband_rules: []appsec_rule.CustomRule{
{
Name: "rule1",
Zones: []string{"ARGS"},
Variables: []string{"foo"},
Match: appsec_rule.Match{Type: "regex", Value: "^toto"},
Transform: []string{"lowercase"},
},
},
// Force the challenge runtime to be initialized by referencing SendChallenge()
// in a hook that won't match this request.
on_challenge: []appsec.Hook{
{Filter: "false", Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "GET",
URI: challenge.ChallengePowWorkerPath,
Args: url.Values{"foo": []string{"toto"}}, // would normally trigger rule1
HTTPRequest: &http.Request{Host: "example.com", URL: powWorkerURL},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Empty(t, events, "WAF should not have evaluated the infrastructure path")
require.Len(t, responses, 1)
require.Equal(t, appsec.ChallengeRemediation, responses[0].Action)
require.Equal(t, challenge.PowWorkerJS, responses[0].UserHTTPBodyContent)
require.Contains(t, responses[0].UserHeaders["Content-Type"], "application/javascript")
},
},
{
name: "on_challenge: invalid submission returns failed body, no hooks run",
expected_load_ok: true,
// Unconditional DropRequest in on_challenge must NOT fire on an
// invalid submission — the dispatcher returns the failed JSON
// body and skips user hooks.
on_challenge: []appsec.Hook{
{Apply: []string{"DropRequest('should not fire')"}},
},
// Force ChallengeRuntime init.
pre_eval: []appsec.Hook{
{Filter: "false", Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
RemoteAddr: "1.2.3.4",
Method: "POST",
URI: challenge.ChallengeSubmitPath,
HTTPRequest: func() *http.Request {
u, _ := url.Parse(challenge.ChallengeSubmitPath)
return &http.Request{Host: "example.com", URL: u, Method: "POST"}
}(),
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.Equal(t, appsec.ChallengeRemediation, responses[0].Action)
require.JSONEq(t, `{"status":"failed"}`, responses[0].UserHTTPBodyContent)
require.False(t, responses[0].InBandInterrupt, "on_challenge hooks must not run on invalid submission")
},
},
{
// Mock LAPI (see testAppSecEngine in appsec_test.go) exposes 5.4.3.2
// as an allowlisted IP. SendChallenge() must be a no-op for it: no
// challenge HTML, no Set-Cookie, response stays at the default pass
// action so the request goes through cleanly.
name: "allowlisted IP: pre_eval SendChallenge() is suppressed",
expected_load_ok: true,
pre_eval: []appsec.Hook{
{Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
ClientIP: "5.4.3.2",
RemoteAddr: "5.4.3.2",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.NotEqual(t, appsec.ChallengeRemediation, responses[0].Action,
"allowlisted IP must not be challenged")
require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
require.Empty(t, responses[0].UserHTTPBodyContent, "no challenge HTML must be served")
require.Empty(t, responses[0].UserHTTPCookies, "no challenge cookie must be issued")
require.False(t, responses[0].InBandInterrupt)
},
},
{
// Same allowlisted IP, but inside a /24 CIDR entry (5.4.4.0/24) —
// confirms range matches are honoured the same way as exact IPs.
name: "allowlisted CIDR: pre_eval SendChallenge() is suppressed",
expected_load_ok: true,
pre_eval: []appsec.Hook{
{Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
ClientIP: "5.4.4.42",
RemoteAddr: "5.4.4.42",
Method: "GET",
URI: "/protected",
HTTPRequest: &http.Request{Host: "example.com"},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.NotEqual(t, appsec.ChallengeRemediation, responses[0].Action)
require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
require.Empty(t, responses[0].UserHTTPBodyContent)
require.Empty(t, responses[0].UserHTTPCookies)
},
},
{
// Allowlisted IPs hitting infrastructure paths must not get the PoW
// worker JS served either — ProcessOnChallengeRules short-circuits
// before the path-based branch, so the request flows through normal
// WAF processing.
name: "allowlisted IP: PoW worker path is not served",
expected_load_ok: true,
// Reference SendChallenge() so ChallengeRuntime initialises.
on_challenge: []appsec.Hook{
{Filter: "false", Apply: []string{"SendChallenge()"}},
},
input_request: appsec.ParsedRequest{
ClientIP: "5.4.3.2",
RemoteAddr: "5.4.3.2",
Method: "GET",
URI: challenge.ChallengePowWorkerPath,
HTTPRequest: &http.Request{Host: "example.com", URL: powWorkerURL},
},
output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) {
require.Len(t, responses, 1)
require.NotEqual(t, appsec.ChallengeRemediation, responses[0].Action,
"allowlisted IP must not receive the PoW worker JS")
require.NotEqual(t, challenge.PowWorkerJS, responses[0].UserHTTPBodyContent)
require.Equal(t, appsec.AllowRemediation, appsecResponse.Action)
},
},
}

runTests(t, tests)
}
65 changes: 49 additions & 16 deletions pkg/acquisition/modules/appsec/appsec_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,7 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request
}

defer func() {
state.Tx.ProcessLogging()
//We don't close the transaction here, as it will reset coraza internal state and break variable tracking

err := r.AppsecRuntime.ProcessPostEvalRules(state, request)
if err != nil {
r.logger.Errorf("unable to process PostEval rules: %s", err)
Expand All @@ -156,11 +154,23 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request
//FIXME: should we abort here ?
}

// User has requested valid challenge, but we did not find a valid cookie
// Immediately return, everything has been set already
if state.RequireChallenge {
r.logger.Infof("valid challenge required, skipping WAF evaluation")
return nil
}

if state.DropInfo(request) != nil {
r.logger.Debug("drop helper triggered during pre_eval, skipping WAF evaluation")
return nil
}

defer func() {
state.Tx.ProcessLogging()
//We don't close the transaction here, as it will reset coraza internal state and break variable tracking
}()

state.Tx.ProcessConnection(request.ClientIP, 0, "", 0)

for k, v := range request.Args {
Expand Down Expand Up @@ -220,14 +230,35 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request
func (r *AppsecRunner) ProcessInBandRules(state *appsec.AppsecRequestState, request *appsec.ParsedRequest) error {
tx := appsec.NewExtendedTransaction(r.AppsecInbandEngine, request.UUID)
state.Tx = tx
// Even if we have no inband rules, we might have pre-eval or post-eval rules to process
// Even if we have no inband rules, we might have pre-eval, post-eval or on_challenge hooks to process
if len(r.AppsecRuntime.InBandRules) == 0 &&
len(r.AppsecRuntime.CommonHooks.PreEval) == 0 &&
len(r.AppsecRuntime.InBandHooks.PreEval) == 0 &&
len(r.AppsecRuntime.CommonHooks.PostEval) == 0 &&
len(r.AppsecRuntime.InBandHooks.PostEval) == 0 {
len(r.AppsecRuntime.InBandHooks.PostEval) == 0 &&
len(r.AppsecRuntime.CompiledOnChallenge) == 0 &&
r.AppsecRuntime.ChallengeRuntime == nil {
return nil
}

// on_challenge runs before any WAF work: it serves PoW infrastructure paths,
// validates submissions, and populates state.Fingerprint from the cookie.
if err := r.AppsecRuntime.ProcessOnChallengeRules(state, request); err != nil {
r.logger.Errorf("unable to process OnChallenge rules: %s", err)
}

// Infrastructure paths (PoW worker, challenge submit) already set up the
// full response — skip pre_eval, WAF evaluation and post_eval entirely.
if state.RequireChallenge {
r.logger.Debugf("challenge response set by on_challenge, skipping WAF evaluation")
return nil
}

if state.DropInfo(request) != nil {
r.logger.Debug("drop helper triggered during on_challenge, skipping WAF evaluation")
return nil
}

err := r.processRequest(state, request)
return err
}
Expand All @@ -247,12 +278,6 @@ func (r *AppsecRunner) ProcessOutOfBandRules(state *appsec.AppsecRequestState, r
}

func (r *AppsecRunner) handleInBandInterrupt(state *appsec.AppsecRequestState, request *appsec.ParsedRequest) {

if allowed, reason := r.appsecAllowlistsClient.IsAllowlisted(request.ClientIP); allowed {
r.logger.Infof("%s is allowlisted by %s, skipping", request.ClientIP, reason)
return
}

//create the associated event for crowdsec itself
evt, err := EventFromRequest(request, r.Labels, state.Tx.ID())
if err != nil {
Expand Down Expand Up @@ -319,12 +344,6 @@ func (r *AppsecRunner) handleInBandInterrupt(state *appsec.AppsecRequestState, r
}

func (r *AppsecRunner) handleOutBandInterrupt(state *appsec.AppsecRequestState, request *appsec.ParsedRequest) {

if allowed, reason := r.appsecAllowlistsClient.IsAllowlisted(request.ClientIP); allowed {
r.logger.Infof("%s is allowlisted by %s, skipping", request.ClientIP, reason)
return
}

evt, err := EventFromRequest(request, r.Labels, state.Tx.ID())
if err != nil {
//let's not interrupt the pipeline for this
Expand Down Expand Up @@ -391,6 +410,17 @@ func (r *AppsecRunner) handleRequest(request *appsec.ParsedRequest) {
logger.Debug("Request received in runner")
r.AppsecRuntime.ClearResponse(&state)

// Allowlisted IPs bypass every form of appsec processing (in-band rules,
// hooks, challenge issuance, on_challenge cookie validation, out-of-band
// rules). We send the default pass response straight back to the bouncer.
if r.appsecAllowlistsClient != nil {
if allowed, reason := r.appsecAllowlistsClient.IsAllowlisted(request.ClientIP); allowed {
logger.Infof("%s is allowlisted by %s, skipping WAF processing", request.ClientIP, reason)
request.ResponseChannel <- state.Response
return
}
}

request.IsInBand = true
request.IsOutBand = false

Expand Down Expand Up @@ -427,6 +457,9 @@ func (r *AppsecRunner) handleRequest(request *appsec.ParsedRequest) {
// send back the result to the HTTP handler for the InBand part
request.ResponseChannel <- state.Response

// TODO: what should we do with challenge remediation for OOB matches ?
// (captcha has no special treatment, but is also useless for OOB)

//Now let's process the out of band rules

request.IsInBand = false
Expand Down
Loading
Loading