Root cause
Incomplete cap misses array-length entries
The readItems callback checks argumentLimit for array-length writes but not byteBudget, so each nesting level can add an argument while repeating the full path past the intended cap.
Provisional finding. Full causal and release verification remains incomplete.
From 3.0.1, human-written code discarded request arguments over SecArgumentsLimit without flagging them, so a padded request hid a payload from ARGS rules. The 3.8.0 remediation, whose commits all carry Claude co-author trailers, still let JSON array-length entries exceed the budget. 3.8.1 closes both.
AI-assisted change
The AI co-authored 3.8.0 remediation adds argument-limit handling and an ArgumentsLimitReached flag, but it does not fully close the pre-existing silent-drop flaw or account for array-length entries.
Root cause
The readItems callback checks argumentLimit for array-length writes but not byteBudget, so each nesting level can add an argument while repeating the full path past the intended cap.
Fix
The security fix checks both argumentLimit and byteBudget before writing an array-length entry, preventing post-iteration writes from bypassing the cap.
Case facts
Cite this finding
Vibe Security Radar, "CVE-2026-41510 in corazawaf/coraza," Georgia Tech SSLab, https://vibesecradar.com/cves/CVE-2026-41510/
@@ -12,6 +12,7 @@ import ( "github.com/tidwall/gjson" "github.com/corazawaf/coraza/v3/experimental/plugins/plugintypes"+ "github.com/corazawaf/coraza/v3/internal/collections" ) type jsonBodyProcessor struct{}@@ -29,11 +30,14 @@ func (js *jsonBodyProcessor) ProcessRequest(reader io.Reader, v plugintypes.Tran ss := s.String() // Process with recursion limit col := v.ArgsPost()- data, err := readJSON(ss, bpo.RequestBodyRecursionLimit)+ data, truncated, err := readJSON(ss, bpo.RequestBodyRecursionLimit, bpo.ArgumentLimit) // The collection is populated before checking the error to still perform a best effort inspection of the payload for key, value := range data { col.SetIndex(key, 0, value) }+ if truncated {+ v.ArgumentsLimitReached().(*collections.Single).Set("1")+ } if err != nil { return err }@@ -50,7 +54,7 @@ func (js *jsonBodyProcessor) ProcessRequest(reader io.Reader, v plugintypes.Tran const ignoreJSONRecursionLimit = -1 -func (js *jsonBodyProcessor) ProcessResponse(reader io.Reader, v plugintypes.TransactionVariables, _ plugintypes.BodyProcessorOptions) error {+func (js *jsonBodyProcessor) ProcessResponse(reader io.Reader, v plugintypes.TransactionVariables, bpo plugintypes.BodyProcessorOptions) error { // Read the entire body to store it and process it s := strings.Builder{} if _, err := io.Copy(&s, reader); err != nil {@@ -59,11 +63,14 @@ func (js *jsonBodyProcessor) ProcessResponse(reader io.Reader, v plugintypes.Tra ss := s.String() // Process with no recursion limit as we don't have a directive for response body col := v.ResponseArgs()- data, err := readJSON(ss, ignoreJSONRecursionLimit)+ data, truncated, err := readJSON(ss, ignoreJSONRecursionLimit, bpo.ArgumentLimit) // The collection is populated before checking the error to still perform a best effort inspection of the payload for key, value := range data { col.SetIndex(key, 0, value) }+ if truncated {+ v.ArgumentsLimitReached().(*collections.Single).Set("1")+ } if err != nil { return err }@@ -78,19 +85,54 @@ func (js *jsonBodyProcessor) ProcessResponse(reader io.Reader, v plugintypes.Tra return nil } -func readJSON(s string, maxRecursion int) (map[string]string, error) {- res := make(map[string]string)+// readJSON flattens s into a map[string]string, stopping once argumentLimit+// entries have been collected (argumentLimit <= 0 means no limit). Without+// this, a small body decoding to a wide flat structure (e.g. a JSON array of+// millions of scalars) grows this map -- and, through it, ARGS_POST/+// RESPONSE_ARGS -- without bound regardless of SecArgumentsLimit, exhausting+// memory on a single request. See GHSA-3ww9-vw83-9w5x.+// flattenBytesFactor bounds the flattened form relative to the body that+// produced it. SecArgumentsLimit counts entries, not bytes, and the flattened+// key is the full path rewritten for every leaf, so memory grows with+// depth x leaves while the entry count grows only with leaves. A body that+// stays comfortably under the argument limit can therefore still retain many+// times its own size: a 34 KB body of long paths measured 1.6 MB of keys at+// 999 arguments, roughly 48x, with no limit reached and no flag set.+//+// The factor is a judgement call rather than a derived number. Eight leaves+// room for genuinely nested documents -- measured well under 2x on ordinary+// API payloads -- while stopping the amplification well before it matters.+const flattenBytesFactor = 8++// flattenBytesFloor keeps small bodies unaffected, where a few long keys can+// legitimately outweigh the input.+const flattenBytesFloor = 4096++func readJSON(s string, maxRecursion int, argumentLimit int) (res map[string]string, truncated bool, err error) {+ res = make(map[string]string) key := []byte("json") + byteBudget := len(s) * flattenBytesFactor+ if byteBudget < flattenBytesFloor {+ byteBudget = flattenBytesFloor+ }++ // The walk runs before the validity check on purpose: a body with a valid+ // prefix still populates ARGS on a best-effort basis, which rules can act+ // on even though an error is returned (see the best-effort tests, and@@ -717,4 +717,7 @@ const ( // url.ParseRequestURI (e.g. it contains raw control bytes). QUERY_STRING // and ARGS_GET are populated from a best-effort split on "?" in that case. URIParseError+ // ArgumentsLimitReached is set to 1 when the number of arguments+ // exceeds the configured SecArgumentsLimit and arguments were dropped.+ ArgumentsLimitReached )@@ -296,18 +296,21 @@ func readItems(json gjson.Result, objKey []byte, maxRecursion int, argumentLimit }) if arrayLen > 0 { // This write happens after ForEach has returned, so neither guard- // inside the callback covers it. Without the check an entry escapes- // the cap for every array level -- 1024 nested arrays in a 2 KB body- // yield 1025 arguments -- and, because the flag stayed false, the- // deny rule never fires and the request is allowed.+ // inside the callback covers it. It needs both: argumentLimit, since+ // every array level adds an entry, and byteBudget, since each of those+ // entries repeats the full path. See GHSA-6r3q-mjv7-xr8m. if argumentLimit > 0 && *argCount >= argumentLimit { iterationTruncated = true } else {- k := string(objKey) lenStr := strconv.Itoa(arrayLen)- res[k] = append(res[k], lenStr)- *usedBytes += len(objKey) + len(lenStr)- *argCount+++ if byteBudget > 0 && *usedBytes+len(objKey)+len(lenStr) > byteBudget {+ iterationTruncated = true+ } else {+ k := string(objKey)+ res[k] = append(res[k], lenStr)+ *usedBytes += len(objKey) + len(lenStr)+ *argCount+++ } } } return iterationTruncated, iterationErrorAI-assisted change 72d994e7ea2f59818ef064a101ead4270ec972b2bddc9b494163e14c79ddb559 · Fix c938ddd43d524d339a5c36448a24d7dd4b15d33807799967500fb003dccebd3f