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:
1 parent
e134b2b234
commit
4f21bb2f76
9 files changed
+380
-2
No files matched your search
@@ -10,6 +10,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- **Stick-table contract**: `python3 scripts/test-stick-table-contract.py` - offline; holds the templates' `store` clauses, `STICK_TABLE_FIELD_CONTRACT`, and every consumer to each other. Run it after touching any `stick-table` line.
|
- **Stick-table contract**: `python3 scripts/test-stick-table-contract.py` - offline; holds the templates' `store` clauses, `STICK_TABLE_FIELD_CONTRACT`, and every consumer to each other. Run it after touching any `stick-table` line.
|
||||||
- **Runtime-map contract**: `python3 scripts/test-runtime-map-contract.py` - offline; asserts the runtime map commands are `@1`-prefixed, reference the map by FILE PATH (never `#<id>`), carry the value `1`, and that every captured rejection is classified as a failure. Run it after touching any `add map`/`del map`/`clear map` path.
|
- **Runtime-map contract**: `python3 scripts/test-runtime-map-contract.py` - offline; asserts the runtime map commands are `@1`-prefixed, reference the map by FILE PATH (never `#<id>`), carry the value `1`, and that every captured rejection is classified as a failure. Run it after touching any `add map`/`del map`/`clear map` path.
|
||||||
- **Certificate destruction safety**: `python3 scripts/test-cert-write-safety.py` - offline; asserts a live `.pem` is never truncated, removed, or its certbot lineage deleted while any configured domain still references it (one bundle serves many names, so `ssl_cert_path` is routinely shared). Run it after touching any `os.remove`/`certbot delete`/PEM-write path.
|
- **Certificate destruction safety**: `python3 scripts/test-cert-write-safety.py` - offline; asserts a live `.pem` is never truncated, removed, or its certbot lineage deleted while any configured domain still references it (one bundle serves many names, so `ssl_cert_path` is routinely shared). Run it after touching any `os.remove`/`certbot delete`/PEM-write path.
|
||||||
|
- **WAF body inspection**: `python3 scripts/test-waf-body-inspection.py` - offline (runs in the image build); pins `option http-buffer-request`, the `X-WHP-WAF-Body` complete/partial stamp (strip -> stamp -> SPOE -> strip), `tune.bufsize` against the 100 ms SPOE fail-open budget and the 65535-byte SPOP frame cap, and Coraza's body limits in `coraza-spoa/config.yaml`. Run it after touching the Coraza block, `tune.bufsize`, or the SPOE engine.
|
||||||
- **Manual Testing**: Run `curl` commands against `http://localhost:8000` endpoints as shown in README.md
|
- **Manual Testing**: Run `curl` commands against `http://localhost:8000` endpoints as shown in README.md
|
||||||
|
|
||||||
### Reading stick tables (and why it is easy to get silently wrong)
|
### Reading stick tables (and why it is easy to get silently wrong)
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ RUN python3 /haproxy/scripts/test-wpadmin-gate.py \
|
|||||||
&& python3 /haproxy/scripts/test-config-rollback.py \
|
&& python3 /haproxy/scripts/test-config-rollback.py \
|
||||||
&& python3 /haproxy/scripts/test-cert-write-safety.py \
|
&& python3 /haproxy/scripts/test-cert-write-safety.py \
|
||||||
&& python3 /haproxy/scripts/test-cert-scripts.py \
|
&& python3 /haproxy/scripts/test-cert-scripts.py \
|
||||||
|
&& python3 /haproxy/scripts/test-waf-body-inspection.py \
|
||||||
&& python3 /haproxy/scripts/validate-rendered-config.py
|
&& python3 /haproxy/scripts/validate-rendered-config.py
|
||||||
# Create log directories
|
# Create log directories
|
||||||
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ applications:
|
|||||||
# CRS-bundled defaults: recommended Coraza settings + CRS setup +
|
# CRS-bundled defaults: recommended Coraza settings + CRS setup +
|
||||||
# the rule pack itself (~16 MB of rules embedded in the binary).
|
# the rule pack itself (~16 MB of rules embedded in the binary).
|
||||||
Include @coraza.conf-recommended
|
Include @coraza.conf-recommended
|
||||||
|
|
||||||
|
# Request-body limits, set explicitly instead of inheriting the
|
||||||
|
# recommended file's 12.5 MB / Reject. HAProxy only ever hands us one
|
||||||
|
# buffer of body (tune.bufsize 16384 in haproxy-manager's
|
||||||
|
# hap_header.tpl), and the SPOP library refuses frames over 65535
|
||||||
|
# bytes, so no body we receive can exceed 64 KB. The invariant is:
|
||||||
|
# tune.bufsize (16 KB) < SPOP frame cap (65535) < SecRequestBodyLimit
|
||||||
|
# Breaking it is NOT harmless: SecRequestBodyLimitAction Reject
|
||||||
|
# interrupts with 413 even under DetectionOnly (Coraza
|
||||||
|
# setAndReturnBodyLimitInterruption ignores the engine mode), which the
|
||||||
|
# edge turns into a 403 on every large upload. ProcessPartial is the
|
||||||
|
# belt to that brace. InMemoryLimit == BodyLimit keeps the body buffer
|
||||||
|
# off disk (this image is distroless). Pinned by
|
||||||
|
# haproxy-manager-base/scripts/test-waf-body-inspection.py.
|
||||||
|
SecRequestBodyLimit 131072
|
||||||
|
SecRequestBodyInMemoryLimit 131072
|
||||||
|
SecRequestBodyLimitAction ProcessPartial
|
||||||
|
|
||||||
Include @crs-setup.conf.example
|
Include @crs-setup.conf.example
|
||||||
|
|
||||||
# Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default.
|
# Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default.
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ SecRuleUpdateActionById 944100-944300 "ctl:ruleEngine=On"
|
|||||||
# single GCP-hosted scanner. Zero false positives observed; standard
|
# single GCP-hosted scanner. Zero false positives observed; standard
|
||||||
# WP/WooCommerce/Divi/HPR URLs do not end in these extensions.
|
# WP/WooCommerce/Divi/HPR URLs do not end in these extensions.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# WARNING (measured 2026-10-09, Coraza v3.7.0 + CRS 4.25.0, in-process AND
|
||||||
|
# live behind haproxy): 920440 is a CHAIN whose first link is
|
||||||
|
# `REQUEST_BASENAME "@rx \.([^.]+)$"` -- it matches EVERY URL with a file
|
||||||
|
# extension. Coraza runs the appended ctl:ruleEngine=On when that first link
|
||||||
|
# matches, even though the chain then fails. So on any *.php / *.css / ...
|
||||||
|
# request the engine is On for the rest of the transaction: 949110 enforces
|
||||||
|
# the whole CRS anomaly score (a 942100 SQLi GET is 403 on /x.php, 200 on /x)
|
||||||
|
# and 200002 denies body-parse errors -- in detect_only mode too, because
|
||||||
|
# SecRuleEngine DetectionOnly cannot override a runtime ctl. Not changed here
|
||||||
|
# (it would loosen live blocking fleet-wide); see waf-body-hardening-report.md.
|
||||||
SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"
|
SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2763,6 +2763,7 @@ def generate_config():
|
|||||||
default_headers = template_env.get_template('hap_header.tpl').render(
|
default_headers = template_env.get_template('hap_header.tpl').render(
|
||||||
cluster_secret = get_or_create_cluster_secret(),
|
cluster_secret = get_or_create_cluster_secret(),
|
||||||
syslog_target = syslog_target,
|
syslog_target = syslog_target,
|
||||||
|
coraza_spoe_backend = coraza_spoe_backend,
|
||||||
)
|
)
|
||||||
config_parts.append(default_headers)
|
config_parts.append(default_headers)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regression tests for WAF request-body inspection (Coraza via SPOE).
|
||||||
|
|
||||||
|
Why this file exists
|
||||||
|
--------------------
|
||||||
|
Measured 2026-10-09 on haproxy 3.0.11 + coraza-spoa v0.7.1 + CRS 4.25.0
|
||||||
|
(report: waf-body-hardening-report.md):
|
||||||
|
|
||||||
|
* Without `option http-buffer-request`, send-spoe-group fires at header
|
||||||
|
time and `req.body` holds only what happened to arrive with the headers.
|
||||||
|
A 14-byte `content=$a($b)` POST (CRS 933180, an ENFORCED rule) passed
|
||||||
|
uninspected when its body was written in a second send(); a body sent 2s
|
||||||
|
late, or after `Expect: 100-continue`, was never inspected at all.
|
||||||
|
* Coraza+CRS costs ~3.3 ms per KB of body. The SPOE `timeout processing`
|
||||||
|
is 100 ms and FAILS OPEN, so raising tune.bufsize to 32 KB turned a
|
||||||
|
payload at byte 0 of a 30 KB base64 body into a bypass. bufsize is a
|
||||||
|
security limit, not a performance knob.
|
||||||
|
* coraza-spoa's SPOP library refuses frames > 65535 bytes, and Coraza's
|
||||||
|
SecRequestBodyLimitAction Reject 413s even under DetectionOnly, so the
|
||||||
|
three limits must stay ordered.
|
||||||
|
* The X-WHP-WAF-Body stamp drives enforcement of body-parse errors
|
||||||
|
(200002/200003) in WHP's generated Coraza rules. A client must never be
|
||||||
|
able to set it, and a backend must never see it.
|
||||||
|
|
||||||
|
These tests pin the rendered config. That HAProxy ACCEPTS it is
|
||||||
|
validate-rendered-config.py's job; that it BEHAVES is the live measurement.
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
python3 scripts/test-waf-body-inspection.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
MODULE_DIR = os.path.abspath(
|
||||||
|
os.environ.get('HAPROXY_MANAGER_DIR',
|
||||||
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
||||||
|
)
|
||||||
|
os.chdir(MODULE_DIR)
|
||||||
|
sys.path.insert(0, MODULE_DIR)
|
||||||
|
|
||||||
|
_LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-logs-')
|
||||||
|
_real_file_handler = logging.FileHandler
|
||||||
|
logging.FileHandler = (
|
||||||
|
lambda filename, *a, **kw: _real_file_handler(
|
||||||
|
os.path.join(_LOG_DIR, os.path.basename(filename)), *a, **kw)
|
||||||
|
)
|
||||||
|
|
||||||
|
import haproxy_manager # noqa: E402
|
||||||
|
|
||||||
|
SPOA_FRAME_CAP = 65535 # dropmorepackets/haproxy-go v0.0.8 maxFrameSize
|
||||||
|
CORAZA_CONFIG = os.path.join(MODULE_DIR, 'coraza-spoa', 'config.yaml')
|
||||||
|
|
||||||
|
|
||||||
|
def render(template, coraza):
|
||||||
|
kw = dict(crt_path='/etc/haproxy/certs', suspension_enabled=False,
|
||||||
|
coraza_spoe_backend='coraza-spoa:9000' if coraza else None,
|
||||||
|
cluster_secret=None, syslog_target='172.18.0.1:514')
|
||||||
|
return haproxy_manager.template_env.get_template(template).render(**kw)
|
||||||
|
|
||||||
|
|
||||||
|
def active_lines(text):
|
||||||
|
"""Config lines with comments and blanks stripped (comments mention the
|
||||||
|
directives by name, so a raw substring check would pass on prose)."""
|
||||||
|
out = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if s and not s.startswith('#'):
|
||||||
|
out.append(s)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def index_of(lines, pattern):
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.search(pattern, line):
|
||||||
|
return i
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerWithCoraza(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.lines = active_lines(render('hap_listener.tpl', coraza=True))
|
||||||
|
|
||||||
|
def test_body_is_buffered_before_inspection(self):
|
||||||
|
self.assertIn('option http-buffer-request', self.lines)
|
||||||
|
|
||||||
|
def test_stamp_order_strip_set_inspect_strip(self):
|
||||||
|
strip1 = index_of(self.lines, r'^http-request del-header X-WHP-WAF-Body$')
|
||||||
|
size = index_of(self.lines, r'^http-request set-var\(txn\.waf_body_size\) req\.body_size if \{ req\.hdr_cnt\(content-length\) eq 1 \}$')
|
||||||
|
complete = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(complete\) if \{ var\(txn\.waf_body_size\) -m found \} \{ req\.body_len,sub\(txn\.waf_body_size\) eq 0 \}$')
|
||||||
|
partial = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(partial\) if \{ var\(txn\.waf_body_size\) -m found \} !\{ req\.body_len,sub\(txn\.waf_body_size\) eq 0 \}$')
|
||||||
|
hdrs_len = index_of(self.lines, r'^http-request set-var\(txn\.waf_hdrs_len\) req\.hdrs,length if !\{ var\(txn\.waf_body_size\) -m found \} \{ req\.body_len gt 0 \}$')
|
||||||
|
nocl_complete = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(complete\) if \{ var\(txn\.waf_hdrs_len\) -m found \} \{ req\.body_len,add\(txn\.waf_hdrs_len\) lt (\d+) \}$')
|
||||||
|
nocl_partial = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(partial\) if !\{ var\(txn\.waf_body_size\) -m found \} \{ req\.body_len gt 0 \} !\{ var\(txn\.waf_body\) -m found \}$')
|
||||||
|
stamp = index_of(self.lines, r'^http-request set-header X-WHP-WAF-Body %\[var\(txn\.waf_body\)\] if \{ var\(txn\.waf_body\) -m found \}$')
|
||||||
|
spoe = index_of(self.lines, r'^http-request send-spoe-group coraza coraza-req$')
|
||||||
|
deny = index_of(self.lines, r'^http-request return status 403 .*var\(txn\.coraza\.action\) -m str deny')
|
||||||
|
for name, idx in (('client-copy strip', strip1), ('size var', size), ('complete', complete),
|
||||||
|
('partial', partial), ('no-CL header length', hdrs_len),
|
||||||
|
('no-CL complete', nocl_complete),
|
||||||
|
('no-CL partial', nocl_partial), ('stamp', stamp),
|
||||||
|
('send-spoe-group', spoe), ('deny', deny)):
|
||||||
|
self.assertGreaterEqual(idx, 0, f'{name} rule missing from the rendered listener')
|
||||||
|
# Client copies stripped BEFORE we stamp, stamp BEFORE the SPOE call.
|
||||||
|
self.assertLess(strip1, size)
|
||||||
|
self.assertLess(size, complete)
|
||||||
|
# The no-CL `partial` fallback relies on "-m found" of an earlier
|
||||||
|
# `complete`: order is load-bearing.
|
||||||
|
self.assertLess(size, hdrs_len)
|
||||||
|
self.assertLess(hdrs_len, nocl_complete)
|
||||||
|
self.assertLess(nocl_complete, nocl_partial)
|
||||||
|
self.assertLess(max(complete, partial, nocl_partial), stamp)
|
||||||
|
self.assertLess(stamp, spoe)
|
||||||
|
# Stripped again AFTER the SPOE call so no backend ever sees it.
|
||||||
|
strip2 = [i for i, l in enumerate(self.lines)
|
||||||
|
if l == 'http-request del-header X-WHP-WAF-Body' and i > spoe]
|
||||||
|
self.assertTrue(strip2, 'X-WHP-WAF-Body must be deleted again after send-spoe-group')
|
||||||
|
self.assertLess(strip2[0], deny)
|
||||||
|
|
||||||
|
def test_no_content_length_complete_cannot_have_filled_the_buffer(self):
|
||||||
|
"""Without a Content-Length, `complete` is inferred from "the buffer
|
||||||
|
cannot have been full". body + headers thresholds must sit well below
|
||||||
|
bufsize - maxrewrite (1024), or a truncated chunked body would be
|
||||||
|
stamped complete and a parse error on it would 403."""
|
||||||
|
line = [l for l in self.lines if 'str(complete) if { var(txn.waf_hdrs_len)' in l][0]
|
||||||
|
total_lt = int(re.search(r'add\(txn\.waf_hdrs_len\) lt (\d+)', line).group(1))
|
||||||
|
header = active_lines(render('hap_header.tpl', coraza=True))
|
||||||
|
bufsize = int([l for l in header if l.startswith('tune.bufsize')][0].split()[1])
|
||||||
|
self.assertLessEqual(total_lt, (bufsize - 1024) * 0.85)
|
||||||
|
|
||||||
|
def test_complete_and_partial_are_mutually_exclusive_conditions(self):
|
||||||
|
"""With a Content-Length, `complete` and `partial` must be the same
|
||||||
|
condition, negated -- an inverted or loosened `complete` would enforce
|
||||||
|
parse errors on truncated bodies (403 on large legitimate posts)."""
|
||||||
|
c = [l for l in self.lines if 'str(complete) if { var(txn.waf_body_size)' in l]
|
||||||
|
p = [l for l in self.lines if 'str(partial) if { var(txn.waf_body_size)' in l]
|
||||||
|
self.assertEqual((len(c), len(p)), (1, 1))
|
||||||
|
c, p = c[0], p[0]
|
||||||
|
self.assertEqual(c.split(' if ', 1)[1].replace('!{', '{'),
|
||||||
|
p.split(' if ', 1)[1].replace('!{', '{'))
|
||||||
|
self.assertIn('!{ req.body_len', p)
|
||||||
|
self.assertNotIn('!{', c)
|
||||||
|
|
||||||
|
def test_stamp_never_set_from_client_input(self):
|
||||||
|
"""The only set-header for the stamp is from our own var."""
|
||||||
|
sets = [l for l in self.lines if re.search(r'set-header\s+X-WHP-WAF-Body', l, re.I)]
|
||||||
|
self.assertEqual(len(sets), 1)
|
||||||
|
self.assertIn('%[var(txn.waf_body)]', sets[0])
|
||||||
|
self.assertFalse([l for l in self.lines if re.search(r'add-header\s+X-WHP-WAF-Body', l, re.I)])
|
||||||
|
|
||||||
|
def test_access_log_carries_coverage_fields(self):
|
||||||
|
lf = [l for l in self.lines if l.startswith('log-format ')]
|
||||||
|
self.assertEqual(len(lf), 1)
|
||||||
|
self.assertTrue(lf[0].endswith('wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)]"'))
|
||||||
|
|
||||||
|
def test_fail_open_preserved(self):
|
||||||
|
"""Nothing may deny on var(txn.coraza.error): an SPOA outage must not
|
||||||
|
503 customer sites."""
|
||||||
|
self.assertFalse([l for l in self.lines if 'txn.coraza.error' in l and
|
||||||
|
re.search(r'\b(deny|reject|return|silent-drop|tarpit)\b', l)])
|
||||||
|
|
||||||
|
|
||||||
|
class ListenerWithoutCoraza(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_waf_off_config_has_no_body_buffering(self):
|
||||||
|
lines = active_lines(render('hap_listener.tpl', coraza=False))
|
||||||
|
self.assertNotIn('option http-buffer-request', lines)
|
||||||
|
self.assertFalse([l for l in lines if 'X-WHP-WAF-Body' in l])
|
||||||
|
self.assertFalse([l for l in lines if 'send-spoe-group' in l])
|
||||||
|
|
||||||
|
def test_waf_off_header_keeps_default_bufsize(self):
|
||||||
|
lines = active_lines(render('hap_header.tpl', coraza=False))
|
||||||
|
self.assertFalse([l for l in lines if l.startswith('tune.bufsize')])
|
||||||
|
|
||||||
|
|
||||||
|
class BufsizeCeilings(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
lines = active_lines(render('hap_header.tpl', coraza=True))
|
||||||
|
found = [l for l in lines if l.startswith('tune.bufsize')]
|
||||||
|
self.assertEqual(len(found), 1, 'tune.bufsize must be pinned when Coraza is on')
|
||||||
|
self.bufsize = int(found[0].split()[1])
|
||||||
|
spoe = active_lines(haproxy_manager.template_env.get_template(
|
||||||
|
'hap_coraza_spoe_engine.tpl').render())
|
||||||
|
m = [re.match(r'timeout\s+processing\s+(\d+)ms$', l) for l in spoe]
|
||||||
|
m = [x for x in m if x]
|
||||||
|
self.assertEqual(len(m), 1)
|
||||||
|
self.processing_ms = int(m[0].group(1))
|
||||||
|
|
||||||
|
def test_bufsize_fits_the_spoe_time_budget(self):
|
||||||
|
"""~3.3 ms/KB measured; 31 KB took 104 ms against a 100 ms timeout and
|
||||||
|
FAILED OPEN. Keep the worst case at about half the budget."""
|
||||||
|
worst_ms = (self.bufsize / 1024.0) * 3.3
|
||||||
|
self.assertLessEqual(worst_ms, self.processing_ms * 0.6,
|
||||||
|
f'tune.bufsize {self.bufsize} => ~{worst_ms:.0f} ms of Coraza time '
|
||||||
|
f'against a {self.processing_ms} ms fail-open timeout')
|
||||||
|
|
||||||
|
def test_bufsize_below_spop_frame_cap(self):
|
||||||
|
self.assertLess(self.bufsize, SPOA_FRAME_CAP)
|
||||||
|
|
||||||
|
def test_body_arg_is_the_full_buffered_body(self):
|
||||||
|
"""The message must keep sending req.body unsliced: the `complete`
|
||||||
|
stamp compares req.body_len to Content-Length, so a slice would make
|
||||||
|
a truncated body look complete to Coraza."""
|
||||||
|
spoe = haproxy_manager.template_env.get_template('hap_coraza_spoe_engine.tpl').render()
|
||||||
|
args = [l.strip() for l in spoe.splitlines() if l.strip().startswith('args ')]
|
||||||
|
self.assertEqual(len(args), 1)
|
||||||
|
self.assertTrue(args[0].endswith(' body=req.body'))
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.path.exists(CORAZA_CONFIG),
|
||||||
|
'coraza-spoa/config.yaml is not shipped in this image; '
|
||||||
|
'this class runs from a repo checkout')
|
||||||
|
class CorazaBodyLimits(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
with open(CORAZA_CONFIG) as fh:
|
||||||
|
self.cfg = active_lines(fh.read())
|
||||||
|
header = active_lines(render('hap_header.tpl', coraza=True))
|
||||||
|
self.bufsize = int([l for l in header if l.startswith('tune.bufsize')][0].split()[1])
|
||||||
|
|
||||||
|
def directive(self, name):
|
||||||
|
vals = [l.split(None, 1)[1] for l in self.cfg if l.split()[0] == name]
|
||||||
|
self.assertEqual(len(vals), 1, f'{name} must be set exactly once in config.yaml')
|
||||||
|
return vals[0]
|
||||||
|
|
||||||
|
def test_limit_strictly_above_anything_haproxy_can_send(self):
|
||||||
|
limit = int(self.directive('SecRequestBodyLimit'))
|
||||||
|
self.assertGreater(limit, SPOA_FRAME_CAP)
|
||||||
|
self.assertGreater(limit, self.bufsize)
|
||||||
|
|
||||||
|
def test_over_limit_never_rejects(self):
|
||||||
|
"""Reject interrupts even under DetectionOnly -> 403 on uploads."""
|
||||||
|
self.assertEqual(self.directive('SecRequestBodyLimitAction'), 'ProcessPartial')
|
||||||
|
|
||||||
|
def test_body_buffer_stays_in_memory(self):
|
||||||
|
self.assertGreaterEqual(int(self.directive('SecRequestBodyInMemoryLimit')),
|
||||||
|
int(self.directive('SecRequestBodyLimit')))
|
||||||
|
|
||||||
|
def test_limits_are_set_after_the_recommended_file(self):
|
||||||
|
inc = self.cfg.index('Include @coraza.conf-recommended')
|
||||||
|
lim = [i for i, l in enumerate(self.cfg) if l.startswith('SecRequestBodyLimit ')][0]
|
||||||
|
self.assertLess(inc, lim, 'the recommended file would override our limits')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -106,6 +106,41 @@ global
|
|||||||
|
|
||||||
# Stats persistence for zero-downtime reloads
|
# Stats persistence for zero-downtime reloads
|
||||||
stats-file /var/lib/haproxy/stats.dat
|
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
|
# DNS resolver for Docker container name resolution
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ frontend web
|
|||||||
# host=/ua= identify the vhost and client; %ST/%B/%tsc give status,
|
# host=/ua= identify the vhost and client; %ST/%B/%tsc give status,
|
||||||
# bytes and the termination state that distinguishes a rate-limit
|
# bytes and the termination state that distinguishes a rate-limit
|
||||||
# deny (PR--) from a tarpit (PT--) from a normal close.
|
# deny (PR--) from a tarpit (PT--) from a normal close.
|
||||||
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r cip=%[var(txn.real_ip)] id=%ID host=%[capture.req.hdr(0)] ua=%[capture.req.hdr(1)] sni=%[ssl_fc_sni] hv=%[fc_http_major]"
|
# wafb=/wafe= WAF body coverage: wafb is complete|partial|- (did Coraza
|
||||||
|
# get the whole request body, see the Coraza block below);
|
||||||
|
# wafe is var(txn.coraza.error), set when the SPOE call failed or
|
||||||
|
# timed out and the request went through UNINSPECTED (fail-open).
|
||||||
|
# Both are "-" when the WAF is off or the request had no body.
|
||||||
|
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Ta %ST %B %CC %CS %tsc %ac/%fc/%bc/%sc/%rc %sq/%bq %hr %hs %{+Q}r cip=%[var(txn.real_ip)] id=%ID host=%[capture.req.hdr(0)] ua=%[capture.req.hdr(1)] sni=%[ssl_fc_sni] hv=%[fc_http_major] wafb=%[var(txn.waf_body)] wafe=%[var(txn.coraza.error)]"
|
||||||
|
|
||||||
# --- URI normalisation (MUST be the first path-touching block here) ---
|
# --- URI normalisation (MUST be the first path-touching block here) ---
|
||||||
# Every path-based control in this frontend (the ACME health-check bypass,
|
# Every path-based control in this frontend (the ACME health-check bypass,
|
||||||
@@ -611,7 +616,61 @@ frontend web
|
|||||||
# guards (no point asking the WAF about requests we're already dropping)
|
# 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).
|
# and AFTER the real-client-IP resolution (so Coraza sees the right src).
|
||||||
filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg
|
filter spoe engine coraza config /etc/haproxy/coraza-spoe.cfg
|
||||||
|
|
||||||
|
# --- Make Coraza actually see the request body -------------------------
|
||||||
|
# send-spoe-group sends `req.body` = whatever body bytes are in the buffer
|
||||||
|
# AT THAT MOMENT. Without buffering that is usually NOTHING: measured on
|
||||||
|
# haproxy 3.0.11 + coraza-spoa v0.7.1, a 14-byte `content=$a($b)` POST
|
||||||
|
# whose body was written in a second send() right after the headers
|
||||||
|
# passed 933180 (an enforced rule) uninspected, while the identical bytes
|
||||||
|
# in one send() were blocked. A body sent 2s late, or after an
|
||||||
|
# `Expect: 100-continue`, was never inspected at all.
|
||||||
|
#
|
||||||
|
# `option http-buffer-request` makes HAProxy wait, before evaluating the
|
||||||
|
# http-request rules, until the WHOLE body is in or the buffer is full,
|
||||||
|
# bounded by `timeout http-request` (30s, hap_header.tpl). It answers
|
||||||
|
# `Expect: 100-continue` itself so the client actually sends the body.
|
||||||
|
# It does NOT hold uploads: once one buffer (tune.bufsize, 16 KB) is full
|
||||||
|
# the request is forwarded and the rest streams through as before -- a
|
||||||
|
# 50 MB upload is not stored at the edge.
|
||||||
|
option http-buffer-request
|
||||||
|
|
||||||
|
# Tell Coraza whether it got the whole body. Coraza cannot tell on its
|
||||||
|
# own (REQUEST_BODY_LENGTH is only filled by the urlencoded/raw
|
||||||
|
# processors), and the difference matters: a TRUNCATED JSON body, or a
|
||||||
|
# multipart body cut inside a boundary/part header, fails to parse by
|
||||||
|
# construction, so the body-parse-error rules (200002/200003) may only
|
||||||
|
# deny a COMPLETE body. WHP's generated Coraza rules
|
||||||
|
# (regenerate-coraza-overrides.sh) key on this header:
|
||||||
|
# complete -> 200002/200003 deny on multipart/JSON (enforce mode)
|
||||||
|
# partial -> 200002/200003 removed for this request (every mode)
|
||||||
|
#
|
||||||
|
# With a Content-Length: complete iff req.body_len (buffered) equals
|
||||||
|
# req.body_size (= Content-Length).
|
||||||
|
# Without one (HTTP/1.1 chunked, HTTP/2-3 without content-length) HAProxy
|
||||||
|
# 3.0 has no "end of message" fetch and req.body_size is just what is
|
||||||
|
# buffered. But http-buffer-request only releases the request early when
|
||||||
|
# the body ENDED or the buffer FILLED (a stalled body gets a 408 instead),
|
||||||
|
# and body + headers well below one buffer (12 KB of the ~15 KB usable)
|
||||||
|
# cannot have filled it. So that is complete, anything else partial.
|
||||||
|
# Without this branch an attacker only had to add
|
||||||
|
# `Transfer-Encoding: chunked` to escape enforcement (measured).
|
||||||
|
#
|
||||||
|
# Client-supplied copies are deleted FIRST so it cannot be forged, and it
|
||||||
|
# is deleted again after the SPOE call so backends never see it.
|
||||||
|
# txn.waf_body is also logged (`wafb=` in the log-format above) so the
|
||||||
|
# complete/partial mix is measurable per host.
|
||||||
|
http-request del-header X-WHP-WAF-Body
|
||||||
|
http-request set-var(txn.waf_body_size) req.body_size if { req.hdr_cnt(content-length) eq 1 }
|
||||||
|
http-request set-var(txn.waf_body) str(complete) if { var(txn.waf_body_size) -m found } { req.body_len,sub(txn.waf_body_size) eq 0 }
|
||||||
|
http-request set-var(txn.waf_body) str(partial) if { var(txn.waf_body_size) -m found } !{ req.body_len,sub(txn.waf_body_size) eq 0 }
|
||||||
|
http-request set-var(txn.waf_hdrs_len) req.hdrs,length if !{ var(txn.waf_body_size) -m found } { req.body_len gt 0 }
|
||||||
|
http-request set-var(txn.waf_body) str(complete) if { var(txn.waf_hdrs_len) -m found } { req.body_len,add(txn.waf_hdrs_len) lt 12288 }
|
||||||
|
http-request set-var(txn.waf_body) str(partial) if !{ var(txn.waf_body_size) -m found } { req.body_len gt 0 } !{ var(txn.waf_body) -m found }
|
||||||
|
http-request set-header X-WHP-WAF-Body %[var(txn.waf_body)] if { var(txn.waf_body) -m found }
|
||||||
|
|
||||||
http-request send-spoe-group coraza coraza-req
|
http-request send-spoe-group coraza coraza-req
|
||||||
|
http-request del-header X-WHP-WAF-Body
|
||||||
|
|
||||||
# Enforce Coraza's verdict. The SPOA sets var(txn.coraza.action) to
|
# Enforce Coraza's verdict. The SPOA sets var(txn.coraza.action) to
|
||||||
# "deny" / "drop" / "redirect" when a rule with the corresponding
|
# "deny" / "drop" / "redirect" when a rule with the corresponding
|
||||||
|
|||||||
Reference in new issue
Block a user