All posts
CrowdSec AppSec WAF bypass via chunked transfer encoding (CVE-2026-44982)

CrowdSec AppSec WAF bypass via chunked transfer encoding (CVE-2026-44982)

This post documentsCVE-2026-44982Read the full CVE entry →

A web application firewall is a body-inspection engine. The point of putting one in front of an app is that it reads the request body and decides whether to forward it. Anything that lets the body skip the inspection is, by definition, a WAF bypass.

CVE-2026-44982 was that kind of bypass in the CrowdSec AppSec component. Same payload bytes. Same target endpoint. One header swap from Content-Length to Transfer-Encoding: chunked flips the response from 403 Forbidden to 200 OK and lands the payload in the application untouched.

CrowdSec is an open-source security platform that ships an embedded WAF called AppSec, built on top of Coraza. Most deployments put a bouncer (nginx, OpenResty, Traefik) in front of the application, the bouncer mirrors each request to the AppSec listener, AppSec runs the Coraza rules and tells the bouncer to allow or block. Versions 1.5.0 through 1.7.7 of CrowdSec shipped with a body-handling bug in the AppSec listener that made every Coraza rule that looks at the request body fail silently when the request did not carry a positive Content-Length. v1.7.8 ships the fix.

The vulnerable function is NewParsedRequestFromRequest in pkg/appsec/request.go. The relevant lines from the 1.7.7 source:

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)
        }
    }
    // ... body handed off to Coraza ...
}

Go's net/http server populates r.ContentLength from the request framing. For HTTP/1.1 requests that carry a Content-Length header, the value is the integer the header named. For HTTP/1.1 requests that use Transfer-Encoding: chunked instead, and for HTTP/2 requests that arrive without a content-length pseudo-header, r.ContentLength is -1. Content-Length is one way to frame a body (RFC 9112), chunked is the other, and both are valid (RFC 9113 §8.1.1). The Go API uses -1 as the "I do not know how long the body is yet" sentinel.

max(-1, 0) is 0. The backing slice has length zero. io.ReadFull is asked to read zero bytes from r.Body, which it satisfies immediately without draining the stream. The byte slice handed to Coraza for body inspection is empty. Every rule that targets REQUEST_BODY, BODY_ARGS, ARGS_POST, JSON or XML evaluates against nothing and produces no hit. Coraza returns allow. The bouncer forwards the original request, framing and body intact, to the backend.

There is no log entry. From the operator's view, the request was scanned and approved.

The visceral version of the bypass. Stack: the docker-compose example from the maxlerebourg/crowdsec-bouncer-traefik-plugin repo, upgraded to crowdsec:v1.7.7 and plugin v1.5.0, with the default appsec-virtual-patching collection. The rule to trigger is crowdsecurity/vpatch-CVE-2021-3129, the virtual patch for the Laravel Ignition RCE, which matches when a request URL ends in /_ignition/execute-solution and the body contains php:// or phar://.

The payload (186 bytes):

{"solution":"Facade\\Ignition\\Solutions\\MakeViewVariableOptionalSolution","parameters":{"variableName":"username","viewFile":"php://filter/convert.base64-encode/resource=/etc/passwd"}}

Case A (Content-Length, blocked):

curl -sS -i -X POST "http://127.0.0.1:18000/foo/_ignition/execute-solution" \
     -H "Content-Type: application/json" \
     --data "$BODY"
# → HTTP/1.1 403 Forbidden

Case B (Transfer-Encoding: chunked, bypassed):

printf '%s' "$BODY" | curl -sS -i -X POST "http://127.0.0.1:18000/foo/_ignition/execute-solution" \
     -H "Content-Type: application/json" \
     -H "Transfer-Encoding: chunked" \
     --data-binary @-
# → HTTP/1.1 200 OK   (the backend's request-echo handler ran)

Same payload bytes. The only difference between the two requests on the wire is whether the client framed the body with Content-Length or chunked encoding. The WAF stops the first. The WAF does not see the second.

The 1.7.8 fix replaces the buffer-sizing line with a readRequestBody helper. Excerpt:

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
    }
    // bounded read via io.LimitReader; a read timeout is treated as
    // end-of-body so a slow client cannot stall the whole request.
    ...
}

The helper reads up to bodySettings.MaxSize from the stream regardless of what r.ContentLength claims, and applies an operator-configurable action (truncate or drop) when the body exceeds the limit. The 1.7.8 release exposes BodySettings as a new configuration section. The single-line buffer-sizing bug at the top of the file is replaced with a function whose name describes what it does.

The published CVE scores the advisory at CVSS 7.2 (S:C, C:L I:L A:N) under CWE-693, Protection Mechanism Failure. Full metadata is on the CVE-2026-44982 page. The score assumes the AppSec listener is reachable through a bouncer, which is the default deployment shape. The operational impact depends on what is behind the WAF. A virtual patch like CVE-2021-3129 going from "filtered" to "delivered" reverses the WAF's whole reason for existing on that endpoint.

Now the part that does not appear on the CVE record. The same assumption error ("if Content-Length is missing or non-positive, there is no body") shipped in two more components of the CrowdSec ecosystem: an official CrowdSec bouncer and the most widely used third-party Traefik integration. Both were addressed in subsequent releases, with different default behaviour and without CVE assignment.

The first is lua-cs-bouncer, maintained inside the CrowdSec organisation. It is the Lua library that cs-nginx-bouncer and cs-openresty-bouncer both depend on. It calls ngx.req.read_body() and discards the body if it cannot be read. Excerpt from lib/crowdsec.lua:

local function get_body()
    ngx.req.read_body()
    local body = ngx.req.get_body_data()
    if not body then
        -- This means that we will likely miss body, but AFAIK,
        -- there's no workaround for this
        return nil
    end
    return body
end

The comment is honest about what happens. For HTTP/2 requests without an explicit content-length header, ngx_lua's read_body does not return the body, get_body returns nil, and the caller in AppSecCheck builds a GET request with no body to the AppSec listener. AppSec is asked "is this request OK", scans nothing, returns allow.

lua-cs-bouncer v1.0.14 adds an operator-configurable option, APPSEC_DROP_UNREADABLE_BODY. When set to true the bouncer denies the request and returns the configured fallback remediation. The flag defaults to false. Operators have to enable it explicitly for the bouncer to fail closed on HTTP/2 traffic without content-length; without it, an upgrade alone does not close the bypass on a default config.

The second is maxlerebourg/crowdsec-bouncer-traefik-plugin, a third-party project maintained outside the CrowdSec organisation. CrowdSec's own docs link it as the canonical Traefik integration, which is how it ends up in front of so many deployments. Its appsecQuery function gates body forwarding on a predicate that includes httpReq.ContentLength > 0. Chunked HTTP/1.1 has ContentLength == -1. HTTP/2 without content-length has ContentLength == -1 or 0. Either condition fails the predicate, the else branch fires, and the plugin builds http.NewRequest(http.MethodGet, routeURL, nil). The AppSec listener receives a GET with no body and returns allow.

The plugin v1.6.0 drops the ContentLength > 0 predicate. The condition is now appsecBodyLimit > 0 && httpReq.Body != nil, and the body is read with io.LimitReader capped at the configured limit regardless of declared length. Versions 1.5.0 and 1.5.1 remain vulnerable. The v1.6.0 upgrade closes the bypass on default config.

Both bugs end the same way before patching: an attacker request with a body the WAF was supposed to inspect arrives at AppSec stripped of its body, AppSec greenlights it, the bouncer forwards the original (untouched) request to the backend. Packet captures of the plugin-to-AppSec hop confirm this on the wire: the Content-Length case is a POST / with the full malicious body, the chunked case is a GET / with no body, payload identical at the public-facing layer.

The practical gap: operators upgrade based on CVE alerts. The CrowdSec core fix in v1.7.8 surfaces on every WAF dashboard that watches CVE feeds. The bouncer fixes ship as ordinary releases. For a cs-nginx-bouncer or cs-openresty-bouncer deployment the upgrade alone is not enough: the operator also has to set APPSEC_DROP_UNREADABLE_BODY=true in the bouncer configuration. For a Traefik-plugin deployment, upgrading to v1.6.0 closes the bypass on default config. Either way, the fix that matters most for those deployments is the bouncer's, not the core's, and that bouncer fix does not carry a CVE for operators to track.

The finding came from a read-audit of the AppSec listener code, not from fuzzing. The signal in the source was max(r.ContentLength, 0) followed by io.ReadFull on a buffer sized by that integer. Any code path that computes a body buffer size from a client-controlled framing header, then uses ReadFull against a buffer of that size, is implicitly trusting the client to declare the body length. Chunked encoding (HTTP/1.1) and length-less HTTP/2 are the two ways the framing legitimately defers that declaration. A WAF that does not account for either is a WAF that can be skipped.

Reported through GHSA on the three affected repositories. CrowdSec assigned CVE-2026-44982 for the core finding. The bouncer fixes shipped without CVE assignment in lua-cs-bouncer v1.0.14 and maxlerebourg/crowdsec-bouncer-traefik-plugin v1.6.0.

Timeline for CVE-2026-44982. 22 April 2026, vulnerability discovered, reported to CrowdSec through GitHub Security Advisories the same day. 11 May 2026, CrowdSec v1.7.8 released with the patch. 27 May 2026, CVE-2026-44982 published. CVSS 7.2, CWE-693. Five weeks from report to public CVE.

Want me to find issues like this in your app before an attacker does?

Request a pentest
← PreviousFinding CVEs in WordPress: CVE-2026-39534, missing authorisation in WP Directory Kit