From 519f2d8adebfedfc5f3f5b19b886af3d8012d67d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 16 Oct 2026 08:03:53 -0700 Subject: [PATCH] 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 --- scripts/test-waf-body-inspection.py | 53 ++++++++++++++++++++++++++++ templates/hap_coraza_spoe_engine.tpl | 37 +++++++++++++++---- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index 7b264aa..e8b957c 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -217,6 +217,59 @@ class BufsizeCeilings(unittest.TestCase): self.assertTrue(args[0].endswith(' body=req.body')) +class CorazaTransactionIdIsTheRequestReference(unittest.TestCase): + """Coraza's transaction ID must be HAProxy's unique-id, so the reference a + blocked customer quotes (403-waf.html / X-Request-Reference / `id=` in + haproxy.log) is the `unique_id` in /var/log/coraza/audit.log and + spoa.log. Without `id=` coraza-spoa v0.7.1 invents a random 16-letter ID + (internal/application.go HandleRequest) that joins to nothing.""" + + def setUp(self): + spoe = haproxy_manager.template_env.get_template( + 'hap_coraza_spoe_engine.tpl').render() + lines = active_lines(spoe) + self.messages = {} + current = None + for line in lines: + m = re.match(r'^spoe-message\s+(\S+)$', line) + if m: + current = m.group(1) + continue + if line.startswith('spoe-') or line.startswith('['): + current = None + elif current and line.startswith('args '): + self.messages[current] = line.split()[1:] + + def args_of(self, message): + return dict(a.split('=', 1) for a in self.messages[message]) + + def test_request_message_sends_unique_id(self): + self.assertIn('coraza-req', self.messages) + self.assertEqual(self.args_of('coraza-req').get('id'), 'unique-id') + + def test_app_is_still_the_first_arg(self): + """agent.go HandleSPOE Panics unless the FIRST kv entry is `app`.""" + for name, args in self.messages.items(): + self.assertTrue(args[0].startswith('app='), + f'{name}: first arg must be app=, got {args[0]}') + + def test_every_response_message_carries_the_same_id(self): + """HandleResponse finds the request transaction by `id`; a coraza-res + without it errors ("response id is empty") on every response.""" + for name in self.messages: + if name != 'coraza-req': + self.assertEqual(self.args_of(name).get('id'), 'unique-id', name) + + def test_unique_id_is_the_header_reference(self): + """The fetch is only meaningful while unique-id-format is the UUID that + is also emitted as X-Request-Reference and on the 403 page.""" + header = active_lines(render('hap_header.tpl', coraza=True)) + self.assertIn('unique-id-format %[uuid()]', [re.sub(r'\s+', ' ', l) for l in header]) + self.assertIn('unique-id-header X-Request-Reference', [re.sub(r'\s+', ' ', l) for l in header]) + listener = render('hap_listener.tpl', coraza=True) + self.assertIn('x-request-reference "%[unique-id]"', listener) + + CORAZA_OVERRIDES = os.path.join(MODULE_DIR, 'coraza-spoa', 'overrides.conf') diff --git a/templates/hap_coraza_spoe_engine.tpl b/templates/hap_coraza_spoe_engine.tpl index 058e35e..745ee17 100644 --- a/templates/hap_coraza_spoe_engine.tpl +++ b/templates/hap_coraza_spoe_engine.tpl @@ -10,9 +10,9 @@ # config.yaml. # # Reference: this config follows the shape from coraza-spoa's upstream -# example/haproxy/coraza.cfg (v0.7.1). Arg names + ordering are required by -# Coraza-SPOA exactly as specified — DO NOT reorder or rename without -# coordinating with the agent. +# example/haproxy/coraza.cfg (v0.7.1). Arg NAMES are a contract with +# coraza-spoa — DO NOT rename without coordinating with the agent. Ordering: +# only `app` is positional (it must be FIRST); the rest are matched by name. [coraza] @@ -56,9 +56,32 @@ spoe-agent coraza # Per-request inspection message. No `event` directive — fires only when # explicitly invoked from haproxy.cfg via `http-request send-spoe-group`. -# Arg order/names are mandatory: Coraza-SPOA parses positionally and renames -# break the agent. `app=str(haproxy)` is the literal application name from -# coraza-spoa's config.yaml `applications:` block. +# +# How coraza-spoa v0.7.1 reads these args (measured from source, not inferred): +# * `app` MUST be the first arg. internal/agent.go HandleSPOE reads exactly +# one KV entry and Panics if its name is not "app". +# * Every other arg is matched BY NAME in a switch (internal/application.go +# HandleRequest); order is irrelevant and unknown names are ignored with a +# debug log. So a typo'd name silently drops that input — renames still +# break inspection, they just don't error. +# `app=str(haproxy)` is the literal application name from coraza-spoa's +# config.yaml `applications:` block. +# +# id=unique-id makes Coraza's transaction ID the SAME UUID HAProxy generates +# via `unique-id-format %[uuid()]` (hap_header.tpl) — the value shown on the +# 403-waf.html page, sent as X-Request-Reference, and logged as `id=` in +# /var/log/haproxy.log. coraza-spoa passes it to waf.NewTransactionWithID(), +# so it becomes `transaction.id` in /var/log/coraza/audit.log and +# `match.unique_id` in /var/log/coraza/spoa.log: a customer-quoted reference +# greps straight to the rule that fired. Without it coraza-spoa invents a +# random 16-letter A-Z ID that appears nowhere on the HAProxy side. If the +# fetch ever came back empty the agent falls back to that random ID, so this +# cannot break inspection. +# +# Response inspection (a `coraza-res` message) is NOT configured: +# config.yaml has response_check: false. If it is ever enabled, coraza-res +# MUST also send `id=unique-id` -- HandleResponse +# looks the request transaction up by that ID and errors without it. # # src-ip uses var(txn.real_ip) — HAProxy resolves the real client IP at the # top of the frontend (CF-Connecting-IP > X-Real-IP > X-Forwarded-For > src, @@ -66,7 +89,7 @@ spoe-agent coraza # when no proxy headers are present. This is what shows up as `client_ip` # in /var/log/coraza/audit.log and the rule manager's view-matches panel. spoe-message coraza-req - args app=str(haproxy) src-ip=var(txn.real_ip) src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body + args app=str(haproxy) id=unique-id src-ip=var(txn.real_ip) src-port=src_port dst-ip=dst dst-port=dst_port method=method path=path query=query version=req.ver headers=req.hdrs body=req.body # Group binding for send-spoe-group invocation in the frontend. One group, # one message; could add more in the future (e.g. coraza-res for response