CrowdSec AppSec silently drops request body for chunked / HTTP-2 requests
Description
Summary
The CrowdSec AppSec component fails to read the HTTP request body for any request whose Content-Length is not positive — most notably HTTP/1.1 requests using Transfer-Encoding: chunked and HTTP/2 requests sent without a content-length header. Coraza is then evaluated against an empty body, so every WAF rule targeting REQUEST_BODY, BODY_ARGS, ARGS_POST, JSON, or XML silently fails to match.
An unauthenticated remote attacker can bypass the entire AppSec body-inspection pipeline by changing a single framing header on an otherwise-malicious request. The bypassed request is forwarded as allow and produces no WAF log entry.
Affected versions
github.com/crowdsecurity/crowdsec— all releases up to and including v1.7.7.
Affected component
pkg/appsec/request.go, function NewParsedRequestFromRequest.
Root cause
func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry) (ParsedRequest, error) {
var err error
contentLength := max(r.ContentLength, 0)
body := make([]byte, contentLength)
if r.Body != nil {
_, err = io.ReadFull(r.Body, body)
if err != nil {
return ParsedRequest{}, fmt.Errorf("unable to read body: %s", err)
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
}
...
}
Go's net/http server sets r.ContentLength = -1 when the request uses Transfer-Encoding: chunked with no Content-Length header, or when an HTTP/2 request omits the content-length pseudo-header (DATA-frame-only body). With ContentLength == -1:
max(-1, 0)evaluates to0.make([]byte, 0)allocates a zero-length slice.io.ReadFullon a zero-length buffer needs zero bytes and returns immediately without touchingr.Body.- The empty buffer is written back onto the request and onto the cloned request constructed later in the same function.
Every downstream consumer then sees an empty body. In the AppSec runner, WriteRequestBody is skipped because the parsed body has zero length, and ProcessRequestBody runs against nothing.
Impact
Every body-scanning rule is bypassed for any request whose framing makes Content-Length non-positive. In default CrowdSec deployments using the standard AppSec collections, the bypass affects any rule with zones containing BODY_ARGS, JSON, XML, REQUEST_BODY, or ARGS_POST.
No configuration option mitigates the issue — the defect is in the request parser, not in any ruleset. Bypassed requests do not produce a WAF log entry, so operators have no signal that rules are being skipped.
Header-only and URI-only rules are unaffected.
Workarounds
No complete workaround is available.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CrowdSec AppSec silently ignores the HTTP request body when Content-Length is not positive, allowing unauthenticated attackers to bypass WAF body-inspection rules by using chunked or HTTP/2 framing.
Vulnerability
In the CrowdSec AppSec component (pkg/appsec/request.go, function NewParsedRequestFromRequest), the HTTP request body is read only when r.ContentLength is positive. Go's net/http server sets ContentLength to -1 for HTTP/1.1 requests with Transfer-Encoding: chunked and no Content-Length header, and for HTTP/2 requests that omit the content-length pseudo-header. The code uses max(r.ContentLength, 0) to compute the buffer size, which becomes 0 for such requests, resulting in an empty byte slice and no body being read. All releases up to and including v1.7.7 are affected [1][2].
Exploitation
An unauthenticated remote attacker can exploit this by sending a malicious request that omits a positive Content-Length header but includes a body. For example, an HTTP/1.1 request with Transfer-Encoding: chunked or an HTTP/2 request with a DATA frame but no content-length pseudo-header. The AppSec component then processes an empty body, and the WAF engine (Coraza) evaluates rules against nothing. No user interaction or special network position beyond the ability to reach the AppSec component is required [1][2].
Impact
All WAF rules that target REQUEST_BODY, BODY_ARGS, ARGS_POST, JSON, or XML are silently bypassed. The malicious request is forwarded as allow without generating a WAF log entry. An attacker can inject arbitrary payloads in the request body (e.g., SQL injection, command injection, XSS) to compromise the backend application, achieving information disclosure, data manipulation, or remote code execution depending on the backend vulnerabilities [1][2].
Mitigation
As of the publication date (2026-05-27), no fix has been released. Users of CrowdSec v1.7.7 and earlier should apply a workaround by configuring the reverse proxy or load balancer in front of CrowdSec to reject requests with Transfer-Encoding: chunked or to inject a Content-Length header for HTTP/2 requests. For HTTP/2, a proxy can be used to translate to HTTP/1.1 with a fixed Content-Length. Monitor the GitHub advisory for patch announcements [1][2].
AI Insight generated on May 27, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
3>= 1.5.0, <= 1.7.7+ 1 more
- (no CPE)range: >= 1.5.0, <= 1.7.7
- (no CPE)range: <= v1.7.7
Patches
354a0dfe6c16bMerge commit from fork
3 files changed · +167 −5
pkg/apiserver/body_limit_test.go+135 −0 added@@ -0,0 +1,135 @@ +package apiserver + +import ( + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1" +) + +// When the MaxBytesReader cap is exceeded, encoding/json surfaces this error +// string from the underlying read. The handlers return it verbatim in the 400 +// response, which lets us assert the middleware is the actual rejecter (as +// opposed to a parse or validation error on a truncated body). +const bodyTooLargeMsg = "http: request body too large" + +// TestBodyLimit_UnauthenticatedOverLimit posts a JSON document larger than the +// 2 MiB cap on the unauthenticated /v1/watchers endpoint and asserts the +// middleware trips. +func TestBodyLimit_UnauthenticatedOverLimit(t *testing.T) { + ctx := t.Context() + router, _ := NewAPITest(t, ctx) + + body := oversizedJSON(t, int(middlewares.UnauthenticatedBodyLimit)+1024) + + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), bodyTooLargeMsg) +} + +// TestBodyLimit_UnauthenticatedUnderLimit sends the same style of request but +// well under the 2 MiB cap, so the middleware must not fire. We don't care +// whether registration ultimately succeeds — only that the failure (if any) is +// not a body-size rejection. +func TestBodyLimit_UnauthenticatedUnderLimit(t *testing.T) { + ctx := t.Context() + router, _ := NewAPITest(t, ctx) + + // Valid registration payload, definitely below 2 MiB. + body := `{"machine_id":"test","password":"test"}` + + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.NotContains(t, w.Body.String(), bodyTooLargeMsg) +} + +// TestBodyLimit_AuthenticatedAboveUnauthCap verifies that an authenticated +// endpoint accepts bodies larger than the unauthenticated cap. The alert +// payload here is not semantically valid, so we don't expect 2xx — but the +// rejection must not come from the body-size middleware. +func TestBodyLimit_AuthenticatedAboveUnauthCap(t *testing.T) { + ctx := t.Context() + lapi := SetupLAPITest(t, ctx) + + // Build a payload ~4 MiB: over the unauth 2 MiB cap, well under the 50 MiB + // auth cap. Uses the alert-array shape so we get past the JSON top-level + // type check and into field validation (which will fail — that's fine). + size := int(middlewares.UnauthenticatedBodyLimit) * 2 + body := `[{"message":"` + strings.Repeat("a", size) + `"}]` + + w := lapi.RecordResponse(t, ctx, http.MethodPost, "/v1/alerts", strings.NewReader(body), passwordAuthType) + + assert.NotContains(t, w.Body.String(), bodyTooLargeMsg) +} + +// TestBodyLimit_AuthenticatedOverLimit posts a payload above the 50 MiB auth +// cap and asserts the middleware trips. +func TestBodyLimit_AuthenticatedOverLimit(t *testing.T) { + ctx := t.Context() + lapi := SetupLAPITest(t, ctx) + + size := int(middlewares.AuthenticatedBodyLimit) + 1024 + body := `[{"message":"` + strings.Repeat("a", size) + `"}]` + + w := lapi.RecordResponse(t, ctx, http.MethodPost, "/v1/alerts", strings.NewReader(body), passwordAuthType) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), bodyTooLargeMsg) +} + +// TestBodyLimit_GzipDecompressedSize confirms the cap is enforced on the +// *decompressed* size: a small compressed payload that expands past the +// unauthenticated cap must be rejected. +func TestBodyLimit_GzipDecompressedSize(t *testing.T) { + ctx := t.Context() + router, _ := NewAPITest(t, ctx) + + // Pad a valid-looking JSON doc with a large whitespace run; zeros/spaces + // compress to a tiny payload but expand past the 2 MiB cap. + decompressed := `{"machine_id":"test","password":"test","_pad":"` + + strings.Repeat(" ", int(middlewares.UnauthenticatedBodyLimit)+1024) + `"}` + + var compressed bytes.Buffer + gz := gzip.NewWriter(&compressed) + _, err := gz.Write([]byte(decompressed)) + require.NoError(t, err) + require.NoError(t, gz.Close()) + require.Less(t, compressed.Len(), int(middlewares.UnauthenticatedBodyLimit), + "compressed body must be under the cap so only the decompressed size can trip it") + + w := httptest.NewRecorder() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "/v1/watchers", bytes.NewReader(compressed.Bytes())) + require.NoError(t, err) + req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "gzip") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), bodyTooLargeMsg) +} + +// oversizedJSON returns a syntactically-valid JSON object whose raw size is at +// least `size` bytes, via a long string field. +func oversizedJSON(t *testing.T, size int) string { + t.Helper() + return `{"machine_id":"test","password":"test","_pad":"` + strings.Repeat("a", size) + `"}` +}
pkg/apiserver/controllers/controller.go+9 −5 modified@@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1" + middlewaresv1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1" "github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/database" "github.com/crowdsecurity/crowdsec/pkg/logging" @@ -110,13 +111,16 @@ func (c *Controller) NewV1() error { ctx.AbortWithStatus(http.StatusMethodNotAllowed) }) + unauthBodyLimit := middlewaresv1.BodyLimit(middlewaresv1.UnauthenticatedBodyLimit) + authBodyLimit := middlewaresv1.BodyLimit(middlewaresv1.AuthenticatedBodyLimit) + groupV1 := c.Router.Group("/v1") - groupV1.POST("/watchers", c.HandlerV1.AbortRemoteIf(c.DisableRemoteLapiRegistration), c.HandlerV1.CreateMachine) - groupV1.POST("/watchers/login", c.HandlerV1.Middlewares.JWT.Middleware.LoginHandler) + groupV1.POST("/watchers", unauthBodyLimit, c.HandlerV1.AbortRemoteIf(c.DisableRemoteLapiRegistration), c.HandlerV1.CreateMachine) + groupV1.POST("/watchers/login", unauthBodyLimit, c.HandlerV1.Middlewares.JWT.Middleware.LoginHandler) jwtAuth := groupV1.Group("") jwtAuth.GET("/refresh_token", c.HandlerV1.Middlewares.JWT.Middleware.RefreshHandler) - jwtAuth.Use(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), v1.PrometheusMachinesMiddleware) + jwtAuth.Use(authBodyLimit, c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), v1.PrometheusMachinesMiddleware) { jwtAuth.POST("/alerts", c.HandlerV1.CreateAlert) jwtAuth.GET("/alerts", c.HandlerV1.FindAlerts) @@ -137,7 +141,7 @@ func (c *Controller) NewV1() error { } apiKeyAuth := groupV1.Group("") - apiKeyAuth.Use(c.HandlerV1.Middlewares.APIKey.Middleware, v1.PrometheusBouncersMiddleware) + apiKeyAuth.Use(authBodyLimit, c.HandlerV1.Middlewares.APIKey.Middleware, v1.PrometheusBouncersMiddleware) { apiKeyAuth.GET("/decisions", c.HandlerV1.GetDecision) apiKeyAuth.HEAD("/decisions", c.HandlerV1.GetDecision) @@ -146,7 +150,7 @@ func (c *Controller) NewV1() error { } eitherAuth := groupV1.Group("") - eitherAuth.Use(eitherAuthMiddleware(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), c.HandlerV1.Middlewares.APIKey.Middleware)) + eitherAuth.Use(authBodyLimit, eitherAuthMiddleware(c.HandlerV1.Middlewares.JWT.Middleware.MiddlewareFunc(), c.HandlerV1.Middlewares.APIKey.Middleware)) { eitherAuth.POST("/usage-metrics", c.HandlerV1.UsageMetrics) }
pkg/apiserver/middlewares/v1/body_limit.go+23 −0 added@@ -0,0 +1,23 @@ +package v1 + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Maximum body size for LAPI queries +// Applies to the decompressed body if it's gzipped +const ( + UnauthenticatedBodyLimit int64 = 2 * 1024 * 1024 // 2 MiB + AuthenticatedBodyLimit int64 = 50 * 1024 * 1024 // 50 MiB +) + +func BodyLimit(max int64) gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max) + } + c.Next() + } +}
57a793548671WAF: enforce body size limitation (#4355)
7 files changed · +681 −103
pkg/acquisition/modules/appsec/appsec_bodysize_test.go+305 −0 added@@ -0,0 +1,305 @@ +package appsecacquisition + +import ( + "net/http" + "net/url" + "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 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) + }, + }, + { + // 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) +} + +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 - ARGS rule still fires (phase 2 still evaluated)", + 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"}, + }, + }, + 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: "GET", + URI: "/?foo=toto", + Args: url.Values{"foo": []string{"toto"}}, + 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.True(t, responses[0].InBandInterrupt) + require.Equal(t, appsec.BanRemediation, responses[0].Action) + }, + }, + { + name: "DisableBodyInspection bypasses BodySizeExceeded drop", + expected_load_ok: true, + 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: "/", + 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.False(t, responses[0].InBandInterrupt) + require.Empty(t, events) + }, + }, + { + name: "BodySizeExceeded with conditional DisableBodyInspection - still drops when filter does not match", + expected_load_ok: true, + 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", + 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) + }, + }, + { + 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) +}
pkg/acquisition/modules/appsec/appsec_runner.go+28 −9 modified@@ -161,6 +161,20 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } + if request.BodySizeExceeded { + // DisableBodyInspection in pre_eval also opts out of the size-exceeded drop: + // the operator has explicitly accepted that this request's body will not be + // processed, so there is nothing to protect the WAF from. + if !state.DisableBodyInspection { + 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 + } + r.logger.Debugf("request body exceeded maximum allowed size but body inspection is disabled, allowing request") + } + state.Tx.ProcessConnection(request.ClientIP, 0, "", 0) for k, v := range request.Args { @@ -193,21 +207,26 @@ func (r *AppsecRunner) processRequest(state *appsec.AppsecRequestState, request return nil } - 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 state.DisableBodyInspection { + 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)) } - 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: %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 + r.logger.Warnf("unable to process request body: %s", err) } if in != nil {
pkg/acquisition/modules/appsec/config.go+33 −12 modified@@ -28,20 +28,26 @@ var ( errInvalidAPIKey = errors.New("invalid API key") ) -var DefaultAuthCacheDuration = (1 * time.Minute) +var ( + DefaultAuthCacheDuration = (1 * time.Minute) + DefaultBodyReadTimeout = (1 * time.Second) +) // configuration structure of the acquis for the application security engine type Configuration struct { - ListenAddr string `yaml:"listen_addr"` - ListenSocket string `yaml:"listen_socket"` - CertFilePath string `yaml:"cert_file"` - KeyFilePath string `yaml:"key_file"` - Path string `yaml:"path"` - Routines int `yaml:"routines"` - AppsecConfig string `yaml:"appsec_config"` - AppsecConfigs []string `yaml:"appsec_configs"` - AppsecConfigPath string `yaml:"appsec_config_path"` - AuthCacheDuration *time.Duration `yaml:"auth_cache_duration"` + ListenAddr string `yaml:"listen_addr"` + ListenSocket string `yaml:"listen_socket"` + CertFilePath string `yaml:"cert_file"` + KeyFilePath string `yaml:"key_file"` + Path string `yaml:"path"` + Routines int `yaml:"routines"` + AppsecConfig string `yaml:"appsec_config"` + AppsecConfigs []string `yaml:"appsec_configs"` + AppsecConfigPath string `yaml:"appsec_config_path"` + AuthCacheDuration *time.Duration `yaml:"auth_cache_duration"` + // BodyReadTimeout bounds how long we wait for the bouncer to finish sending the request body. + // Set to 0 to disable. Defaults to DefaultBodyReadTimeout. + BodyReadTimeout *time.Duration `yaml:"body_read_timeout"` configuration.DataSourceCommonCfg `yaml:",inline"` } @@ -143,6 +149,11 @@ func (w *Source) Configure(_ context.Context, yamlConfig []byte, logger *log.Ent w.logger.Infof("Cache duration for auth not set, using default: %v", *w.config.AuthCacheDuration) } + if w.config.BodyReadTimeout == nil { + w.config.BodyReadTimeout = &DefaultBodyReadTimeout + w.logger.Infof("Body read timeout not set, using default: %v", *w.config.BodyReadTimeout) + } + w.mux = http.NewServeMux() w.server = &http.Server{ @@ -334,8 +345,18 @@ func (w *Source) appsecHandler(rw http.ResponseWriter, r *http.Request) { return } + // Force client to send the body quickly enough. + // In practice, this is not a real issue as bouncers are trusted and assumed to behave properly, + // but some bouncers may take time to forward POST request with an empty body + // which would make us wait for several seconds on each empty-body request. + if *w.config.BodyReadTimeout > 0 { + if err := http.NewResponseController(rw).SetReadDeadline(time.Now().Add(*w.config.BodyReadTimeout)); err != nil { + w.logger.Debugf("unable to set read deadline: %s", err) + } + } + // 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)
pkg/appsec/appsec.go+66 −0 modified@@ -82,6 +82,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 ( @@ -150,6 +162,8 @@ type AppsecRequestState struct { PendingAction *string PendingHTTPCode *int + + DisableBodyInspection bool } func (s *AppsecRequestState) ResetResponse(cfg *AppsecConfig) { @@ -196,6 +210,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"` +} + // AppsecPhaseConfig holds configuration scoped to a specific phase (inband or outofband). // Hooks defined here are automatically dispatched only during the corresponding phase. type AppsecPhaseConfig struct { @@ -235,6 +258,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 { @@ -571,6 +597,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 @@ -884,6 +914,42 @@ 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 errors.New("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) + } +} + +// 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"`
pkg/appsec/request.go+139 −72 modified@@ -3,6 +3,7 @@ package appsec import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net" @@ -18,13 +19,13 @@ import ( ) const ( - URIHeaderName = "X-Crowdsec-Appsec-Uri" - VerbHeaderName = "X-Crowdsec-Appsec-Verb" - HostHeaderName = "X-Crowdsec-Appsec-Host" - IPHeaderName = "X-Crowdsec-Appsec-Ip" - APIKeyHeaderName = "X-Crowdsec-Appsec-Api-Key" - UserAgentHeaderName = "X-Crowdsec-Appsec-User-Agent" - HTTPVersionHeaderName = "X-Crowdsec-Appsec-Http-Version" + URIHeaderName = "X-Crowdsec-Appsec-Uri" + VerbHeaderName = "X-Crowdsec-Appsec-Verb" + HostHeaderName = "X-Crowdsec-Appsec-Host" + IPHeaderName = "X-Crowdsec-Appsec-Ip" + APIKeyHeaderName = "X-Crowdsec-Appsec-Api-Key" + UserAgentHeaderName = "X-Crowdsec-Appsec-User-Agent" + HTTPVersionHeaderName = "X-Crowdsec-Appsec-Http-Version" TransactionIDHeaderName = "X-Crowdsec-Appsec-Transaction-Id" ) @@ -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,20 +291,117 @@ 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) - if r.Body != nil { - _, err = io.ReadFull(r.Body, body) - if err != nil { - return ParsedRequest{}, fmt.Errorf("unable to read body: %s", err) +// forwardedHeaders are the X-Crowdsec-Appsec-* headers the bouncer supplies to the WAF. +// They carry the original request's metadata and must be stripped before handing the request +// off to Coraza so they aren't mistaken for client-supplied headers. +var forwardedHeaders = []string{ + IPHeaderName, + HostHeaderName, + URIHeaderName, + VerbHeaderName, + UserAgentHeaderName, + APIKeyHeaderName, + HTTPVersionHeaderName, + TransactionIDHeaderName, +} + +// readRequestBody reads r.Body bounded by bodySettings.MaxSize, applies the oversize action, and +// replaces r.Body with a buffered copy of what was kept so downstream code can still read it. +// A timeout on Read is treated as end-of-body — whatever was received is returned without error. +func readRequestBody(r *http.Request, bodySettings BodySettings, logger *log.Entry) (body []byte, truncated, exceeded bool, err error) { + if r.Body == nil { + return nil, false, false, nil + } + + 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)) + var netErr net.Error + hasTimedout := err != nil && errors.As(err, &netErr) && netErr.Timeout() + // ErrUnexpectedEOF can occur on POST requests without a body — accept what was read. + // A net.Error timeout means the read deadline fired; keep what we got and move on. + // Bouncers are semi-trusted; misbehaving ones would otherwise stall the WAF for seconds. + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !hasTimedout { + return nil, false, false, fmt.Errorf("unable to read body: %w", err) + } + + if int64(len(body)) > maxSize { + // Drain remaining bytes so the client doesn't time out waiting for us 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) + body = nil + exceeded = 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] + truncated = true } - // reset the original body back as it's been read, i'm not sure its needed? - r.Body = io.NopCloser(bytes.NewBuffer(body)) + } + + r.Body = io.NopCloser(bytes.NewBuffer(body)) + return body, truncated, exceeded, nil +} + +// applyHTTPVersion parses the 2-character HTTP version header (e.g. "11" for HTTP/1.1, "20" for HTTP/2) +// and updates r.Proto / r.ProtoMajor / r.ProtoMinor. Malformed values are logged and ignored. +func applyHTTPVersion(r *http.Request, version string, logger *log.Entry) { + if len(version) != 2 || + version[0] < '0' || version[0] > '9' || + version[1] < '0' || version[1] > '9' { + logger.Warnf("Invalid value %s for HTTP version header", version) + return + } + + r.ProtoMajor = int(version[0] - '0') + r.ProtoMinor = int(version[1] - '0') + if r.ProtoMajor == 2 && r.ProtoMinor == 0 { + r.Proto = "HTTP/2" + } else { + r.Proto = "HTTP/" + string(version[0]) + "." + string(version[1]) + } +} + +// normalizeRemoteAddr extracts the IP from a "host:port" address and returns its canonical form. +// Returns the input unchanged (and logs) if the value can't be parsed. +func normalizeRemoteAddr(remoteAddr string) string { + host, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + log.Errorf("Invalid appsec remote IP source %v: %s", remoteAddr, err.Error()) + return remoteAddr + } + ip := net.ParseIP(host) + if ip == nil { + log.Errorf("Invalid appsec remote IP address source %v", remoteAddr) + return remoteAddr + } + return ip.String() +} +// 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) { + body, bodyTruncated, bodySizeExceeded, err := readRequestBody(r, bodySettings, logger) + if err != nil { + return ParsedRequest{}, err } + clientIP := r.Header.Get(IPHeaderName) if clientIP == "" { return ParsedRequest{}, fmt.Errorf("missing '%s' header", IPHeaderName) @@ -315,90 +418,52 @@ func NewParsedRequestFromRequest(r *http.Request, logger *log.Entry) (ParsedRequ } clientHost := r.Header.Get(HostHeaderName) - if clientHost == "" { //this might be empty + if clientHost == "" { logger.Debugf("missing '%s' header", HostHeaderName) } - userAgent := r.Header.Get(UserAgentHeaderName) //This one is optional + userAgent := r.Header.Get(UserAgentHeaderName) - // Extract transaction ID from header if present, otherwise generate a new UUID transactionID := r.Header.Get(TransactionIDHeaderName) if transactionID == "" { transactionID = uuid.New().String() } - httpVersion := r.Header.Get(HTTPVersionHeaderName) - if httpVersion == "" { + if httpVersion := r.Header.Get(HTTPVersionHeaderName); httpVersion != "" { + applyHTTPVersion(r, httpVersion, logger) + } else { logger.Debugf("missing '%s' header", HTTPVersionHeaderName) } - if httpVersion != "" && len(httpVersion) == 2 && - httpVersion[0] >= '0' && httpVersion[0] <= '9' && - httpVersion[1] >= '0' && httpVersion[1] <= '9' { - major := httpVersion[0] - minor := httpVersion[1] - - r.ProtoMajor = int(major - '0') - r.ProtoMinor = int(minor - '0') - if r.ProtoMajor == 2 && r.ProtoMinor == 0 { - r.Proto = "HTTP/2" - } else { - r.Proto = "HTTP/" + string(major) + "." + string(minor) - } - } else if httpVersion != "" { - logger.Warnf("Invalid value %s for HTTP version header", httpVersion) + for _, h := range forwardedHeaders { + delete(r.Header, h) } - // delete those headers before coraza process the request - delete(r.Header, IPHeaderName) - delete(r.Header, HostHeaderName) - delete(r.Header, URIHeaderName) - delete(r.Header, VerbHeaderName) - delete(r.Header, UserAgentHeaderName) - delete(r.Header, APIKeyHeaderName) - delete(r.Header, HTTPVersionHeaderName) - delete(r.Header, TransactionIDHeaderName) + parsedURL, err := url.Parse(clientURI) + if err != nil { + return ParsedRequest{}, fmt.Errorf("unable to parse url '%s': %s", clientURI, err) + } originalHTTPRequest := r.Clone(r.Context()) originalHTTPRequest.Body = io.NopCloser(bytes.NewBuffer(body)) originalHTTPRequest.RemoteAddr = clientIP originalHTTPRequest.RequestURI = clientURI originalHTTPRequest.Method = clientMethod originalHTTPRequest.Host = clientHost + originalHTTPRequest.URL = parsedURL if userAgent != "" { + // Override the UA in the original request — this is what the WAF engine sees. originalHTTPRequest.Header.Set("User-Agent", userAgent) - r.Header.Set("User-Agent", userAgent) //Override the UA in the original request, as this is what will be used by the waf engine + r.Header.Set("User-Agent", userAgent) } else { - //If we don't have a forwarded UA, delete the one that was set by the remediation in both original and incoming + // No forwarded UA: drop any UA the remediation layer added, on both copies. originalHTTPRequest.Header.Del("User-Agent") r.Header.Del("User-Agent") } - parsedURL, err := url.Parse(clientURI) - if err != nil { - return ParsedRequest{}, fmt.Errorf("unable to parse url '%s': %s", clientURI, err) - } - - originalHTTPRequest.URL = parsedURL - - var remoteAddrNormalized string if r.RemoteAddr == "@" { r.RemoteAddr = "127.0.0.1:65535" } - // TODO we need to implement forwrded headers - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - log.Errorf("Invalid appsec remote IP source %v: %s", r.RemoteAddr, err.Error()) - remoteAddrNormalized = r.RemoteAddr - } else { - ip := net.ParseIP(host) - if ip == nil { - log.Errorf("Invalid appsec remote IP address source %v", r.RemoteAddr) - remoteAddrNormalized = r.RemoteAddr - } else { - remoteAddrNormalized = ip.String() - } - } return ParsedRequest{ RemoteAddr: r.RemoteAddr, @@ -412,10 +477,12 @@ 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), - RemoteAddrNormalized: remoteAddrNormalized, + RemoteAddrNormalized: normalizeRemoteAddr(r.RemoteAddr), HTTPRequest: originalHTTPRequest, }, nil }
pkg/appsec/request_test.go+98 −1 modified@@ -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) + }) + } +}
pkg/appsec/waf_helpers.go+12 −9 modified@@ -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, } } @@ -41,6 +43,7 @@ func GetPreEvalEnv(w *AppsecRuntimeConfig, state *AppsecRequestState, request *P state.PendingHTTPCode = &code return nil }, + "DisableBodyInspection": func() error { return w.DisableBodyInspection(state) }, } }
632274597a88LAPI body limit linting (#4462)
1 file changed · +2 −2
pkg/apiserver/middlewares/v1/body_limit.go+2 −2 modified@@ -13,10 +13,10 @@ const ( AuthenticatedBodyLimit int64 = 50 * 1024 * 1024 // 50 MiB ) -func BodyLimit(max int64) gin.HandlerFunc { +func BodyLimit(maxSize int64) gin.HandlerFunc { return func(c *gin.Context) { if c.Request.Body != nil { - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, max) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize) } c.Next() }
Vulnerability mechanics
Root cause
"The `NewParsedRequestFromRequest` function uses `max(r.ContentLength, 0)` to size the body buffer, so when Go's `net/http` sets `ContentLength = -1` (chunked/HTTP/2 requests), a zero-length buffer is allocated and the actual body is never read."
Attack vector
An unauthenticated remote attacker sends an HTTP request with a malicious body (e.g. SQL injection, XSS payload) but uses `Transfer-Encoding: chunked` (HTTP/1.1) or omits the `content-length` header/pseudo-header (HTTP/2) [ref_id=1][ref_id=2]. Go's `net/http` sets `r.ContentLength = -1`, causing `NewParsedRequestFromRequest` to allocate a zero-length buffer and skip reading the body [ref_id=1][ref_id=2]. The WAF (Coraza) evaluates rules against an empty body, so every rule targeting `REQUEST_BODY`, `BODY_ARGS`, `ARGS_POST`, `JSON`, or `XML` silently fails to match [ref_id=1][ref_id=2]. The request is forwarded as `allow` with no WAF log entry [ref_id=1][ref_id=2].
Affected code
The bug is in `pkg/appsec/request.go`, function `NewParsedRequestFromRequest` [ref_id=1][ref_id=2]. The code calls `max(r.ContentLength, 0)` and then `make([]byte, contentLength)`. When Go's `net/http` sets `ContentLength = -1` (for chunked or HTTP/2 requests without a content-length header), `max(-1, 0)` yields 0, producing a zero-length buffer that causes `io.ReadFull` to return immediately without reading the actual body [ref_id=1][ref_id=2].
What the fix does
The patches [patch_id=2749101][patch_id=2749102][patch_id=2749103] address the root cause by replacing the naive `max(r.ContentLength, 0)` approach with proper body reading logic that handles non-positive content lengths. Instead of allocating a buffer based on `ContentLength` (which can be -1), the fix reads the body using a method that correctly handles chunked transfer encoding and HTTP/2 framing. This ensures the actual request body is always read and passed to Coraza for inspection, closing the bypass.
Preconditions
- inputThe request must use Transfer-Encoding: chunked (HTTP/1.1) or omit the content-length header/pseudo-header (HTTP/2)
- authNo authentication required — attacker can be unauthenticated and remote
- configThe CrowdSec AppSec component must be deployed with body-scanning rules enabled (default configuration)
Generated on May 27, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.