VYPR
Medium severity4.6GHSA Advisory· Published May 27, 2026· Updated May 27, 2026

CrowdSec LAPI: Denial of Service via Unbounded Gzip Decompression

CVE-2026-44981

Description

The LAPI router uses gin-contrib/gzip with DefaultDecompressHandle globally (pkg/apiserver/controllers/controller.go). This middleware decompresses incoming request bodies without enforcing a maximum decompressed size.

The endpoints /v1/watchers or /v1/watchers/login require no authentication. An attacker can send small gzip-compressed JSON payloads that, when decompressed, result in hundreds of MB of valid JSON occupying server memory. Sending enough requests concurrently will cause LAPI to allocate excessive heap memory, leading the OS to forcibly terminate the process.

This vulnerability is not exploitable from the network in default configurations, as LAPI only listens on the loopback interface. If developers' applications are using a multi-server setup, LAPI will be exposed in the network, in which case they are at risk if untrusted IPs can access it.

Impact

Exploiting this vulnerability will make LAPI unreachable, meaning that bouncers will not be able to fetch new decisions (but existing decisions will still be enforced) and log processors will not be able to send alerts, effectively denying the creation of new decisions.

Workarounds

If the LAPI is exposed on the network (either directly or through a reverse proxy), for example in the case of a multi-server deployment, restrict access to trusted IP addresses.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

CrowdSec LAPI's unbounded gzip decompression in gin-contrib/gzip middleware allows unauthenticated remote attackers to cause denial of service via small compressed requests.

Vulnerability

The LAPI router in pkg/apiserver/controllers/controller.go uses gin-contrib/gzip with DefaultDecompressHandle globally, which decompresses incoming request bodies without enforcing a maximum decompressed size [1], [2]. This affects all versions using this middleware. The unauthenticated endpoints /v1/watchers and /v1/watchers/login are exposed to this issue.

Exploitation

An attacker sends small gzip-compressed JSON payloads that decompress to hundreds of megabytes of valid JSON. By sending concurrent requests, the attacker forces LAPI to allocate excessive heap memory, leading to OS termination of the process. The vulnerability is exploitable only when LAPI is network-accessible; default configurations bind to loopback, but multi-server setups expose LAPI to the network [1], [2].

Impact

Successful exploitation results in denial of service: LAPI becomes unreachable, preventing bouncers from fetching new decisions and log processors from sending alerts. Existing decisions remain enforced, but no new decisions can be created [1], [2].

Mitigation

As of the advisory publication, no patch is available. The recommended workaround is to restrict network access to trusted IP addresses when LAPI is exposed via a reverse proxy or in multi-server deployments [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
632274597a88

LAPI body limit linting (#4462)

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

Vulnerability mechanics

Root cause

"Missing maximum decompressed size enforcement in gzip middleware allows small compressed payloads to expand into hundreds of MB of memory."

Attack vector

An attacker sends small gzip-compressed JSON payloads that, when decompressed, expand to hundreds of MB of valid JSON [ref_id=1][ref_id=2]. The unauthenticated endpoints `/v1/watchers` or `/v1/watchers/login` accept these payloads [ref_id=1][ref_id=2]. Sending enough concurrent requests causes LAPI to allocate excessive heap memory, leading the OS to forcibly terminate the process [ref_id=1][ref_id=2]. In default configurations LAPI only listens on the loopback interface, so the attack is not exploitable from the network unless the application uses a multi-server setup that exposes LAPI [ref_id=1][ref_id=2].

Affected code

The LAPI router in `pkg/apiserver/controllers/controller.go` uses `gin-contrib/gzip` with `DefaultDecompressHandle` globally [ref_id=1][ref_id=2]. This middleware decompresses incoming request bodies without enforcing a maximum decompressed size.

What the fix does

The patches (commit IDs 54a0dfe6, 57a79354, 63227459) introduce a maximum decompressed size limit on the gzip middleware, preventing unbounded memory allocation from compressed payloads [patch_id=2749104][patch_id=2749105][patch_id=2749106]. This closes the denial-of-service vector by rejecting decompressed data that exceeds the configured threshold.

Preconditions

  • networkLAPI must be exposed on the network (directly or via reverse proxy), e.g. in a multi-server deployment
  • authNo authentication required on /v1/watchers or /v1/watchers/login endpoints

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.