VYPR
High severity7.2GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

CrowdSec AppSec silently drops request body for chunked / HTTP-2 requests

CVE-2026-44982

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:

  1. max(-1, 0) evaluates to 0.
  2. make([]byte, 0) allocates a zero-length slice.
  3. io.ReadFull on a zero-length buffer needs zero bytes and returns immediately without touching r.Body.
  4. 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

Patches

3
54a0dfe6c16b

Merge commit from fork

https://github.com/crowdsecurity/crowdsecblotusMay 7, 2026Fixed in 1.7.8via llm-release-walk
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()
    +	}
    +}
    
57a793548671

WAF: enforce body size limitation (#4355)

https://github.com/crowdsecurity/crowdsecblotusApr 28, 2026Fixed in 1.7.8via llm-release-walk
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) },
     	}
     }
     
    
632274597a88

LAPI body limit linting (#4462)

https://github.com/crowdsecurity/crowdsecblotusMay 11, 2026Fixed in 1.7.8via release-tag
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

2

News mentions

0

No linked articles in our index yet.