Skip to content

URL-encoded form Content-Type parameters bypass Coraza body inspection

Moderate
fzipi published GHSA-w253-m66g-rx24 Oct 2, 2026

Package

gomod github.com/corazawaf/coraza/v3 (Go)

Affected versions

>= 3.0.4, < 3.8.1

Patched versions

3.8.1

Description

Summary

Coraza fails to inspect URL-encoded form bodies when their valid Content-Type contains a media-type parameter, for example:

Content-Type: application/x-www-form-urlencoded; charset=UTF-8

An unauthenticated attacker can use this header to hide the complete form body from custom Coraza rules that inspect ARGS_POST or REQUEST_BODY, while the bundled Go HTTP middleware forwards the body and the backend parses it normally.

This is a deterministic request-body inspection bypass. Suggested severity: Medium. Current OWASP CRS includes rule 901340, which forces fallback inspection and mitigates this path; this report does not claim a bypass of an unmodified current CRS ruleset

Details

The affected component is request-body processor selection in internal/corazawaf/transaction.go.

Transaction.AddRequestHeader compares the complete lowercased header value using exact equality:

case "content-type":
    val := strings.ToLower(value)
    if val == "application/x-www-form-urlencoded" {
        tx.variables.reqbodyProcessor.Set("URLENCODED")
    } else if strings.HasPrefix(val, "multipart/form-data") {
        tx.variables.reqbodyProcessor.Set("MULTIPART")
    }

Source: internal/corazawaf/transaction.go, lines 383-390.

The media type of application/x-www-form-urlencoded; charset=UTF-8 remains application/x-www-form-urlencoded; charset is a parameter. Because Coraza compares the entire header, the comparison fails and REQBODY_PROCESSOR remains empty.

ProcessRequestBody treats the empty processor as success:

rbp = strings.ToLower(rbp)
if rbp == "" {
    tx.WAF.Rules.Eval(types.PhaseRequestBody, tx)
    return tx.interruption, nil
}

Source: internal/corazawaf/transaction.go, lines 1115-1120.

Coraza therefore evaluates phase 2 without parsing the body and without setting REQBODY_ERROR. The URL-encoded processor that normally populates ARGS_POST, REQUEST_BODY, and REQUEST_BODY_LENGTH is never invoked.

The bundled middleware buffers and reconstructs the full request body before invoking the backend. The backend consequently receives data that was absent from Coraza's body variables.

The issue exists in the standard compiled implementation; no special build tag is required. Request-body access defaults to Off in an empty configuration, but Coraza's recommended configuration enables it with SecRequestBodyAccess On.

PoC

Save this self-contained test as parameterized_form_bypass_test.go in the Coraza repository root:

package coraza_test

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"

    coraza "github.com/corazawaf/coraza/v3"
    corazahttp "github.com/corazawaf/coraza/v3/http"
)

func TestParameterizedFormInspectionBypass(t *testing.T) {
    cfg := coraza.NewWAFConfig().WithDirectives(`
SecRuleEngine On
SecRequestBodyAccess On
SecRule ARGS_POST:cmd "@streq evil" "id:910001,phase:2,deny,status:403,t:none"
`)

    waf, err := coraza.NewWAF(cfg)
    if err != nil {
        t.Fatal(err)
    }

    backendSaw := ""
    handler := corazahttp.WrapHandler(waf, http.HandlerFunc(
        func(w http.ResponseWriter, r *http.Request) {
            if err := r.ParseForm(); err != nil {
                t.Fatal(err)
            }
            backendSaw = r.PostForm.Get("cmd")
            w.WriteHeader(http.StatusNoContent)
        },
    ))

    req := httptest.NewRequest(
        http.MethodPost,
        "http://example.test/",
        strings.NewReader("cmd=evil"),
    )
    req.Header.Set(
        "Content-Type",
        "application/x-www-form-urlencoded; charset=UTF-8",
    )

    rec := httptest.NewRecorder()
    handler.ServeHTTP(rec, req)

    if rec.Code != http.StatusNoContent {
        t.Fatalf("expected request to reach backend, status=%d", rec.Code)
    }
    if backendSaw != "evil" {
        t.Fatalf("backend did not receive malicious value: %q", backendSaw)
    }
}

Run:

go test . -run TestParameterizedFormInspectionBypass -v

Observed:

=== RUN   TestParameterizedFormInspectionBypass
--- PASS: TestParameterizedFormInspectionBypass (0.00s)
PASS

The test passes only when Coraza fails to return its configured 403 response and the backend parses cmd=evil.

As a control, change the header to:

Content-Type: application/x-www-form-urlencoded

Coraza then selects the URL-encoded processor, exposes cmd=evil as ARGS_POST:cmd, and returns 403 before the backend runs.

Impact

This is a parser differential and request-body inspection bypass affecting applications protected by custom Coraza rules.

An unauthenticated attacker can add charset=UTF-8 to an ordinary form request. Coraza then runs phase-2 rules without the submitted parameters and reports no body-processing failure, while the application receives and processes those parameters.

Rules relying on these variables are affected on this path:

  • ARGS_POST
  • ARGS for POST-derived values
  • ARGS_POST_NAMES
  • ARGS_NAMES
  • REQUEST_BODY
  • REQUEST_BODY_LENGTH

This defeats custom rules intended to reject injection strings, dangerous commands, or forbidden business values in form fields. The final application impact depends on the backend behavior that the bypassed rule was intended to protect.

Affected operators are those who enable request-body access and rely on automatic URL-encoded parsing without current CRS rule 901340, ctl:forceRequestBodyVariable=On, an explicitly forced processor, or equivalent backend validation.

The fix is to parse Content-Type according to MIME syntax and compare the normalized media type without parameters, for example with Go's mime.ParseMediaType. If an expected body has no processor, Coraza should expose an explicit processing error instead of silently evaluating phase 2 with empty variables.

Follow-up (2026-09-30): duplicate Content-Type headers desynchronize processor selection from the parsed body

The original fix made body-processor selection tolerant of media-type parameters (charset=UTF-8 etc.) by switching from exact equality to strings.HasPrefix. A second, distinct gap was found while reviewing that same selection code: it never accounted for a request carrying more than one Content-Type header.

Root cause

Transaction.AddRequestHeader is called once per header by the integrator (documented contract). Its content-type case runs on every call:

case "content-type":
    val := strings.ToLower(value)
    if strings.HasPrefix(val, "application/x-www-form-urlencoded") {
        tx.variables.reqbodyProcessor.Set("URLENCODED")
    } else if strings.HasPrefix(val, "multipart/form-data") {
        tx.variables.reqbodyProcessor.Set("MULTIPART")
    }

A request with two Content-Type headers therefore has its body-processor selection silently overwritten by the last one, since each call unconditionally calls Set. Meanwhile, ProcessRequestBody extracts the mimeType it passes to whichever processor gets selected from requestHeaders.Get("content-type")[0] -- the first value (internal/corazawaf/transaction.go, a few hundred lines below AddRequestHeader) -- and Go's net/http Header.Get (what a typical backend uses) also returns only the first value. So selection follows the last header while everything else follows the first, and the two can disagree entirely.

PoC

Content-Type: multipart/form-data; boundary=XyZ
Content-Type: application/x-www-form-urlencoded

with a genuine multipart body containing cmd=evil and a shell.php file upload. Against commit 19b86824:

  • reqbodyProcessor resolves to "URLENCODED" (the second, spoofed header wins).
  • The URL-encoded processor runs against the raw multipart body text, parses without error, and produces nothing.
  • ARGS_POST:cmd, FILES, and FILES_NAMES are all empty -- the entire payload is invisible to any rule inspecting those variables.
  • The backend (or any integrator using Header.Get, which reads the first value) still sees Content-Type: multipart/form-data and parses cmd=evil and the uploaded shell.php normally.

Current OWASP CRS includes rule 920620 (Content-Type header sanity check via count/format), which catches this shape; the recommended coraza.conf-recommended has no equivalent, so a Coraza deployment with only custom rules (no CRS) is exposed.

Fix

Only the first Content-Type header may set reqbodyProcessor: guard the existing logic with if tx.variables.reqbodyProcessor.Get() == "". This makes header-driven processor selection consistent with ProcessRequestBody's own mimeType extraction and with standard Header.Get semantics, without a new field (nothing else can set reqbodyProcessor before headers finish processing; ctl:requestBodyProcessor and ForceRequestBodyVariable both run afterward, in phase 1 rule evaluation and body processing respectively, and are unaffected).

As defense in depth, a rule author can also detect the anomaly directly today, with no code change: SecRule &REQUEST_HEADERS:Content-Type "@gt 1" "deny,..." -- left as a suggested addition to coraza.conf-recommended rather than bundled into this fix, since it is a policy choice (deny vs. flag) rather than a correctness requirement.

AI involvement disclosure

  • AI tools/models used: Claude Sonnet 5 (Anthropic), via Claude Code.
  • What was generated/assisted: the vulnerability hypothesis and repro shape were supplied by the reporter as an existing written finding; Claude Sonnet 5 independently re-derived the root cause by reading the current source, traced the mimeType/Header.Get first-value asymmetry, wrote and ran a fresh PoC against commit 19b86824 confirming the full bypass (ARGS_POST/FILES/FILES_NAMES all empty), verified the fix closes it, and drafted this addendum.
  • Review performed: reproduced by hand against a clean checkout of commit 19b86824 before and after the fix using the real Transaction API (AddRequestHeader x2, WriteRequestBody, ProcessRequestBody); confirmed the regression test fails deterministically against the pre-fix code and passes after it; ran the full test suite, the build-tag matrix (coraza.no_memoize, coraza.rule.multiphase_evaluation, coraza.rule.no_regex_multiline), and the testing/coreruleset CRS regression suite, all green; reviewed by a human maintainer (fzipi) before this addendum was submitted.

Fix: https://github.com/corazawaf/coraza-ghsa-w253-m66g-rx24/pull/2

Patched in 3.8.1

The 3.8.0 fix was incomplete. 3.8.1 completes it: only the first Content-Type header now selects the body processor (the one a typical backend reads with Header.Get), and leading whitespace in the media type, including Unicode whitespace that net/http accepts, is trimmed the way mime.ParseMediaType trims it. Upgrade to 3.8.1; 3.8.0 is listed as affected.

Severity (revised 2026-10-02)

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N (5.8, Medium).

Attack Complexity is Low: every form and multipart parser accepts these Content-Type values. The previous vector used Scope Unchanged (5.3).

Impact metrics follow the convention used across Coraza's WAF-bypass advisories: the vulnerable component is Coraza, but the impact lands on the protected application, so Scope is Changed. The bypass hides a payload from inspection; the application still has to be vulnerable to it, so Integrity is Low and Confidentiality is not scored separately.

AI involvement in this section: Claude Opus 5.5 (Anthropic), via Claude Code, re-derived the CVSS vector from the project's triage guidance (AGENTS.md, "CVSS preconditions get verified, not copied from the report") and drafted this text. A human maintainer (fzipi) chose the S:C/I:L impact convention and directed this update.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Changed
Confidentiality
None
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N

CVE ID

No known CVE

Weaknesses

Improper Input Validation

The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. Learn more on MITRE.

Interpretation Conflict

Product A handles inputs or steps differently than Product B, which causes A to perform incorrect actions based on its perception of B's state. Learn more on MITRE.

Credits