Skip to content
Draft
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
3 changes: 2 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 @@ -219,7 +221,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
114 changes: 114 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,116 @@ 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")
},
},
}

runTests(t, tests)
}
42 changes: 38 additions & 4 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 Down Expand Up @@ -427,6 +458,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
21 changes: 17 additions & 4 deletions pkg/acquisition/modules/appsec/appsec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ type appsecRuleTest struct {
pre_eval []appsec.Hook
post_eval []appsec.Hook
on_match []appsec.Hook
on_challenge []appsec.Hook
// Phase-scoped hooks (dispatched only during the matching phase)
inband_on_match []appsec.Hook
inband_pre_eval []appsec.Hook
inband_post_eval []appsec.Hook
inband_on_challenge []appsec.Hook
outofband_on_match []appsec.Hook
outofband_pre_eval []appsec.Hook
outofband_post_eval []appsec.Hook
Expand Down Expand Up @@ -115,6 +117,7 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) {
PreEval: test.pre_eval,
PostEval: test.post_eval,
OnMatch: test.on_match,
OnChallenge: test.on_challenge,
BouncerBlockedHTTPCode: test.BouncerBlockedHTTPCode,
UserBlockedHTTPCode: test.UserBlockedHTTPCode,
UserPassedHTTPCode: test.UserPassedHTTPCode,
Expand All @@ -123,11 +126,12 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) {
}

// Set phase-scoped hooks if any are provided
if len(test.inband_on_match) > 0 || len(test.inband_pre_eval) > 0 || len(test.inband_post_eval) > 0 {
if len(test.inband_on_match) > 0 || len(test.inband_pre_eval) > 0 || len(test.inband_post_eval) > 0 || len(test.inband_on_challenge) > 0 {
appsecCfg.InBand = &appsec.AppsecPhaseConfig{
OnMatch: test.inband_on_match,
PreEval: test.inband_pre_eval,
PostEval: test.inband_post_eval,
OnMatch: test.inband_on_match,
PreEval: test.inband_pre_eval,
PostEval: test.inband_post_eval,
OnChallenge: test.inband_on_challenge,
}
}

Expand All @@ -146,6 +150,15 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) {
}
AppsecRuntime.InBandRules = []appsec.AppsecCollection{{Rules: inbandRules, NativeRules: nativeInbandRules}}
AppsecRuntime.OutOfBandRules = []appsec.AppsecCollection{{Rules: outofbandRules, NativeRules: nativeOutofbandRules}}

// Hooks using SendChallenge() or on_challenge hooks require the WASM
// challenge runtime; mirror what pkg/acquisition/modules/appsec/config.go
// does in production. We share a single runtime across tests — building
// one is expensive (~15-20s for WASM obfuscator warm-up), and none of the
// tests mutate runtime-level state like default difficulty.
if AppsecRuntime.NeedWASMVM {
AppsecRuntime.ChallengeRuntime = getSharedChallengeRuntime(t)
}
appsecRunnerUUID := uuid.New().String()
//we copy AppsecRutime for each runner
wrt := *AppsecRuntime
Expand Down
17 changes: 12 additions & 5 deletions pkg/acquisition/modules/appsec/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/crowdsecurity/crowdsec/pkg/apiclient/useragent"
"github.com/crowdsecurity/crowdsec/pkg/appsec"
"github.com/crowdsecurity/crowdsec/pkg/appsec/allowlists"
"github.com/crowdsecurity/crowdsec/pkg/appsec/challenge"
"github.com/crowdsecurity/crowdsec/pkg/metrics"
)

Expand Down Expand Up @@ -115,7 +116,7 @@ func loadCertPool(caCertPath string, logger log.FieldLogger) (*x509.CertPool, er
return caCertPool, nil
}

func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Entry, _ metrics.AcquisitionMetricsLevel) error {
func (w *Source) Configure(ctx context.Context, yamlConfig []byte, logger *log.Entry, _ metrics.AcquisitionMetricsLevel) error {
if w.hub == nil {
return errors.New("appsec datasource requires a hub. this is a bug, please report")
}
Expand Down Expand Up @@ -188,6 +189,15 @@ func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Ent
return fmt.Errorf("unable to build appsec_config: %w", err)
}

if appsecRuntime.NeedWASMVM {
logger.Info("Initializing WASM runtime for challenge obfuscation")
challengeRuntime, err := challenge.NewChallengeRuntime(ctx)
if err != nil {
return fmt.Errorf("unable to create challenge runtime: %w", err)
}
appsecRuntime.ChallengeRuntime = challengeRuntime
}

w.AppsecRuntime = appsecRuntime

err = w.AppsecRuntime.ProcessOnLoadRules()
Expand All @@ -201,14 +211,11 @@ func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Ent

for nbRoutine := range w.config.Routines {
appsecRunnerUUID := uuid.New().String()
// we copy AppsecRuntime for each runner
wrt := *w.AppsecRuntime
wrt.Logger = w.logger.Dup().WithField("runner_uuid", appsecRunnerUUID)
runner := AppsecRunner{
inChan: w.InChan,
UUID: appsecRunnerUUID,
logger: w.logger.WithField("runner_uuid", appsecRunnerUUID),
AppsecRuntime: &wrt,
AppsecRuntime: w.AppsecRuntime,
Labels: w.config.Labels,
appsecAllowlistsClient: w.appsecAllowlistClient,
}
Expand Down
31 changes: 31 additions & 0 deletions pkg/acquisition/modules/appsec/testhelpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package appsecacquisition

import (
"context"
"sync"
"testing"

"github.com/stretchr/testify/require"

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

// sharedChallengeRuntime lazily creates a single ChallengeRuntime for the
// whole package test run. NewChallengeRuntime runs the obfuscator WASM to
// generate a challenge JS bundle (~15-20s), so spinning one up per test is
// prohibitively slow and unnecessary for integration tests that don't mutate
// runtime-level state.
var (
sharedChallengeRuntimeOnce sync.Once
sharedChallengeRuntimeInst *challenge.ChallengeRuntime
sharedChallengeRuntimeErr error
)

func getSharedChallengeRuntime(t *testing.T) *challenge.ChallengeRuntime {
t.Helper()
sharedChallengeRuntimeOnce.Do(func() {
sharedChallengeRuntimeInst, sharedChallengeRuntimeErr = challenge.NewChallengeRuntime(context.Background())
})
require.NoError(t, sharedChallengeRuntimeErr)
return sharedChallengeRuntimeInst
}
Loading
Loading