Skip to content

Large octet-stream request bodies are force-parsed into REQUEST_BODY/ARGS and transformed → memory blow-up (SecRequestBodyNoFilesLimit not enforced; forced URLENCODED vs RAW) #1628

Description

@jptosso

Summary

When OWASP CRS is loaded, a large request body with an unknown/binary Content-Type (e.g. application/octet-stream, as used by WebDAV / Nextcloud chunked uploads) is force-loaded into REQUEST_BODY and ARGS_POST and then run through CRS transformation chains. This causes a severe memory blow-up and high latency that scales with body size × number of rules, even though the body is a binary file that should not be inspected as form input.

This diverges from ModSecurity v3 (libmodsecurity), which does not url-decode an octet-stream body into ARGS and which enforces SecRequestBodyNoFilesLimit. The result is that the same CRS config that is safe on ModSecurity exhausts memory on Coraza.

This is the root cause behind corazawaf/coraza-caddy#76 (Caddy + Coraza eating all RAM on Nextcloud sync).

Impact / performance

Reproduced with Caddy + coraza-caddy + CRS (SecRuleEngine On), uploading ~12 MB application/octet-stream chunks (Nextcloud chunked-upload pattern), 12 concurrent:

  • Process RSS: ~56 MB → ~1.9 GB (live heap, captured with pprof -inuse_space, is GC-independent).
  • Per-chunk latency: ~26 s for a 12 MB body.
  • Heap breakdown (pprof): ~94% in strings.(*Builder).WriteString / internal/bytealg.MakeNoZero / internal/transformations.doReplaceComments.

The mechanism is per-transaction: each transformation in a rule's chain allocates a fresh full-size copy of the multi-MB value; CRS applies many transformations across many rules, so the cost is O(body_size × rules) in both CPU and short-lived allocations. The strings.Builder allocations come from io.Copy(&strings.Builder, reader) in the urlencoded/raw body processors pulling the entire buffered body (including any disk/tmpfs spill) back into one contiguous string, which transformations then clone repeatedly.

Reproduction

  1. Build Caddy with coraza-caddy and a standard CRS config (recommended conf + crs-setup + @owasp_crs/*.conf), SecRuleEngine On.
  2. PUT a ≥1 MB body with Content-Type: application/octet-stream to any path.
  3. Observe latency and capture a heap profile (:2019/debug/pprof/heap): transformation allocations dominate.

A self-contained docker repro (Caddy + a fake-Nextcloud backend + k6) is available on request.

Root cause

Two independent divergences from ModSecurity; either one mitigates, both should be addressed.

1. Forced URLENCODED processor for unknown content types

CRS rule 901340 issues ctl:forceRequestBodyVariable=On for any non-URLENCODED/MULTIPART/JSON/XML body. In Coraza this is honored in ProcessRequestBody and defaults the processor to URLENCODED for an empty/unknown RBP (internal/corazawaf/transaction.go, ~L1104-1114):

if tx.ForceRequestBodyVariable {
    if rbp == "" {
        // TODO(4.x): Evaluate if the new RAW body parser fits better than URLENCODED ...
        rbp = "URLENCODED"
    }
    tx.variables.reqbodyProcessor.Set(rbp)
}

The URLENCODED processor (internal/bodyprocessors/urlencoded.go) copies the whole body into REQUEST_BODY and ParseQuerys it into ARGS_POST — so a binary body is double-stored and parsed into garbage args, all of which then carry transformations.

In ModSecurity, ctl:forceRequestBodyVariable is a documented no-op (never implemented), and extractArguments("POST", ...) runs only for WWWFormUrlEncoded — octet-stream is never parsed into ARGS.

This is the same concern already noted in #938.

2. SecRequestBodyNoFilesLimit is parsed but never enforced

The directive is accepted and stored but no logic uses it (internal/corazawaf/waf.go):

// TODO: SecRequestBodyNoFilesLimit directive is retrieving the value, but no logic
// based on it is implemented. See https://github.com/corazawaf/coraza/issues/896
RequestBodyNoFilesLimit int64

In ModSecurity this limit (recommended 131072) is enforced before the body processor runs (src/transaction.cc), capping the inspected non-file body and skipping parsing when exceeded. Coraza has no such cap, so the full body (default SecRequestBodyLimit is 128 MB in Coraza vs ModSecurity's recommended 13 MB) is parsed and transformed.

This is #896.

ModSecurity comparison (octet-stream body, SecRequestBodyAccess On)

ModSecurity v3 Coraza v3.7.0
processor for octet-stream none (UnknownFormat) none by default
ctl:forceRequestBodyVariable no-op forces URLENCODED
REQUEST_BODY raw string, not parsed whole body copied in
ARGS_POST from raw body not populated populated via ParseQuery
SecRequestBodyNoFilesLimit enforced (128 KB) parsed but not enforced

Note: multipart file uploads are not the problem — both engines route file part content to a temp file / FILES metadata and never run file bytes through transformations. The issue is specific to the raw octet-stream path that CRS forces into a transformable variable.

Proposed fixes (open to discussion)

  1. Enforce SecRequestBodyNoFilesLimit (Support SecRequestBodyNoFilesLimit directive #896): in ProcessRequestBody, for non-multipart bodies, if the buffered length exceeds the limit, skip the body processor (mirroring ModSecurity). The field already exists.
  2. Default the forced processor to RAW instead of URLENCODED for unknown content types (Raw body processor #938): RAW sets only REQUEST_BODY and avoids the bogus ARGS_POST duplication.
  3. Bound transformations by size, not inspection (preferred safeguard): for body variables above a configurable threshold, skip the transformation chain but still let operators (e.g. @detectSQLi/@detectXSS, which do not require transformations) evaluate the raw value. This bounds memory while preserving content-agnostic detection — strictly better than skipping inspection entirely.

A combination of (2) + (3) keeps the body inspectable (no REQUEST_BODY error, uploads are not blocked) while eliminating the allocation blow-up. (1) is the most faithful to ModSecurity but, depending on CRS policy around REQBODY_ERROR, can affect whether oversized bodies are blocked — so its disruptive behavior should be deliberate/configurable.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions