Commit Graph
6 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 cbbd15b787 feat(waf): log why Coraza acted (wrule/wrules/wscore); VERSION 2026.10.4
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 3m14s
coraza-spoa v0.7.1 already returns the interrupting rule id
(txn.coraza.ruleid) and, when the request message sends
exportRuleIDs=bool(true), the comma list of every matched CRS attack
rule (txn.coraza.rule_ids) plus the inbound anomaly score. Send the arg
and append all three to the access log so a block's cause (e.g.
920440,949110 score 5) is visible in haproxy.log without opening the
Coraza audit log. Fields are appended last; existing ones unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:20:46 -07:00
shadowdaoandClaude Opus 5.5 519f2d8ade feat(waf): Coraza transaction ID = HAProxy request reference UUID
Send `id=unique-id` in the coraza-req SPOE message so coraza-spoa calls
waf.NewTransactionWithID() with HAProxy's unique-id (the %[uuid()] shown
on 403-waf.html, sent as X-Request-Reference and logged as `id=` in
haproxy.log). A customer-quoted reference now greps directly to
transaction.id in /var/log/coraza/audit.log and match.unique_id in
spoa.log. Previously coraza-spoa invented a random 16-letter A-Z ID
that joined to nothing on the HAProxy side.

Measured against coraza-spoa v0.7.1 source (no version bump needed):
- internal/agent.go HandleSPOE: only `app` is positional (must be the
  first KV, else Panic); the template comment claiming all args are
  parsed positionally was wrong and is corrected.
- internal/application.go HandleRequest: every other arg is matched by
  name; `id` is recognised and passed to NewTransactionWithID. Empty/
  NULL id falls back to the random ID, so this cannot break inspection.
- coraza v3.7.0 places no format/length constraint on the ID.

response_check is false so there is no coraza-res message; the template
and a test now require any future coraza-res to send the same id
(HandleResponse looks the request transaction up by it).

Tests: new CorazaTransactionIdIsTheRequestReference class in
test-waf-body-inspection.py (fails without the change). Full in-image
suite + validate-rendered-config (haproxy 3.0.11 -c) pass; a negative
control with a bogus fetch name is rejected by haproxy -c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 08:03:59 -07:00
shadowdaoandClaude Opus 5.5 2da2020305 fix(waf): stop promoting 944110/944120 chain starters (round 3)
Owner ruling (same mode contract as 920440: no accidental enforcement from
bare words). 944110 and 944120 are CRS chains whose first link matches
`runtime|processbuilder` / `getproperty|forclosure|filewriter|...`
anywhere in the request; the range promotion 944100-944300 put
ctl:ruleEngine=On on those starters, so any request containing the word
ran with full anomaly enforcement, in detect_only too.

Promotion is now 944100 and 944130-944300. Live (haproxy-manager-base
:wafbody-r3 + coraza-spoa :wafbody-r3), both modes: `?q=runtime&<SQLi>`
and `?q=getproperty&<SQLi>` 403 -> 200 on detect_only; Log4Shell in the
query (plain and ${lower:j} obfuscated), User-Agent and a JSON body still
403.

test-waf-body-inspection.py: 21 tests (+2: no CRS chain starter
920440/944110/944120 is covered by a promotion; the Log4Shell ids keep
theirs). Red on round 2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:10:41 -07:00
shadowdaoandClaude Opus 5.5 234029c249 fix(waf): promote 920440 chain-safely, not on its chain starter (round 2)
Owner decision. `SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"` ran on
920440's chain STARTER (`REQUEST_BASENAME "@rx \.([^.]+)$"`), which matches
every URL with a file extension, so every *.php/*.css request was evaluated
with the engine On: full CRS anomaly enforcement (949110) and 200002, in
detect_only mode too.

Replaced by `SecRule TX:EXTENSION "@within %{tx.restricted_extensions}"
... ctl:ruleEngine=On` (id 992920440, phase 1, after 920440). CRS 4.25.0
sets tx.extension in 920440's first link (REQUEST-920...conf:667, the
coreruleset version coraza-spoa v0.7.1 pins). Live, both modes:
.bak/.sql/.save/.old/.swp probes still 403; benign .php/.css 200; a
942100 SQLi on /x.php and the onclick / "select ... union" admin saves are
no longer 403 in detect_only.

test-waf-body-inspection.py: 19 tests (2 new, repo-checkout only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:00:22 -07:00
shadowdaoandClaude Opus 5.5 cc0652e0c0 fix(waf): 400 duplicate Content-Type; JSON depth limit 64 (round 1)
- hap_listener.tpl (Coraza block): `http-request deny deny_status 400 if
  { req.fhdr_cnt(content-type) gt 1 }`. With two Content-Type lines Coraza
  evaluates every value (text/xml on either line selects its XML processor,
  which accepts a form body without error) while Apache merges the lines
  and PHP parses the body as a form. Measured with Coraza v3.7.0 + PHP 8.3:
  `application/x-www-form-urlencoded` + `text/xml` put the payload in
  $_POST uninspected. Live: two lines -> 400 over HTTP/1.1 and HTTP/2; one
  comma-joined line (browser XHR double-set) is unaffected.
- coraza-spoa/config.yaml: SecRequestBodyJsonDepthLimit 64 (was 1024 from
  the recommended file). Coraza's JSON flattening is quadratic in depth:
  depth 300 (601 B) = 297 ms, depth 1000 (2 KB) = 3.1 s, past the 100 ms
  fail-open timeout. Over the limit it is a 0.1 ms parse error, which WHP's
  enforce-mode rule denies on a complete body (live: depth 65/300/1000 ->
  403, depth 64 and a depth-9 global-styles body -> 200). Legitimate depth
  measured on WordPress core, Gutenberg, WooCommerce and Elementor JSON:
  max 8.
- test-waf-body-inspection.py: 17 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:21:49 -07:00
shadowdaoandClaude Opus 5.5 4f21bb2f76 feat(waf): make Coraza see request bodies; stamp complete/partial; 2026.10.2
Measured on haproxy 3.0.11 + coraza-spoa v0.7.1 + CRS 4.25.0 (local
haproxy-manager + coraza-spoa + echo backend):

- send-spoe-group fired at header time, so req.body held only what arrived
  with the headers. A 933180 payload whose body came in a second send(),
  2s late, or after Expect: 100-continue passed uninspected; 8 parallel
  14 KB payload-first POSTs: 6/40 blocked before, 40/40 after.
- Add `option http-buffer-request` (Coraza block only). Uploads are not
  held: one buffer is inspected, the rest streams (50 MB upload: sha-ok,
  same timing before/after).
- Stamp X-WHP-WAF-Body: complete|partial for Coraza (client copies
  stripped first, stripped again before the backend). Content-Length:
  exact compare; chunked/no-CL: body+headers < 12 KB => complete. WHP's
  generated rules use it to deny body-parse errors (200002/200003) only on
  complete bodies.
- Pin tune.bufsize 16384 (= default) when Coraza is on. Coraza costs
  ~3.3 ms/KB of body and the 100 ms SPOE timeout fails open: at 32 KB a
  payload at byte 0 of a 30 KB base64 body bypassed. SPOP frames > 65535
  are refused by the agent library.
- coraza-spoa/config.yaml: SecRequestBodyLimit 131072 / InMemory 131072 /
  ProcessPartial (Reject 413s even under DetectionOnly).
- Access log gains wafb= (complete|partial) and wafe= (SPOE error =
  request passed uninspected).
- overrides.conf: document (comment only) that the 920440 promotion turns
  the engine On for every URL with a file extension.
- scripts/test-waf-body-inspection.py (16 tests), wired into the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:49:27 -07:00