From e20524dcbf6f99966c4b9f7cf2ddcf588b5a975b Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Thu, 5 Mar 2026 14:07:40 +0100 Subject: [PATCH 1/6] WAF: enforce body size limitation --- .../modules/appsec/appsec_bodysize_test.go | 171 ++++++++++++++++++ .../modules/appsec/appsec_runner.go | 12 ++ pkg/acquisition/modules/appsec/config.go | 2 +- pkg/appsec/appsec.go | 59 +++++- pkg/appsec/request.go | 62 ++++++- pkg/appsec/request_test.go | 99 +++++++++- pkg/appsec/waf_helpers.go | 20 +- 7 files changed, 402 insertions(+), 23 deletions(-) create mode 100644 pkg/acquisition/modules/appsec/appsec_bodysize_test.go diff --git a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go new file mode 100644 index 00000000000..8b6cf0e222b --- /dev/null +++ b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go @@ -0,0 +1,171 @@ +package appsecacquisition + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/crowdsecurity/crowdsec/pkg/appsec" + "github.com/crowdsecurity/crowdsec/pkg/appsec/appsec_rule" + "github.com/crowdsecurity/crowdsec/pkg/pipeline" +) + +func TestAppsecBodySize(t *testing.T) { + tests := []appsecRuleTest{ + { + // Same pattern as pre_eval DropRequest: 3 events (APPSEC + LOG inband + LOG outband) + // because BodySizeExceeded triggers DropRequest in both inband and outband processRequest. + name: "body size exceeded – default ban", + expected_load_ok: true, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + HTTPRequest: &http.Request{Host: "example.com"}, + BodySizeExceeded: true, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + require.Equal(t, appsec.BanRemediation, responses[0].Action) + require.Equal(t, 403, responses[0].BouncerHTTPResponseCode) + require.Len(t, events, 3) + require.Equal(t, pipeline.APPSEC, events[0].Type) + require.Equal(t, pipeline.LOG, events[1].Type) + require.Equal(t, pipeline.LOG, events[2].Type) + require.True(t, events[1].Appsec.HasInBandMatches) + require.True(t, events[2].Appsec.HasOutBandMatches) + require.Equal(t, "request body exceeded maximum allowed size", events[1].Parsed["appsec_drop_reason"]) + }, + }, + { + name: "body size exceeded – on_match changes status code", + expected_load_ok: true, + on_match: []appsec.Hook{ + {Filter: "IsInBand == true", Apply: []string{"SetReturnCode(413)"}}, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + HTTPRequest: &http.Request{Host: "example.com"}, + BodySizeExceeded: true, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + require.Equal(t, 413, responses[0].UserHTTPResponseCode) + require.Equal(t, 403, responses[0].BouncerHTTPResponseCode) + }, + }, + { + name: "body size exceeded – on_match cancels inband alert and event", + expected_load_ok: true, + on_match: []appsec.Hook{ + {Filter: "IsInBand == true", Apply: []string{"CancelAlert()", "CancelEvent()"}}, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + HTTPRequest: &http.Request{Host: "example.com"}, + BodySizeExceeded: true, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + // Inband alert+event cancelled; outband LOG event still fires + require.Len(t, events, 1) + require.Equal(t, pipeline.LOG, events[0].Type) + require.True(t, events[0].Appsec.HasOutBandMatches) + }, + }, + { + // Body was truncated to the limit; the matched content is in the kept portion. + name: "body truncated (partial) – rule matches on kept content", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "MALICIOUS"}, + }, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: []byte("payload=MALICIOUS"), + BodyTruncated: true, + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + require.Equal(t, appsec.BanRemediation, responses[0].Action) + }, + }, + { + // Body was truncated; the rule matches content only present in the discarded tail. + name: "body truncated (partial) – rule misses content beyond truncation point", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "DANGER"}, + }, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: []byte("payload=safe"), + BodyTruncated: true, + }, + 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) + }, + }, + { + // Body is nil (allow action): body rules do not fire. + name: "body nil (allow action) – body rule does not fire", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "TRIGGER"}, + }, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: nil, + }, + 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) + }, + }, + } + + runTests(t, tests) +} diff --git a/pkg/acquisition/modules/appsec/appsec_runner.go b/pkg/acquisition/modules/appsec/appsec_runner.go index 8ce99a3e753..ab9af44bb1b 100644 --- a/pkg/acquisition/modules/appsec/appsec_runner.go +++ b/pkg/acquisition/modules/appsec/appsec_runner.go @@ -161,6 +161,14 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } + if request.BodySizeExceeded { + r.logger.Warnf("request body exceeded maximum allowed size, dropping request") + if err = r.AppsecRuntime.DropRequest(state, request, "request body exceeded maximum allowed size"); err != nil { + r.logger.Errorf("unable to drop request: %s", err) + } + return nil + } + state.Tx.ProcessConnection(request.ClientIP, 0, "", 0) for k, v := range request.Args { @@ -193,6 +201,10 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } + if request.BodyTruncated { + r.logger.Warnf("request body was truncated to %d bytes (partial mode)", len(request.Body)) + } + if len(request.Body) > 0 { in, _, err = state.Tx.WriteRequestBody(request.Body) if err != nil { diff --git a/pkg/acquisition/modules/appsec/config.go b/pkg/acquisition/modules/appsec/config.go index 4b9554faa64..8f43d165479 100644 --- a/pkg/acquisition/modules/appsec/config.go +++ b/pkg/acquisition/modules/appsec/config.go @@ -335,7 +335,7 @@ func (w *Source) appsecHandler(rw http.ResponseWriter, r *http.Request) { } // parse the request only once - parsedRequest, err := appsec.NewParsedRequestFromRequest(r, w.logger) + parsedRequest, err := appsec.NewParsedRequestFromRequest(r, w.logger, w.AppsecRuntime.BodySettings) if err != nil { w.logger.Errorf("%s", err) rw.WriteHeader(http.StatusInternalServerError) diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index acb2d9ed65c..e99932a2fd6 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -41,6 +41,18 @@ const ( AllowRemediation = "allow" ) +const ( + // BodySizeActionDrop drops the request when the body exceeds the maximum size. + BodySizeActionDrop = "drop" + // BodySizeActionPartial reads the body up to the maximum size and processes it. + BodySizeActionPartial = "partial" + // BodySizeActionAllow processes the request without inspecting the body. + BodySizeActionAllow = "allow" + + // DefaultMaxBodySize is the default maximum body size (10MB). + DefaultMaxBodySize = int64(10 * 1024 * 1024) +) + type phase int const ( @@ -155,6 +167,15 @@ type AppsecSubEngineOpts struct { RequestBodyInMemoryLimit *int `yaml:"request_body_in_memory_limit"` } +// BodySettings controls how oversized request bodies are handled. +type BodySettings struct { + // MaxSize is the maximum allowed body size in bytes. Defaults to DefaultMaxBodySize (10MB). + MaxSize int64 `yaml:"max_body_size"` + // Action controls what happens when a body exceeds MaxSize: + // "drop" (default) - block the request, "partial" - inspect up to MaxSize bytes, "allow" - skip body inspection. + Action string `yaml:"body_size_exceeded_action"` +} + // runtime version of AppsecConfig type AppsecRuntimeConfig struct { Name string @@ -181,6 +202,9 @@ type AppsecRuntimeConfig struct { DisabledOutOfBandRuleIds []int DisabledOutOfBandRulesTags []string // Also used for ByName, as the name (for modsec rules) is a tag crowdsec-NAME + + // BodySettings controls how oversized request bodies are handled. Settable via on_load hooks. + BodySettings BodySettings } type AppsecConfig struct { @@ -199,8 +223,8 @@ type AppsecConfig struct { PostEval []Hook `yaml:"post_eval"` OnMatch []Hook `yaml:"on_match"` VariablesTracking []string `yaml:"variables_tracking"` - InbandOptions AppsecSubEngineOpts `yaml:"inband_options"` - OutOfBandOptions AppsecSubEngineOpts `yaml:"outofband_options"` + InbandOptions AppsecSubEngineOpts `yaml:"inband_options"` + OutOfBandOptions AppsecSubEngineOpts `yaml:"outofband_options"` LogLevel *log.Level `yaml:"log_level"` Logger *log.Entry `yaml:"-"` @@ -403,6 +427,10 @@ func (wc *AppsecConfig) Build(hub *cwhub.Hub) (*AppsecRuntimeConfig, error) { ret.Name = wc.Name ret.Config = wc ret.DefaultRemediation = wc.DefaultRemediation + ret.BodySettings = BodySettings{ + MaxSize: DefaultMaxBodySize, + Action: BodySizeActionDrop, + } wc.Logger.Tracef("Loading config %+v", wc) // load rules @@ -849,6 +877,33 @@ func (w *AppsecRuntimeConfig) SetHTTPCode(state *AppsecRequestState, code int) e return nil } +// SetMaxBodySize sets the maximum allowed body size in bytes. Intended for use in on_load hooks. +func (w *AppsecRuntimeConfig) SetMaxBodySize(size int64) error { + if size <= 0 { + return fmt.Errorf("max_body_size must be a positive integer") + } + + w.Logger.Debugf("setting max body size to %d bytes", size) + w.BodySettings.MaxSize = size + + return nil +} + +// SetBodySizeExceededAction sets what happens when the body exceeds the maximum size. +// Valid values: "drop" (block request), "partial" (inspect up to max size), "allow" (skip body inspection). +// Intended for use in on_load hooks. +func (w *AppsecRuntimeConfig) SetBodySizeExceededAction(action string) error { + switch action { + case BodySizeActionDrop, BodySizeActionPartial, BodySizeActionAllow: + w.Logger.Debugf("setting body size exceeded action to %q", action) + w.BodySettings.Action = action + + return nil + default: + return fmt.Errorf("invalid body_size_exceeded_action %q (must be %s, %s, or %s)", action, BodySizeActionDrop, BodySizeActionPartial, BodySizeActionAllow) + } +} + type BodyResponse struct { Action string `json:"action"` HTTPStatus int `json:"http_status"` diff --git a/pkg/appsec/request.go b/pkg/appsec/request.go index a300954f76d..835acba9556 100644 --- a/pkg/appsec/request.go +++ b/pkg/appsec/request.go @@ -3,6 +3,7 @@ package appsec import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net" @@ -48,6 +49,11 @@ type ParsedRequest struct { AppsecEngine string `json:"appsec_engine,omitempty"` RemoteAddrNormalized string `json:"normalized_remote_addr,omitempty"` HTTPRequest *http.Request `json:"-"` + // BodyTruncated is true when the body was larger than the configured limit and was truncated (partial mode). + BodyTruncated bool `json:"body_truncated,omitempty"` + // BodySizeExceeded is true when the body exceeded the configured limit and the action is drop. + // The body is not populated in this case; a fake interruption will be triggered in the runner. + BodySizeExceeded bool `json:"body_size_exceeded,omitempty"` } type ReqDumpFilter struct { @@ -285,19 +291,53 @@ func (r *ReqDumpFilter) ToJSON() error { return nil } -// Generate a ParsedRequest from a http.Request. ParsedRequest can be consumed by the App security Engine -func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry) (ParsedRequest, error) { - var err error - contentLength := max(r.ContentLength, 0) - body := make([]byte, contentLength) +// Generate a ParsedRequest from a http.Request. ParsedRequest can be consumed by the App security Engine. +// bodySettings controls the maximum body size and what to do when the limit is exceeded. +func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry, bodySettings BodySettings) (ParsedRequest, error) { + var ( + err error + body []byte + bodyTruncated bool + bodySizeExceeded bool + ) + if r.Body != nil { - _, err = io.ReadFull(r.Body, body) - if err != nil { - return ParsedRequest{}, fmt.Errorf("unable to read body: %s", err) + maxSize := bodySettings.MaxSize + if maxSize <= 0 { + maxSize = DefaultMaxBodySize + } + + action := bodySettings.Action + if action == "" { + action = BodySizeActionDrop + } + + // Always read from the actual stream — never trust Content-Length. + // Read up to maxSize+1 bytes so we can detect whether the body exceeds the limit. + body, err = io.ReadAll(io.LimitReader(r.Body, maxSize+1)) + // io.ErrUnexpectedEOF means the connection was closed without a clean EOF (e.g. no + // Content-Length and no body). Treat whatever was read as the complete body. + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + return ParsedRequest{}, fmt.Errorf("unable to read body: %w", err) } - // reset the original body back as it's been read, i'm not sure its needed? - r.Body = io.NopCloser(bytes.NewBuffer(body)) + if int64(len(body)) > maxSize { + switch action { + case BodySizeActionDrop: + logger.Warnf("request body exceeds limit %d bytes, will drop request", maxSize) + body = nil + bodySizeExceeded = true + case BodySizeActionAllow: + logger.Warnf("request body exceeds limit %d bytes, skipping body inspection", maxSize) + body = nil + case BodySizeActionPartial: + logger.Warnf("request body exceeds limit %d bytes, truncating", maxSize) + body = body[:maxSize] + bodyTruncated = true + } + } + + r.Body = io.NopCloser(bytes.NewBuffer(body)) } clientIP := r.Header.Get(IPHeaderName) if clientIP == "" { @@ -412,6 +452,8 @@ func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry) (ParsedRequ URL: parsedURL, Proto: r.Proto, Body: body, + BodyTruncated: bodyTruncated, + BodySizeExceeded: bodySizeExceeded, Args: exprhelpers.ParseQuery(parsedURL.RawQuery), TransferEncoding: r.TransferEncoding, ResponseChannel: make(chan AppsecTempResponse), diff --git a/pkg/appsec/request_test.go b/pkg/appsec/request_test.go index 8b457e24dab..79b0d947129 100644 --- a/pkg/appsec/request_test.go +++ b/pkg/appsec/request_test.go @@ -1,6 +1,14 @@ package appsec -import "testing" +import ( + "bytes" + "io" + "net/http" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/require" +) func TestBodyDumper(t *testing.T) { tests := []struct { @@ -176,3 +184,92 @@ func TestBodyDumper(t *testing.T) { }) } } + +func makeTestRequest(t *testing.T, body []byte) *http.Request { + t.Helper() + + var bodyReader io.ReadCloser + if body != nil { + bodyReader = io.NopCloser(bytes.NewReader(body)) + } + + r := &http.Request{ + RemoteAddr: "1.2.3.4:1234", + Body: bodyReader, + Header: http.Header{ + IPHeaderName: []string{"1.2.3.4"}, + URIHeaderName: []string{"/test"}, + VerbHeaderName: []string{"POST"}, + }, + } + + return r +} + +func TestNewParsedRequestFromRequestBodyLimit(t *testing.T) { + logger := log.WithField("test", "body-limit") + + tests := []struct { + name string + body []byte + settings BodySettings + expectBody []byte // nil means expect empty/nil body + expectTruncated bool + expectExceeded bool + }{ + { + name: "no body", + body: nil, + settings: BodySettings{MaxSize: 10, Action: BodySizeActionDrop}, + }, + { + name: "within limit", + body: bytes.Repeat([]byte("x"), 5), + settings: BodySettings{MaxSize: 10, Action: BodySizeActionDrop}, + expectBody: bytes.Repeat([]byte("x"), 5), + }, + { + name: "exactly at limit", + body: bytes.Repeat([]byte("x"), 10), + settings: BodySettings{MaxSize: 10, Action: BodySizeActionDrop}, + expectBody: bytes.Repeat([]byte("x"), 10), + }, + { + name: "over limit – drop", + body: bytes.Repeat([]byte("x"), 15), + settings: BodySettings{MaxSize: 10, Action: BodySizeActionDrop}, + expectExceeded: true, + }, + { + name: "over limit – partial", + body: bytes.Repeat([]byte("x"), 15), + settings: BodySettings{MaxSize: 10, Action: BodySizeActionPartial}, + expectBody: bytes.Repeat([]byte("x"), 10), + expectTruncated: true, + }, + { + name: "over limit – allow", + body: bytes.Repeat([]byte("x"), 15), + settings: BodySettings{MaxSize: 10, Action: BodySizeActionAllow}, + }, + { + name: "zero MaxSize uses default (small body fits)", + body: bytes.Repeat([]byte("x"), 5), + settings: BodySettings{}, + expectBody: bytes.Repeat([]byte("x"), 5), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := makeTestRequest(t, test.body) + + parsed, err := NewParsedRequestFromRequest(r, logger, test.settings) + require.NoError(t, err) + + require.Equal(t, test.expectTruncated, parsed.BodyTruncated) + require.Equal(t, test.expectExceeded, parsed.BodySizeExceeded) + require.Equal(t, test.expectBody, parsed.Body) + }) + } +} diff --git a/pkg/appsec/waf_helpers.go b/pkg/appsec/waf_helpers.go index 7da4eba2398..04e4b9b7c97 100644 --- a/pkg/appsec/waf_helpers.go +++ b/pkg/appsec/waf_helpers.go @@ -6,15 +6,17 @@ import ( func GetOnLoadEnv(w *AppsecRuntimeConfig) map[string]interface{} { return map[string]interface{}{ - "RemoveInBandRuleByID": w.DisableInBandRuleByID, - "RemoveInBandRuleByTag": w.DisableInBandRuleByTag, - "RemoveInBandRuleByName": w.DisableInBandRuleByName, - "RemoveOutBandRuleByID": w.DisableOutBandRuleByID, - "RemoveOutBandRuleByTag": w.DisableOutBandRuleByTag, - "RemoveOutBandRuleByName": w.DisableOutBandRuleByName, - "SetRemediationByTag": w.SetActionByTag, - "SetRemediationByID": w.SetActionByID, - "SetRemediationByName": w.SetActionByName, + "RemoveInBandRuleByID": w.DisableInBandRuleByID, + "RemoveInBandRuleByTag": w.DisableInBandRuleByTag, + "RemoveInBandRuleByName": w.DisableInBandRuleByName, + "RemoveOutBandRuleByID": w.DisableOutBandRuleByID, + "RemoveOutBandRuleByTag": w.DisableOutBandRuleByTag, + "RemoveOutBandRuleByName": w.DisableOutBandRuleByName, + "SetRemediationByTag": w.SetActionByTag, + "SetRemediationByID": w.SetActionByID, + "SetRemediationByName": w.SetActionByName, + "SetMaxBodySize": w.SetMaxBodySize, + "SetBodySizeExceededAction": w.SetBodySizeExceededAction, } } From 312568d92d481d988e5526af8dd4e9855d59d410 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Thu, 5 Mar 2026 16:03:02 +0100 Subject: [PATCH 2/6] make sure to drain body if too big --- pkg/appsec/request.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/appsec/request.go b/pkg/appsec/request.go index 835acba9556..ff13238c690 100644 --- a/pkg/appsec/request.go +++ b/pkg/appsec/request.go @@ -322,6 +322,11 @@ func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry, bodySetting } if int64(len(body)) > maxSize { + // Drain the remaining body so the client doesn't time out waiting for the server + // to finish reading. The LimitReader stopped at maxSize+1, so r.Body may still + // have unread bytes. + _, _ = io.Copy(io.Discard, r.Body) + switch action { case BodySizeActionDrop: logger.Warnf("request body exceeds limit %d bytes, will drop request", maxSize) From 012b82529fb4188b20e516418fccc8f06b26f102 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Thu, 5 Mar 2026 16:03:22 +0100 Subject: [PATCH 3/6] do not error out if we cannot process the body with coraza --- pkg/acquisition/modules/appsec/appsec_runner.go | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/acquisition/modules/appsec/appsec_runner.go b/pkg/acquisition/modules/appsec/appsec_runner.go index ab9af44bb1b..cf8416a22b9 100644 --- a/pkg/acquisition/modules/appsec/appsec_runner.go +++ b/pkg/acquisition/modules/appsec/appsec_runner.go @@ -208,18 +208,17 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request if len(request.Body) > 0 { in, _, err = state.Tx.WriteRequestBody(request.Body) if err != nil { - r.logger.Errorf("unable to write request body : %s", err) - return err - } - if in != nil { + r.logger.Warnf("unable to write request body, skipping body inspection: %s", err) + } else if in != nil { return nil } } - in, err = state.Tx.ProcessRequestBody() - if err != nil { - r.logger.Errorf("unable to process request body : %s", err) - return err + if err == nil { + in, err = state.Tx.ProcessRequestBody() + if err != nil { + r.logger.Warnf("unable to process request body, skipping body inspection: %s", err) + } } if in != nil { From 144774172435240c6b368d386a0308380e2ec436 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Fri, 6 Mar 2026 09:52:36 +0100 Subject: [PATCH 4/6] lint --- pkg/acquisition/modules/appsec/appsec_bodysize_test.go | 2 +- pkg/appsec/appsec.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go index 8b6cf0e222b..54161a4c7bb 100644 --- a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go +++ b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go @@ -78,7 +78,7 @@ func TestAppsecBodySize(t *testing.T) { output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { require.Len(t, responses, 1) require.True(t, responses[0].InBandInterrupt) - // Inband alert+event cancelled; outband LOG event still fires + // Inband alert+event canceled; outband LOG event still fires require.Len(t, events, 1) require.Equal(t, pipeline.LOG, events[0].Type) require.True(t, events[0].Appsec.HasOutBandMatches) diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index e99932a2fd6..9bf0c43fa5e 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -223,8 +223,8 @@ type AppsecConfig struct { PostEval []Hook `yaml:"post_eval"` OnMatch []Hook `yaml:"on_match"` VariablesTracking []string `yaml:"variables_tracking"` - InbandOptions AppsecSubEngineOpts `yaml:"inband_options"` - OutOfBandOptions AppsecSubEngineOpts `yaml:"outofband_options"` + InbandOptions AppsecSubEngineOpts `yaml:"inband_options"` + OutOfBandOptions AppsecSubEngineOpts `yaml:"outofband_options"` LogLevel *log.Level `yaml:"log_level"` Logger *log.Entry `yaml:"-"` @@ -880,7 +880,7 @@ func (w *AppsecRuntimeConfig) SetHTTPCode(state *AppsecRequestState, code int) e // SetMaxBodySize sets the maximum allowed body size in bytes. Intended for use in on_load hooks. func (w *AppsecRuntimeConfig) SetMaxBodySize(size int64) error { if size <= 0 { - return fmt.Errorf("max_body_size must be a positive integer") + return errors.New("max_body_size must be a positive integer") } w.Logger.Debugf("setting max body size to %d bytes", size) From 4d49c7257dcb366890dbccebb477a6fec1dd5f64 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Wed, 1 Apr 2026 11:45:39 +0200 Subject: [PATCH 5/6] add new helper to disable body inspection --- .../modules/appsec/appsec_bodysize_test.go | 91 +++++++++++++++++++ .../modules/appsec/appsec_runner.go | 32 ++++--- pkg/appsec/appsec.go | 11 +++ pkg/appsec/waf_helpers.go | 1 + 4 files changed, 121 insertions(+), 14 deletions(-) diff --git a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go index 54161a4c7bb..0b2cef34f47 100644 --- a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go +++ b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go @@ -169,3 +169,94 @@ func TestAppsecBodySize(t *testing.T) { runTests(t, tests) } + +func TestAppsecDisableBodyInspection(t *testing.T) { + tests := []appsecRuleTest{ + { + name: "DisableBodyInspection – body rule does not fire", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "MALICIOUS"}, + }, + }, + pre_eval: []appsec.Hook{ + {Filter: "1 == 1", Apply: []string{"DisableBodyInspection()"}}, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: []byte("payload=MALICIOUS"), + }, + 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) + require.Empty(t, events) + }, + }, + { + name: "DisableBodyInspection – without hook, body rule fires normally", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "MALICIOUS"}, + }, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: []byte("payload=MALICIOUS"), + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + require.Equal(t, appsec.BanRemediation, responses[0].Action) + }, + }, + { + name: "DisableBodyInspection – conditional filter, body inspected when filter does not match", + expected_load_ok: true, + inband_rules: []appsec_rule.CustomRule{ + { + Name: "rule1", + Zones: []string{"BODY_ARGS"}, + Variables: []string{"payload"}, + Match: appsec_rule.Match{Type: "contains", Value: "MALICIOUS"}, + }, + }, + pre_eval: []appsec.Hook{ + {Filter: "req.URL.Path startsWith '/upload'", Apply: []string{"DisableBodyInspection()"}}, + }, + input_request: appsec.ParsedRequest{ + ClientIP: "1.2.3.4", + RemoteAddr: "127.0.0.1", + Method: "POST", + URI: "/api", + Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + HTTPRequest: &http.Request{Host: "example.com"}, + Body: []byte("payload=MALICIOUS"), + }, + output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { + require.Len(t, responses, 1) + require.True(t, responses[0].InBandInterrupt) + require.Equal(t, appsec.BanRemediation, responses[0].Action) + }, + }, + } + + runTests(t, tests) +} diff --git a/pkg/acquisition/modules/appsec/appsec_runner.go b/pkg/acquisition/modules/appsec/appsec_runner.go index cf8416a22b9..4ddec47dc4d 100644 --- a/pkg/acquisition/modules/appsec/appsec_runner.go +++ b/pkg/acquisition/modules/appsec/appsec_runner.go @@ -201,23 +201,27 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } - if request.BodyTruncated { - r.logger.Warnf("request body was truncated to %d bytes (partial mode)", len(request.Body)) - } + if state.DisableBodyInspection { + r.logger.Debugf("body inspection is disabled for this request, skipping body processing") + } else { + if request.BodyTruncated { + r.logger.Warnf("request body was truncated to %d bytes (partial mode)", len(request.Body)) + } - if len(request.Body) > 0 { - in, _, err = state.Tx.WriteRequestBody(request.Body) - if err != nil { - r.logger.Warnf("unable to write request body, skipping body inspection: %s", err) - } else if in != nil { - return nil + if len(request.Body) > 0 { + in, _, err = state.Tx.WriteRequestBody(request.Body) + if err != nil { + r.logger.Warnf("unable to write request body, skipping body inspection: %s", err) + } else if in != nil { + return nil + } } - } - if err == nil { - in, err = state.Tx.ProcessRequestBody() - if err != nil { - r.logger.Warnf("unable to process request body, skipping body inspection: %s", err) + if err == nil { + in, err = state.Tx.ProcessRequestBody() + if err != nil { + r.logger.Warnf("unable to process request body, skipping body inspection: %s", err) + } } } diff --git a/pkg/appsec/appsec.go b/pkg/appsec/appsec.go index 9bf0c43fa5e..ea53c537876 100644 --- a/pkg/appsec/appsec.go +++ b/pkg/appsec/appsec.go @@ -121,6 +121,8 @@ type AppsecRequestState struct { PendingAction *string PendingHTTPCode *int + + DisableBodyInspection bool } func (s *AppsecRequestState) ResetResponse(cfg *AppsecConfig) { @@ -904,6 +906,15 @@ func (w *AppsecRuntimeConfig) SetBodySizeExceededAction(action string) error { } } +// DisableBodyInspection prevents Coraza from processing the request body for the current request. +// Intended for use in pre_eval hooks. +func (w *AppsecRuntimeConfig) DisableBodyInspection(state *AppsecRequestState) error { + state.DisableBodyInspection = true + w.Logger.Debugf("body inspection disabled for this request") + + return nil +} + type BodyResponse struct { Action string `json:"action"` HTTPStatus int `json:"http_status"` diff --git a/pkg/appsec/waf_helpers.go b/pkg/appsec/waf_helpers.go index 04e4b9b7c97..e79979f9007 100644 --- a/pkg/appsec/waf_helpers.go +++ b/pkg/appsec/waf_helpers.go @@ -43,6 +43,7 @@ func GetPreEvalEnv(w *AppsecRuntimeConfig, state *AppsecRequestState, request *P state.PendingHTTPCode = &code return nil }, + "DisableBodyInspection": func() error { return w.DisableBodyInspection(state) }, } } From a6e1ba6c72ffe3298fb656c0d2712a3bbae837d3 Mon Sep 17 00:00:00 2001 From: Sebastien Blot Date: Wed, 1 Apr 2026 11:52:10 +0200 Subject: [PATCH 6/6] always run phase2 rules --- .../modules/appsec/appsec_bodysize_test.go | 23 +++++++++++-------- .../modules/appsec/appsec_runner.go | 12 +++++----- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go index 0b2cef34f47..24bb44a421f 100644 --- a/pkg/acquisition/modules/appsec/appsec_bodysize_test.go +++ b/pkg/acquisition/modules/appsec/appsec_bodysize_test.go @@ -2,6 +2,7 @@ package appsecacquisition import ( "net/http" + "net/url" "testing" "github.com/stretchr/testify/require" @@ -173,7 +174,7 @@ func TestAppsecBodySize(t *testing.T) { func TestAppsecDisableBodyInspection(t *testing.T) { tests := []appsecRuleTest{ { - name: "DisableBodyInspection – body rule does not fire", + name: "DisableBodyInspection - body rule does not fire", expected_load_ok: true, inband_rules: []appsec_rule.CustomRule{ { @@ -202,24 +203,26 @@ func TestAppsecDisableBodyInspection(t *testing.T) { }, }, { - name: "DisableBodyInspection – without hook, body rule fires normally", + name: "DisableBodyInspection - ARGS rule still fires (phase 2 still evaluated)", expected_load_ok: true, inband_rules: []appsec_rule.CustomRule{ { Name: "rule1", - Zones: []string{"BODY_ARGS"}, - Variables: []string{"payload"}, - Match: appsec_rule.Match{Type: "contains", Value: "MALICIOUS"}, + Zones: []string{"ARGS"}, + Variables: []string{"foo"}, + Match: appsec_rule.Match{Type: "regex", Value: "^toto"}, }, }, + pre_eval: []appsec.Hook{ + {Filter: "1 == 1", Apply: []string{"DisableBodyInspection()"}}, + }, input_request: appsec.ParsedRequest{ ClientIP: "1.2.3.4", RemoteAddr: "127.0.0.1", - Method: "POST", - URI: "/", - Headers: http.Header{"Content-Type": []string{"application/x-www-form-urlencoded"}}, + Method: "GET", + URI: "/?foo=toto", + Args: url.Values{"foo": []string{"toto"}}, HTTPRequest: &http.Request{Host: "example.com"}, - Body: []byte("payload=MALICIOUS"), }, output_asserts: func(events []pipeline.Event, responses []appsec.AppsecTempResponse, appsecResponse appsec.BodyResponse, statusCode int) { require.Len(t, responses, 1) @@ -228,7 +231,7 @@ func TestAppsecDisableBodyInspection(t *testing.T) { }, }, { - name: "DisableBodyInspection – conditional filter, body inspected when filter does not match", + name: "DisableBodyInspection - conditional filter, body inspected when filter does not match", expected_load_ok: true, inband_rules: []appsec_rule.CustomRule{ { diff --git a/pkg/acquisition/modules/appsec/appsec_runner.go b/pkg/acquisition/modules/appsec/appsec_runner.go index 4ddec47dc4d..da6fc8e5875 100644 --- a/pkg/acquisition/modules/appsec/appsec_runner.go +++ b/pkg/acquisition/modules/appsec/appsec_runner.go @@ -202,7 +202,7 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request } if state.DisableBodyInspection { - r.logger.Debugf("body inspection is disabled for this request, skipping body processing") + r.logger.Debugf("body inspection is disabled for this request, skipping body write") } else { if request.BodyTruncated { r.logger.Warnf("request body was truncated to %d bytes (partial mode)", len(request.Body)) @@ -216,12 +216,12 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } } + } - if err == nil { - in, err = state.Tx.ProcessRequestBody() - if err != nil { - r.logger.Warnf("unable to process request body, skipping body inspection: %s", err) - } + if err == nil { + in, err = state.Tx.ProcessRequestBody() + if err != nil { + r.logger.Warnf("unable to process request body, skipping body inspection: %s", err) } }