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>
coraza-spoa sidecar
A sidecar container that runs Coraza-SPOA as a WAF engine for haproxy-manager. HAProxy consults it per-request via the SPOE/SPOP protocol; Coraza evaluates the request against OWASP CRS rules and tells HAProxy whether to allow or block.
Design constraints
haproxy-managerdoes NOT depend on this sidecar. The base image works standalone (used in other projects and home networks) without WAF. SPOE config in the generatedhaproxy.cfgis opt-in via an env var onhaproxy-manager.- Fail-open when the sidecar is unhealthy.
option set-on-error continuein the HAProxy SPOE config means request flow continues uninspected if coraza-spoa is unreachable, rather than 503-ing customer traffic. - Detect-only globally; enforce explicitly. See
overrides.conffor the day-one enforce list. Most CRS rules log without blocking until we've tuned per-customer false positives.
Deployment shape
Two containers per host, both on the client-net docker network:
haproxy-manager (existing) — ports 80, 443, 8000
│ SPOE TCP/9000 → reach coraza-spoa by container DNS
▼
coraza-spoa (this image)
port 9000 (SPOE) — NOT exposed on host; internal network only
/var/log/coraza — bind-mounted to host for AI Monitor consumption
Typical docker run:
mkdir -p /var/log/coraza
chown 65532:65532 /var/log/coraza # distroless nonroot UID
docker run -d \
--name coraza-spoa \
--network client-net \
--restart unless-stopped \
-v /var/log/coraza:/var/log/coraza \
your-registry.example.com/cloud-hosting-platform/coraza-spoa:latest
Then on the haproxy-manager container, add the env var:
-e HAPROXY_CORAZA_SPOE_BACKEND=coraza-spoa:9000
The haproxy-manager template engine sees the env var and renders the SPOE config block pointing at this sidecar. Without the env var, no SPOE blocks render — the haproxy-manager image's behavior is unchanged.
Files
| File | Purpose |
|---|---|
Dockerfile |
Multi-stage build (golang:1.25 → distroless), pinned to upstream coraza-spoa tag |
config.yaml |
SPOA listener config + one named application haproxy |
overrides.conf |
Day-one enforce list (ctl:ruleEngine=On for high-confidence rule IDs) |
README.md |
This file |
Audit log
/var/log/coraza/audit.log — JSON, one event per line, RelevantOnly (only requests that triggered ≥1 rule are logged). AI Monitor should be configured to tail this on each host.
Entries include rule IDs, matched patterns, request metadata, and action taken (log for detect-only, deny for enforced). Use the JSON action field to filter blocked vs. observed.
Upgrading the pin
CRS rules are bundled into the coraza-spoa binary at build time, so the CRS version is whatever ships with the pinned coraza-spoa tag. To upgrade:
- Check upstream releases: https://github.com/corazawaf/coraza-spoa/releases
- Skim the CHANGELOG for new/changed rules in the
overrides.confID ranges. - Bump
ARG CORAZA_SPOA_VERSIONin the Dockerfile. - Push to
main— the Gitea workflow at.gitea/workflows/build-push-coraza.yamlrebuilds + pushes:latest. - On each host, run
container-manager.sh recreate coraza-spoato pull the new image.
Tuning false positives
When a legitimate request triggers a blocked rule, the audit log shows the rule ID. Two ways to silence it:
- Per-rule exception in
overrides.conf:SecRuleRemoveById <id>(full disable) orSecRuleRemoveTargetById <id> "<target>"(targeted exception). - Drop from the enforce list: remove the rule's ID range from the
ctl:ruleEngine=Onoverrides; it falls back to detect-only.
After tuning, push the change — CI rebuilds, then recreate coraza-spoa on each host to apply.