---
id: CVE-2026-41508
aliases:
  - GHSA-r3rm-qphw-hh76
title: >-
  Coraza: Truncated multipart body bypasses MULTIPART_STRICT_ERROR (rule 200003)
  via silent io.ErrUnexpectedEOF handling
summary: >-
  Coraza: Truncated multipart body bypasses MULTIPART_STRICT_ERROR (rule 200003)
  via silent io.ErrUnexpectedEOF handling
severity: medium
cvss: 5.8
cvssVector: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N'
vendor: corazawaf
product: github.com/corazawaf/coraza/v3
ecosystem: go
affected:
  - 'github.com/corazawaf/coraza/v3 >= 3.4.0, < 3.8.0'
patched:
  - github.com/corazawaf/coraza/v3 3.8.0
published: '2026-10-06'
updated: '2026-10-06'
sourceUpdated: '2026-10-06T20:45:03.776574590Z'
source: OSV
sourceUrl: 'https://osv.dev/vulnerability/GHSA-r3rm-qphw-hh76'
references:
  - url: >-
      https://github.com/corazawaf/coraza/security/advisories/GHSA-r3rm-qphw-hh76
  - url: >-
      https://github.com/corazawaf/coraza/commit/f94c81bec209f658120c418448d0590a549b71df
  - url: 'https://github.com/corazawaf/coraza'
  - url: 'https://github.com/corazawaf/coraza/releases/tag/v3.8.0'
  - url: 'https://github.com/advisories/GHSA-r3rm-qphw-hh76'
tags:
  - osv
  - go
  - ghsa
cwe:
  - CWE-20
  - CWE-693
  - CWE-755
ingestedAt: '2026-10-06T21:20:28.990Z'
---

## Overview

## Root Cause

File: `internal/bodyprocessors/multipart.go` (since commit `3347961b`, PR #1453 *"feat: ignore unexpected EOF in MIME multipart request body processor"*, merged 2026-03-06, first shipped in `v3.4.0`).

The multipart body processor treats `io.ErrUnexpectedEOF` as a benign condition. Three sites are affected; all mishandle the error the same way.

### File branch, filesystem-backed (lines 71–77)

```go
sz, err := io.Copy(temp, p)
if err != nil {
    if !errors.Is(err, io.ErrUnexpectedEOF) {
        v.MultipartStrictError().(*collections.Single).Set("1")
        return err
    }
    seenUnexpectedEOF = true     // <-- flag never set for UnexpectedEOF
}
```

### File branch, TinyGo path (lines 82–88)

```go
sz, err := io.Copy(io.Discard, p)
if err != nil {
    if !errors.Is(err, io.ErrUnexpectedEOF) {
        v.MultipartStrictError().(*collections.Single).Set("1")
        return err
    }
    seenUnexpectedEOF = true     // <-- same gap
}
```

### Field branch (lines 102–113)

```go
data, err := io.ReadAll(p)
if err != nil {
    if !errors.Is(err, io.ErrUnexpectedEOF) {
        v.MultipartStrictError().(*collections.Single).Set("1")
        return err
    }
}
...
if errors.Is(err, io.ErrUnexpectedEOF) {
    break                         // <-- exits loop with no flag set
}
```

The function then returns `nil` at line 116 for any body that ended prematurely. Consequences:

1. `MULTIPART_STRICT_ERROR` stays at its initial value `0`.
2. `REQBODY_ERROR` is not propagated either (since `ProcessRequest` returns `nil`).
3. Neither of the two canonical defensive rules shipped in `coraza.conf-recommended` fires:
   ```conf
   SecRule REQBODY_ERROR "!@eq 0" "id:200002,phase:2,deny,status:400,..."
   SecRule MULTIPART_STRICT_ERROR "!@eq 0" "id:200003,phase:2,deny,status:400,..."
   ```

All other error branches in the same function (lines 27, 48, 66, 84, 104) correctly set `MULTIPART_STRICT_ERROR` before returning; this is an inconsistency introduced in #1453, not a systemic issue.

## Context — why the error is swallowed

PR #1453 was introduced to support `SecRequestBodyLimitAction ProcessPartial`: when a body is cut off because it hit the configured request-body limit, the parser should still surface the parts it did receive. The PR legitimately needs to avoid `return err` on `ErrUnexpectedEOF`. But it also silenced the strict-error flag, which is the wrong compromise — the flag is exactly how operators observe that something was incomplete. The fix should keep the non-fatal `break` and still set `MULTIPART_STRICT_ERROR`, letting the operator decide (via rule 200003 or their own policy) whether partial processing is acceptable.

## Impact

Rule 200003 is Coraza's blanket defense against **multipart parser-inconsistency evasions** — attack classes where the body is crafted so Coraza's Go `mime/multipart` reader and the backend's multipart parser (PHP, Node, Java, legacy libmodsecurity, etc.) disagree about where fields start or end. The rule fails-closed on *any* malformed body, so the operator does not need to enumerate every parser-disagreement trick. That defense is now void for any evasion that also truncates the body.

Examples of what becomes reachable:

- Smuggling a second field past the truncation boundary that the backend's more permissive parser still extracts.
- Hiding payload bytes after a deliberately malformed `Content-Disposition` header that the Go parser refuses but the backend accepts.
- Generic CRS evasion chains that depend on rule 200003 as a catch-all.

The fix is tiny and low-risk. The impact is disproportionately large because rule 200003 is the *only* defense-in-depth rule for multipart in the recommended config — there is no secondary signal.

## Proof of Concept

Start a Coraza-wrapped HTTP server shipping the canonical defensive rules from `coraza.conf-recommended`:

```conf
SecRuleEngine On
SecRequestBodyAccess On
SecRule REQBODY_ERROR "!@eq 0" \
    "id:200002,phase:2,t:none,log,deny,status:400,msg:'Failed to parse request body.'"
SecRule MULTIPART_STRICT_ERROR "!@eq 0" \
    "id:200003,phase:2,t:none,log,deny,status:400,msg:'Multipart strict validation failed.'"
```

Three real-curl requests against the listener:

| # | Body | Expected (with rule 200003) | Observed |
|---|---|---|---|
| 1 | Well-formed `field1=benign` + closing boundary | HTTP 200 | HTTP 200 (baseline) |
| 2 | Two parts, **no closing boundary** (`...MALFORMED_NO_TRAILING_BOUNDARY`) | HTTP 400 | **HTTP 200** |
| 3 | Mid-part cutoff: `name="x"\r\n\r\nabc` (no `\r\n`, no boundary) | HTTP 400 | **HTTP 200** |

Server-side match log is empty for cases 2 and 3: neither rule 200002 nor rule 200003 fires. Example (case 2):

```
--PoCBoundary12345\r\n
Content-Disposition: form-data; name="field1"\r\n\r\n
benign\r\n
--PoCBoundary12345\r\n
Content-Disposition: form-data; name="truncated"\r\n\r\n
MALFORMED_NO_TRAILING_BOUNDARY
```
→ `HTTP 200`, no audit record, transaction.variables.multipartStrictError == 0.

## Mitigation

A two-line change per site in `internal/bodyprocessors/multipart.go`:

```go
if errors.Is(err, io.ErrUnexpectedEOF) {
    v.MultipartStrictError().(*collections.Single).Set("1")
    seenUnexpectedEOF = true   // keep existing break semantics
}
```

Apply at the three sites (lines 71–77, 82–88, 102–113 in the current code). No change to the `return err` control flow is needed — the fix only adds the flag-setter alongside the existing `seenUnexpectedEOF = true` / `break` path. PR #1453's ProcessPartial goal is preserved.

Operators running in `ProcessPartial` mode who intentionally allow truncated bodies should pair this with a config-level change (downgrade rule 200003 to detection-only, or scope it with a secondary check on whether `SecRequestBodyLimit` was actually hit). The engine change above is safe by default — it restores the invariant that malformed multipart always raises `MULTIPART_STRICT_ERROR`.

## Affected versions

Introduced in PR #1453 (commit `3347961b`, merged 2026-03-06). First released in `v3.4.0` and still present on `main` at `599ae64a`.

Affected: `>= 3.4.0, <= 3.7.0`.

Releases prior to `v3.4.0` returned `err` on `io.ErrUnexpectedEOF` and so `REQBODY_ERROR` would propagate via rule 200002 even if `MULTIPART_STRICT_ERROR` was not set — a different (arguably stricter) behavior that did not exhibit this bypass.

## References

- `internal/bodyprocessors/multipart.go` lines 70–113
- `coraza.conf-recommended` rule `id:200003` (`MULTIPART_STRICT_ERROR`)
- PR #1453 — introduction of `ErrUnexpectedEOF` swallowing
- `coraza.conf-recommended` rule `id:200002` (`REQBODY_ERROR`) — also not raised because `ProcessRequest` returns `nil`

## Affected packages

- `github.com/corazawaf/coraza/v3 >= 3.4.0, < 3.8.0`

## Remediation

Upgrade to a patched release:

- `github.com/corazawaf/coraza/v3 3.8.0`
