This directory contains the hand-written reference configuration that HAProxy uses to consult the Coraza SPOA WAF. It is intentionally small and readable: M2 (#110) will replace it with Jinja2 templates rendered from the policy database, so this file is the seed those templates must reproduce.
| File | Purpose |
|---|---|
haproxy.cfg |
Frontend, vhost ACL, SPOE filter, backends |
coraza.cfg |
SPOE engine + message definition for the Coraza SPOA |
README.md |
This document |
Client ──► HAProxy :80 ──► (SPOE) ──► Coraza SPOA :9000
▲ │
│ ▼
└──── allow / deny ◄── decision
│
▼ (if allowed)
Backend app.local ──► be_app (backend:8000)
- The client sends an HTTP request to HAProxy on port 80.
- The
fe_httpfrontend stamps it withX-Request-IDand matches theHostheader, without any request port, against thehost_appACL. Unknown hosts are rejected with421 Misdirected Request. - The
spoefilter sends acoraza-reqmessage to thecoraza-spoabackend (TCP,coraza:9000). - The SPOA evaluates the request against Coraza/CRS rules and
returns variables under
txn.coraza.*. - If the
coraza-spoabackend has no usable servers, HAProxy returns503 Service Unavailablewith degraded-mode headers before attempting SPOE. - If SPOE starts but fails,
txn.coraza.erroris set and HAProxy returns503 Service Unavailablewith degraded-mode headers before contacting the backend. - If
txn.coraza.action == "deny", HAProxy responds with403 Forbiddenand never contacts the backend. - Otherwise the request is routed to
be_app, which forwards tobackend:8000(the FastAPI service in Docker Compose).
All variables set by the SPOA are namespaced via
option var-prefix coraza, so HAProxy sees them as txn.coraza.<name>.
| Variable | Meaning |
|---|---|
txn.coraza.action |
Decision string (deny blocks; anything else allows) |
txn.coraza.anomaly_score |
Inbound anomaly score from the rule set, propagated as header |
txn.coraza.error |
SPOE/SPOP error code set by option set-on-error error |
txn.coraza.id |
Transaction id correlated with HAProxy's unique-id |
The exact set of variables produced depends on the Coraza SPOA version and rule configuration; the three above are the minimum this reference relies on.
coraza-req is sent by the coraza-req SPOE group and carries the data
Coraza needs to run request-phase rules:
| Argument | HAProxy fetch | Notes |
|---|---|---|
app |
str(default) |
Which Coraza application bundle to use |
id |
unique-id |
Same value as the X-Request-ID header |
src-ip |
src |
Client IP |
src-port |
src_port |
Client TCP port |
dst-ip |
dst |
HAProxy bind IP |
dst-port |
dst_port |
HAProxy bind port |
method |
method |
HTTP method |
path |
path |
Request path without query |
query |
query |
Raw query string |
version |
req.ver |
HTTP version |
headers |
req.hdrs |
All request headers, framed for SPOE |
body |
req.body |
Request body (subject to SPOA limits) |
exportRuleIDs |
bool(false) |
Keep rule-id export disabled by default |
Response-phase inspection is deliberately out of scope for M1 (see ADR-007).
spoe-agent is configured with option set-on-error error. If SPOE
processing starts and then times out, receives a malformed response, or
hits an internal processing error, HAProxy sets txn.coraza.error to
the SPOE/SPOP error code. HAProxy also checks nbsrv(coraza-spoa) before
sending SPOE, so startup and unhealthy-container windows fail closed even
when no SPOE exchange can begin.
The M1 reference configuration fails closed for protected traffic:
degraded requests return 503 Service Unavailable before contacting
be_app. Responses include X-WAF-Status: degraded and
X-WAF-Degraded-Reason, whose value is either coraza-unavailable or
spoe-processing-error. SPOE processing failures also include
X-WAF-Error-Code: <code>. HAProxy raises these requests to err log
level and captures the degraded reason in the access log.
This intentionally trades availability for safety: if inspection is not
available, protected application traffic is not forwarded. /health
continues to bypass WAF inspection, and backend/dashboard status
reporting is tracked separately in #69.
The M1 reference stack supports an opt-in debug mode (make dev) that runs
HAProxy with the -d flag and switches Coraza SPOA logging to debug level,
so a single request can be followed across both services. The default make run
mode uses info logging.
Warning: debug mode logs full request metadata. Use only for local troubleshooting against non-production traffic.
-
Start the stack in debug mode and follow only the WAF path logs:
make dev docker-compose -f docker/docker-compose.yml --env-file docker/.env logs -f haproxy coraza
-
Send a request with an explicit correlation id:
curl -i \ -H "Host: app.local" \ -H "X-Request-ID: spoe-debug-1" \ "http://localhost:8080/?id=1%27%20OR%20%271%27=%271"
-
Check HAProxy output first:
- the request should pass through
fe_http; - non-
/healthrequests should triggersend-spoe-group coraza coraza-req; - denied requests should show a
403generated beforebe_app.
- the request should pass through
-
Check Coraza output next:
- the SPOA should receive the
defaultapplication name; - request metadata should match the
coraza-reqarguments documented above; - matching CRS rules should also appear in
/var/log/coraza/audit.json.
- the SPOA should receive the
-
If HAProxy returns
421, the request failed the reference host ACL before routing. Retry withHost: app.local. -
If HAProxy returns
503withX-WAF-Status: degraded, Coraza/SPOA inspection failed and the proxy failed closed before contacting the backend. UseX-WAF-Degraded-Reason,X-WAF-Error-Codewhen present, and the HAProxyerrlog line to identify the failure class.
For raw frame inspection in the Docker Compose setup, capture the SPOA
traffic from inside the haproxy container while reproducing the
request. Container-to-container traffic does not normally traverse the
host lo interface:
docker-compose -f docker/docker-compose.yml --env-file docker/.env \
exec haproxy tcpdump -i any -A -s 0 port 9000The configuration is exercised in two ways:
-
Syntax / semantic check (no runtime needed):
haproxy -c -f configs/haproxy/haproxy.cfg
If
haproxyis not installed locally, run the same check inside the pinned image:docker run --rm \ -v "$PWD/configs/haproxy:/usr/local/etc/haproxy:ro" \ haproxy:3.0-alpine \ haproxy -c -f /usr/local/etc/haproxy/haproxy.cfgThe check must report
Configuration file is validwith no warnings. -
End-to-end smoke test against the full Docker Compose stack:
docker-compose -f docker/docker-compose.yml --env-file docker/.env up -d --build docker-compose -f docker/docker-compose.yml --env-file docker/.env ps curl -i http://localhost:8080/health curl -i "http://localhost:8080/?id=1%27%20OR%20%271%27=%271" docker-compose -f docker/docker-compose.yml --env-file docker/.env downAll five services should become healthy. The benign
/healthrequest should return200 OK; the SQL-injection payload should return403 Forbidden. The backend currently does not define a root route, so/returns404 Not Foundeven though it is routed through HAProxy.
- ADR-007 — picks upstream
coraza-spoaas the SPOA implementation. - #106 — provides the Coraza SPOA + OWASP CRS bundle this config talks to.
- #107 — wires this
configs/haproxy/directory into Docker Compose. - #108 — runs the end-to-end smoke test that this reference unblocks.
- #110 — replaces these files with Jinja2 templates seeded from this reference.