From ba4c10113578cc2f68c6ba8f7ccc5886b9895559 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Tue, 12 May 2026 17:16:03 -0700 Subject: [PATCH] fix(coraza): add deny rules that act on Coraza's verdict + spop-check on backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that complete the SPOE enforcement path: 1. Listener was sending requests to Coraza for inspection but never reading the result. Coraza-SPOA sets var(txn.coraza.action) to "deny" / "drop" / "redirect" when a rule with that disruptive action fires; HAProxy needs explicit rules that READ the variable and apply the action. Without them, the audit log shows "Access denied" but the request still gets HTTP 200 (verified on staging: sqlmap/JNDI/shellinj all detected, all returned 200). Added the standard six rules from upstream's example/haproxy/haproxy.cfg covering http-request + http-response phases for each of deny/drop/ redirect. Same set the upstream Coraza-SPOA docs recommend. Intentionally did NOT add the upstream's fail-CLOSED rule `http-request deny deny_status 500 if { var(txn.coraza.error) -m int gt 0 }` — for a hosting platform we want fail-open. Documented inline. 2. Backend health check switched from plain TCP `check` to `option spop-check`. The spop-check actually negotiates a SPOE session against the agent, so HAProxy detects a half-broken SPOA that's listening on :9000 but failing protocol handshakes. Plain `check` would miss that. Co-Authored-By: Claude Opus 4.7 (1M context) --- templates/hap_coraza_spoa_backend.tpl | 10 ++++++---- templates/hap_listener.tpl | 20 +++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/templates/hap_coraza_spoa_backend.tpl b/templates/hap_coraza_spoa_backend.tpl index 10d86b9..d62c41c 100644 --- a/templates/hap_coraza_spoa_backend.tpl +++ b/templates/hap_coraza_spoa_backend.tpl @@ -6,9 +6,11 @@ # container's name + 9000 inside the shared docker network). backend coraza-spoa-backend mode tcp + # spop-check actually speaks the SPOE protocol against the agent — + # confirms the agent can negotiate a session, not just that the TCP + # port is open. Required to detect a half-broken SPOA that's listening + # but not actually processing. + option spop-check timeout connect 5s timeout server 30s - # Keep-alive connection to the SPOA — saves a TCP handshake on every - # request. SPOE protocol multiplexes multiple requests over one - # connection so this is normal. - server coraza-spoa {{ agent_target }} check inter 30s rise 2 fall 3 + server coraza-spoa {{ agent_target }} check diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index f647600..18bcca8 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -58,9 +58,23 @@ frontend web # Coraza WAF inspection via SPOE. Runs AFTER rate-limit and IP-block # guards (no point asking the WAF about requests we're already dropping) # and AFTER the real-client-IP resolution (so Coraza sees the right src). - # Fail-open: option set-on-error in coraza-spoe.cfg only SETS the error - # var; we deliberately don't have a `http-request deny if errored` rule, - # so SPOA outages let traffic through uninspected. filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg http-request send-spoe-group coraza coraza-req + + # Enforce Coraza's verdict. The SPOA sets var(txn.coraza.action) to + # "deny" / "drop" / "redirect" when a rule with the corresponding + # disruptive action fires (depends on SecRuleEngine mode + per-rule + # ctl:ruleEngine overrides). Without these rules, Coraza would inspect + # but never block. + http-request deny deny_status 403 hdr waf-block "request" if { var(txn.coraza.action) -m str deny } + http-response deny deny_status 403 hdr waf-block "response" if { var(txn.coraza.action) -m str deny } + http-request silent-drop if { var(txn.coraza.action) -m str drop } + http-response silent-drop if { var(txn.coraza.action) -m str drop } + http-request redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } + http-response redirect code 302 location %[var(txn.coraza.data)] if { var(txn.coraza.action) -m str redirect } + + # FAIL-OPEN on SPOA error. Upstream's example does the opposite — denies + # 500 if var(txn.coraza.error) is set — but for a hosting platform we'd + # rather lose WAF coverage briefly than 503 customer sites. The error + # variable still gets set, so monitoring can observe it. {%- endif %}