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>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-09 10:49:27 -07:00
1 parent e134b2b234
commit 4f21bb2f76
9 files changed
+380 -2

No files matched your search

+35
View File
@@ -106,6 +106,41 @@ global
# Stats persistence for zero-downtime reloads
stats-file /var/lib/haproxy/stats.dat
{%- if coraza_spoe_backend %}
# WAF BODY INSPECTION LIMIT. Only emitted when Coraza is wired in, so a
# WAF-off / standalone config is unchanged byte-for-byte. 16384 is also
# HAProxy's default: this line changes no behaviour or memory, it pins
# the value so nobody raises it without reading this.
#
# The request body Coraza sees is whatever `req.body` holds when
# send-spoe-group fires, and with `option http-buffer-request`
# (hap_listener.tpl) that is "the whole body, or as much as fits in ONE
# buffer". So bufsize IS the inspection limit: a body up to roughly
# bufsize - maxrewrite (1024) - the request headers (~15 KB) is inspected
# whole and stamped `complete`; anything larger is forwarded intact, its
# first ~15 KB inspected, and stamped `partial`. Large uploads are never
# rejected or held for it.
#
# DO NOT RAISE THIS WITHOUT RE-MEASURING -- three ceilings, all measured:
# * TIME. Coraza+CRS 4.25 costs ~3.3 ms per KB of body (linear; 31 KB
# base64 = 104 ms, 15 KB = ~50 ms on a 32-core box). The SPOE
# `timeout processing` is 100 ms (hap_coraza_spoe_engine.tpl) and a
# timeout FAILS OPEN: with tune.bufsize 32768 a payload at byte 0 of a
# 30 KB base64 multipart body passed 933180 uninspected. A larger
# buffer is a bypass, not more coverage, unless the timeout (and CPU
# budget) grows with it.
# * FRAME. coraza-spoa's SPOP library (dropmorepackets/haproxy-go
# v0.0.8) refuses a HELLO whose max-frame-size exceeds 65535; HAProxy
# derives its offer from bufsize. bufsize >= 64 KB = every SPOE
# handshake fails = WAF silently off for all traffic.
# * MEMORY. maxconn 4000 x 2 buffers x bufsize, inside the 1 GB edge
# container.
# Coraza's SecRequestBodyLimit (coraza-spoa/config.yaml, 131072) must stay
# above anything this can deliver. Pinned by
# scripts/test-waf-body-inspection.py.
tune.bufsize 16384
{%- endif %}
#---------------------------------------------------------------------
# DNS resolver for Docker container name resolution