Root Cause
File: internal/corazawaf/transaction.go, lines 834–866.
parsedURL, err := url.ParseRequestURI(uri)
query := ""
if err != nil {
tx.variables.urlencodedError.Set(err.Error())
path = uri
tx.variables.requestURI.Set(uri)
/*
tx.Variables.VARIABLE_URI_PARSE_ERROR.Set("1")
posRawQuery := strings.Index(uri, "?")
if posRawQuery != -1 {
tx.ExtractArguments("GET", uri[posRawQuery+1:])
path = uri[:posRawQuery]
query = uri[posRawQuery+1:]
} else {
path = uri
}
tx.Variables.RequestUri.Set(uri)
*/
} else {
tx.ExtractGetArguments(parsedURL.RawQuery) // only path that populates ARGS_GET
tx.variables.requestURI.Set(parsedURL.String())
path = parsedURL.Path
query = parsedURL.RawQuery
}
...
tx.variables.queryString.Set(query)
When url.ParseRequestURI(uri) returns an error — which Go's stdlib does for any URI containing raw control bytes (\x00, \n, \r, \t, other 0x00–0x1F, 0x7F) — the error branch silently produces an empty QUERY_STRING and an empty ARGS_GET collection. The fallback logic that should split on ? and populate the GET arguments from the raw tail is already present in the source as a commented-out block, referencing a VARIABLE_URI_PARSE_ERROR variable that was never wired up.
Consequences on the error branch:
ARGS_GET / ARGS_GET_NAMES / ARGS (union) are empty — ExtractGetArguments is never called.
QUERY_STRING is empty (initial query := "" at line 835 persists through to queryString.Set(query) at line 866).
REQUEST_FILENAME / REQUEST_BASENAME contain the entire URI including any ?… query suffix (because path = uri at line 838 bypasses the parse, and the subsequent strings.LastIndexAny(path, "/\\") runs over the raw URI).
URLENCODED_ERROR is set to the Go error message. That variable is also set by the urlencoded body processor on body-decode failures, so an operator cannot distinguish "malformed URI" from "malformed request body" without string-matching the error text.
REQUEST_URI_RAW (set unconditionally at line 822, before the parse) is populated correctly.
Any rule targeting ARGS_GET, ARGS, ARGS_NAMES, ARGS_GET_NAMES, or QUERY_STRING — which is the default target set for the vast majority of OWASP CRS GET-side signature rules — does not fire against attacker content that reaches Coraza via a URI Go's net/url rejects.
Reachability
This issue does not affect the standard coraza/v3/http + net/http integration. Go's http.ReadRequest calls url.ParseRequestURI first and rejects malformed URIs with 400 Bad Request before ProcessURI is invoked. Verified experimentally against a Coraza-wrapped net/http server — a raw request with a control-byte-laced URI produced HTTP 400, and the handler was never reached.
The bug is reachable when an integration forwards raw URI bytes to tx.ProcessURI directly, bypassing Go's HTTP parser:
coraza-spoa — HAProxy SPOP agent. Receives URI from HAProxy, which permits bytes net/http rejects.
coraza-proxy-wasm — Envoy WASM filter. Passes the :path pseudo-header from Envoy.
- Custom FFI/WASM hosts and any embedder calling
tx.ProcessURI(rawURI, method, httpVersion) with bytes not pre-validated by Go's URL parser.
This gates the attack to Attack Complexity:High — a standard Go HTTP deployment is not exposed.
Proof of Concept
Direct-API reproduction (simulating the non-net/http integration path):
waf, _ := coraza.NewWAF(coraza.NewWAFConfig().WithDirectives(`
SecRuleEngine On
SecRule ARGS_GET "@contains ATTACK_HERE_XYZ" "id:9001,phase:1,deny,status:403"
SecRule QUERY_STRING "@contains ATTACK_HERE_XYZ" "id:9002,phase:1,deny,status:403"
`))
for _, uri := range []string{
"/search?q=ATTACK_HERE_XYZ", // baseline
"/search?q=ATTACK_HERE_XYZ\x00&y=1", // NUL byte
"/search?q=ATTACK_HERE_XYZ\ninjected: header", // bare LF
"/search?q=ATTACK_HERE_XYZ\rhdr: x", // bare CR
"/search?q=ATTACK_HERE_XYZ\tx=1", // tab
} {
tx := waf.NewTransaction()
tx.ProcessURI(uri, "GET", "HTTP/1.1")
it := tx.ProcessRequestHeaders()
// inspect tx.Variables().QueryString().Get() and tx.Variables().ArgsGet().FindAll()
tx.Close()
}
Observed:
| URI |
QUERY_STRING |
ARGS_GET |
interrupted? |
/search?q=ATTACK_HERE_XYZ |
q=ATTACK_HERE_XYZ |
1 entry |
yes (403) |
/search?q=ATTACK_HERE_XYZ\x00&y=1 |
"" |
0 entries |
no — BYPASS |
/search?q=ATTACK_HERE_XYZ\ninjected: header |
"" |
0 entries |
no — BYPASS |
/search?q=ATTACK_HERE_XYZ\rhdr: x |
"" |
0 entries |
no — BYPASS |
/search?q=ATTACK_HERE_XYZ\tx=1 |
"" |
0 entries |
no — BYPASS |
REQUEST_URI_RAW is populated correctly in every case (line 822 sets it before the parse), so a rule written against REQUEST_URI_RAW still catches the attack. CRS and most operator-written rules target ARGS_GET / ARGS / QUERY_STRING — those do not fire.
HTTP-layer reachability check (stock net/http):
$ printf 'GET /?q=ATTACK_HERE_XYZ\x00&y=1 HTTP/1.1\r\nHost: x\r\n\r\n' | nc 127.0.0.1 8092
HTTP/1.1 400 Bad Request
Confirms the exposure is limited to non-net/http integrations.
Mitigation
Recommended fixes, in order:
1. Re-enable the existing fallback and wire up URI_PARSE_ERROR
The code to fix this is already present as a commented-out block at transaction.go:840–851. Re-enable it, promote the referenced VARIABLE_URI_PARSE_ERROR to a real transaction variable, and populate ARGS_GET / QUERY_STRING from the raw ?… tail:
if err != nil {
tx.variables.urlencodedError.Set(err.Error())
tx.variables.uriParseError.Set("1") // new variable
tx.variables.requestURI.Set(uri)
if i := strings.Index(uri, "?"); i != -1 {
path = uri[:i]
query = uri[i+1:]
tx.ExtractGetArguments(query) // populate ARGS_GET
} else {
path = uri
}
} else {
...
}
2. Ship a companion rule in coraza.conf-recommended
SecRule URI_PARSE_ERROR "@eq 1" \
"id:'200010',phase:1,t:none,log,deny,status:400,msg:'URI failed to parse'"
This gives operators a fail-closed default (analogous to rule 200003 for multipart strict error and rule 200002 for body-parse error), so non-net/http integrations at least stop the request regardless of downstream rule coverage.
3. Do not overload URLENCODED_ERROR
The current code uses URLENCODED_ERROR for URI parse failures. That variable is also set by the urlencoded body processor on body-decode errors; operators cannot distinguish the two causes without string-matching the error text, and any rule they add will fire on both classes of failure. A dedicated URI_PARSE_ERROR variable (per the commented-out TODO) is the right shape.
Affected versions
All releases on the v3 branch (>= 3.0.0, <= 3.7.0); the silent-drop behavior has been present since the first v3 release. Only deployments using non-net/http integrations (coraza-spoa, coraza-proxy-wasm, custom FFI) are exposed in practice.
References
internal/corazawaf/transaction.go lines 834–866 (ProcessURI error branch)
internal/corazawaf/transaction.go line 822 (REQUEST_URI_RAW is populated before the parse, which is why REQUEST_URI_RAW-targeted rules still catch the attack)
- Commented-out fallback at lines 840–851 referencing
VARIABLE_URI_PARSE_ERROR
- CWE-20 — Improper Input Validation
- CWE-436 — Interpretation Conflict
Severity (revised 2026-10-02)
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N (4.0, Medium).
Attack Complexity stays High: the bypass only applies to integrations that pass Coraza a raw URI that Go's URL parser rejects, which net/http does not. The previous vector scored Integrity High (6.8); it is scored here like Coraza's other inspection bypasses.
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.
Root Cause
File:
internal/corazawaf/transaction.go, lines 834–866.When
url.ParseRequestURI(uri)returns an error — which Go's stdlib does for any URI containing raw control bytes (\x00,\n,\r,\t, other0x00–0x1F,0x7F) — the error branch silently produces an emptyQUERY_STRINGand an emptyARGS_GETcollection. The fallback logic that should split on?and populate the GET arguments from the raw tail is already present in the source as a commented-out block, referencing aVARIABLE_URI_PARSE_ERRORvariable that was never wired up.Consequences on the error branch:
ARGS_GET/ARGS_GET_NAMES/ARGS(union) are empty —ExtractGetArgumentsis never called.QUERY_STRINGis empty (initialquery := ""at line 835 persists through toqueryString.Set(query)at line 866).REQUEST_FILENAME/REQUEST_BASENAMEcontain the entire URI including any?…query suffix (becausepath = uriat line 838 bypasses the parse, and the subsequentstrings.LastIndexAny(path, "/\\")runs over the raw URI).URLENCODED_ERRORis set to the Go error message. That variable is also set by the urlencoded body processor on body-decode failures, so an operator cannot distinguish "malformed URI" from "malformed request body" without string-matching the error text.REQUEST_URI_RAW(set unconditionally at line 822, before the parse) is populated correctly.Any rule targeting
ARGS_GET,ARGS,ARGS_NAMES,ARGS_GET_NAMES, orQUERY_STRING— which is the default target set for the vast majority of OWASP CRS GET-side signature rules — does not fire against attacker content that reaches Coraza via a URI Go'snet/urlrejects.Reachability
This issue does not affect the standard
coraza/v3/http+net/httpintegration. Go'shttp.ReadRequestcallsurl.ParseRequestURIfirst and rejects malformed URIs with400 Bad RequestbeforeProcessURIis invoked. Verified experimentally against a Coraza-wrappednet/httpserver — a raw request with a control-byte-laced URI producedHTTP 400, and the handler was never reached.The bug is reachable when an integration forwards raw URI bytes to
tx.ProcessURIdirectly, bypassing Go's HTTP parser:coraza-spoa— HAProxy SPOP agent. Receives URI from HAProxy, which permits bytesnet/httprejects.coraza-proxy-wasm— Envoy WASM filter. Passes the:pathpseudo-header from Envoy.tx.ProcessURI(rawURI, method, httpVersion)with bytes not pre-validated by Go's URL parser.This gates the attack to Attack Complexity:High — a standard Go HTTP deployment is not exposed.
Proof of Concept
Direct-API reproduction (simulating the non-net/http integration path):
Observed:
QUERY_STRINGARGS_GET/search?q=ATTACK_HERE_XYZq=ATTACK_HERE_XYZ/search?q=ATTACK_HERE_XYZ\x00&y=1""/search?q=ATTACK_HERE_XYZ\ninjected: header""/search?q=ATTACK_HERE_XYZ\rhdr: x""/search?q=ATTACK_HERE_XYZ\tx=1""REQUEST_URI_RAWis populated correctly in every case (line 822 sets it before the parse), so a rule written againstREQUEST_URI_RAWstill catches the attack. CRS and most operator-written rules targetARGS_GET/ARGS/QUERY_STRING— those do not fire.HTTP-layer reachability check (stock
net/http):Confirms the exposure is limited to non-net/http integrations.
Mitigation
Recommended fixes, in order:
1. Re-enable the existing fallback and wire up
URI_PARSE_ERRORThe code to fix this is already present as a commented-out block at
transaction.go:840–851. Re-enable it, promote the referencedVARIABLE_URI_PARSE_ERRORto a real transaction variable, and populateARGS_GET/QUERY_STRINGfrom the raw?…tail:2. Ship a companion rule in
coraza.conf-recommendedThis gives operators a fail-closed default (analogous to rule 200003 for multipart strict error and rule 200002 for body-parse error), so non-net/http integrations at least stop the request regardless of downstream rule coverage.
3. Do not overload
URLENCODED_ERRORThe current code uses
URLENCODED_ERRORfor URI parse failures. That variable is also set by the urlencoded body processor on body-decode errors; operators cannot distinguish the two causes without string-matching the error text, and any rule they add will fire on both classes of failure. A dedicatedURI_PARSE_ERRORvariable (per the commented-out TODO) is the right shape.Affected versions
All releases on the v3 branch (
>= 3.0.0, <= 3.7.0); the silent-drop behavior has been present since the first v3 release. Only deployments using non-net/http integrations (coraza-spoa, coraza-proxy-wasm, custom FFI) are exposed in practice.References
internal/corazawaf/transaction.golines 834–866 (ProcessURI error branch)internal/corazawaf/transaction.goline 822 (REQUEST_URI_RAWis populated before the parse, which is whyREQUEST_URI_RAW-targeted rules still catch the attack)VARIABLE_URI_PARSE_ERRORSeverity (revised 2026-10-02)
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N(4.0, Medium).Attack Complexity stays High: the bypass only applies to integrations that pass Coraza a raw URI that Go's URL parser rejects, which
net/httpdoes not. The previous vector scored Integrity High (6.8); it is scored here like Coraza's other inspection bypasses.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:Limpact convention and directed this update.