{"id":"GHSA-g4qm-m288-5cp9","summary":"Coraza has Cookie Parser Confusion","details":"## Summary\nCoraza's cookie parser (`internal/cookies.ParseCookies`) does not strip ASCII control characters (CTLs) from the edges of a cookie name/value before they're matched against `REQUEST_COOKIES` / `REQUEST_COOKIES_NAMES`. When a CTL sits directly next to the `=` separator, Coraza absorbs it into the adjacent name or value, while several backend cookie parsers trim it away — so the WAF and the application disagree about the cookie it just received.\n\n## Root cause\n- `ParseCookies` (`internal/cookies/cookies.go:17,26,31`) trims via `net/textproto.TrimString`, which strips only space (`0x20`) and tab (`0x09`).\n- RFC 6265 §4.1.1 defines cookie `name` as an HTTP `token` and `value` as `cookie-octet`, both excluding the full C0 control range (`0x00–0x1F`, `0x7F`) — not just space/tab.\n- Input `a\\v=\\t'` (vertical tab `\\v` next to `=`) keeps `\\v` in the name (`a\\v`), yielding name=`a\\v`, value=`\\t'`.\n\n## Confirmed divergence from real backends\n| Implementation | Name | Value |\n|---|---|---|\n| Coraza (\u003c 3.8.0) | `a\\v` | `\\t'` |\n| Python `http.cookies`, and the Werkzeug/Flask version in the report below | `a` | `'` |\n| PHP `$_COOKIE` | `a` | `\\t'` |\n| Node.js `cookie` package | `a\\v` | `'` |\n\nRFC 6265 itself calls this exact cookie-pair invalid, so there's no single spec-correct reference — but Coraza's boundary handling diverges from 2 of these 3 widely-used backends.\n\nCorrection (2026-10-02): current Werkzeug (3.1.9, checked during review of the 3.8.1 follow-up) keeps `a\\v` as the name, like Node's `cookie` package. The name divergence therefore applies to PHP and to Python's `http.cookies`, not to every Werkzeug version.\n\n## Impact\nAn attacker can pad a `Cookie` header with a CTL adjacent to `=` so Coraza indexes a different name/value than the backend application does. A `SecRule` scoped to a specific cookie name or value can then miss the cookie the application actually processes — a WAF bypass for cookie-carried attacks.\n\n## Affected component\n`internal/cookies.ParseCookies`, consumed via `REQUEST_COOKIES` / `REQUEST_COOKIES_NAMES`.\n\n## Fix\nTrim the full CTL range (not just space/tab) from both ends of the extracted name and value, treating a boundary-adjacent CTL as a delimiter rather than token content — aligning with RFC 6265's `token`/`cookie-octet` grammar.\n\nThe fix does not attempt to resolve what happens when a CTL lands in the *interior* of an otherwise-plausible name (e.g. `ab\\vcd`). That case is disputed among the backends themselves — Python's `http.cookies` rejects the whole pair, PHP strips the CTL from the middle, Node's `cookie` package keeps it — so there is no consensus to converge on. It is left as a separate follow-up rather than guessed at here.\n\n### Implementation note\nThe trim is deliberately hand-rolled (a byte-wise scan on `b \u003c= ' ' || b == 0x7f`, which covers octets `0x00–0x20` plus `0x7F`) rather than delegated to the standard library. This is a conscious choice on a security hot path and should not be \"simplified\" away later:\n\n- **`strings.TrimFunc` was measured and rejected.** It invokes its predicate through a func value once per byte scanned, which Go cannot devirtualize through `strings.indexFunc`. On an Apple M2, parsing a 64 KiB CTL-saturated `Cookie` header costs **167.6 µs** via `TrimFunc` versus **33.9 µs** byte-wise — a ~5× CPU amplification handed to an attacker, on input that is attacker-controlled and parsed on every request. Allocation counts are identical either way; the cost is purely the per-byte indirect call.\n- **`strings.TrimSpace` is not a substitute.** It misses most of the CTL range (`0x00–0x08`, `0x0E–0x1F`, `0x7F`) and additionally trims `U+0085` and `U+00A0`, whose multi-byte UTF-8 encodings a backend would not strip — reintroducing the very parser-disagreement class this advisory closes.\n\nThe byte-wise implementation was verified equivalent to a `TrimFunc`-based one across all 16,843,009 byte strings of length 0–3, including invalid UTF-8, with zero mismatches. `BenchmarkParseCookies/CTLFlood` guards the hot path against a future regression to a per-byte indirect call.\n\n## Proof of Concept (original report)\n\n\u003e Hi, @fzipi, i hope you doing well, i'm RelunSec from InsiteTech.jp\n\u003e\n\u003e we discovered a parser confusion in cookie parser, i used a simple flask app that print the cookies\n\u003e\n\u003e ```py\n\u003e from flask import Flask, request\n\u003e\n\u003e app = Flask(__name__)\n\u003e\n\u003e @app.route('/')\n\u003e def index():\n\u003e     # 1. Print all cookies as a dictionary to your terminal console\n\u003e     print(\"All cookies:\", request.cookies)\n\u003e\n\u003e     return \"Cookies logged in terminal!\"\n\u003e\n\u003e if __name__ == '__main__':\n\u003e     app.run(debug=True)\n\u003e ```\n\u003e\n\u003e and a go setup\n\u003e\n\u003e ```go\n\u003e package cookies\n\u003e\n\u003e import (\n\u003e \t\"fmt\"\n\u003e \t\"testing\"\n\u003e )\n\u003e\n\u003e func TestParseCookie(t *testing.T) {\n\u003e \tinputs := []string{\n\u003e \"a\\v=\\t'\",\n\u003e \t}\n\u003e\n\u003e \tfmt.Println(\"\\n==========================================\")\n\u003e \tfmt.Println(\"     COOKIE PARSE DIRECT LOCAL RUN       \")\n\u003e \tfmt.Println(\"==========================================\")\n\u003e\n\u003e \tfor _, input := range inputs {\n\u003e \t\t// Calling the exact lowercase function name from the repo\n\u003e \t\tcookies := ParseCookies(input)\n\u003e\n\u003e \t\tfmt.Printf(\"-\u003e Input:   %q\\n\", input)\n\u003e \t\tfmt.Printf(\"   Output:  %q\\n\", cookies)\n\u003e \t\tfmt.Println(\"------------------------------------------\")\n\u003e \t}\n\u003e \tfmt.Println(\"==========================================\")\n\u003e }\n\u003e ```\n\u003e\n\u003e i runned the go program as you can see\n\u003e\n\u003e ```go\n\u003e relunsec@relunsec:~/software/coraza/internal/cookies$ go test\n\u003e\n\u003e ==========================================\n\u003e      COOKIE PARSE DIRECT LOCAL RUN\n\u003e ==========================================\n\u003e -\u003e Input:   \"a\\v=\\t'\"\n\u003e    Output:  map[\"a\\v\":[\"\\t'\"]]\n\u003e ------------------------------------------\n\u003e ==========================================\n\u003e PASS\n\u003e ok  \tgithub.com/corazawaf/coraza/v3/internal/cookies\t0.003s\n\u003e ```\n\u003e\n\u003e and then i sended a curl request to the python flask web app\n\u003e\n\u003e ```bash\n\u003e relunsec@relunsec:~/software/coraza/internal/cookies$ curl 127.0.0.1:5000 -H $'Cookie: a\\v=\\t'\n\u003e Cookies logged in terminal!\n\u003e ```\n\u003e\n\u003e and then i saw in the running flask app terminal\n\u003e\n\u003e ```python\n\u003e All cookies: ImmutableMultiDict([('a', \"'\")])\n\u003e ```\n\u003e\n\u003e as you can see python see that as the a cookie and the value of it is `'`, while coraza see it in a different name and a value\n\u003e\n\u003e an attacker can craft a crafted payload that evade cookie inspection and then perfom their attack\n\n### Patched in 3.8.1\n\nThe 3.8.0 fix was incomplete. 3.8.1 completes it: trimming control characters in 3.8.0 turned a cookie whose name is only control characters (`\\x01=payload`) into a cookie with an empty name, and empty names have always been skipped, so its value was no longer inspected. Node's `cookie` package (`{\"\\x01\": \"payload\"}`, `{\"\": \"payload\"}`) and Werkzeug still pass such pairs to the application. 3.8.1 keeps them in `REQUEST_COOKIES` under the name `\"\"`. This is an intentional deviation from ModSecurity v2 and v3, which skip empty names. Upgrade to 3.8.1; 3.8.0 is listed as affected.\n\n\n### Severity (revised 2026-10-02)\n\n`CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N` (4.0, Medium).\n\nUnchanged vector; precondition stated per the project's triage guidance. Attack Complexity is High because the bypass depends on a specific backend cookie parser: the original trim discrepancy affects backends that split `a\\v` as `a` (PHP's `$_COOKIE`, Python's `http.cookies`) and only rules keyed on a cookie name, and the 3.8.0 regression affects backends that pass empty or control-character-only cookie names to the application (Node's `cookie` package, Werkzeug for `\\x01`).\n\nImpact 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.\n\n_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._","modified":"2026-10-08T18:00:10.398267094Z","published":"2026-10-08T17:51:30Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-08T17:51:30Z","nvd_published_at":null,"cwe_ids":["CWE-436"]},"references":[{"type":"WEB","url":"https://github.com/corazawaf/coraza/security/advisories/GHSA-g4qm-m288-5cp9"},{"type":"WEB","url":"https://github.com/corazawaf/coraza/commit/0b940e197ad9983fb3aa36e84f1f81ff985461af"},{"type":"WEB","url":"https://github.com/corazawaf/coraza/commit/9f8521398d1ff023b958fad0b944cac265763866"},{"type":"PACKAGE","url":"https://github.com/corazawaf/coraza"},{"type":"WEB","url":"https://github.com/corazawaf/coraza/releases/tag/v3.8.1"}],"affected":[{"package":{"name":"github.com/corazawaf/coraza/v3","ecosystem":"Go","purl":"pkg:golang/github.com/corazawaf/coraza/v3"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"3.8.1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-g4qm-m288-5cp9/GHSA-g4qm-m288-5cp9.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:N/I:L/A:N"}]}