Merge feat/waf-body-hardening into release/2026.10.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
3712860429
9 files changed
+491
-6
No files matched your search
@@ -11,6 +11,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **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.
|
||||
- **API thread-pool safety**: `python3 scripts/test-gunicorn-socket-timeouts.py` - runs REAL gunicorn (~60s); asserts a silent/half-written client cannot pin a gthread pool thread (per-connection socket timeout, `HAPROXY_MGR_CLIENT_TIMEOUT`), 2050 never-sending connections are reaped (`HAPROXY_MGR_FIRST_BYTE_TIMEOUT`), slowloris header drips are cut (`HAPROXY_MGR_HEADER_DEADLINE`, counted from enqueue), 40 reconnecting drippers cannot starve legit requests, the :8000 peer filter (`HAPROXY_MGR_API_ALLOWED_PEERS`) refuses non-allowed peers, slow legit requests are not cut off, the wedge watchdog (`HAPROXY_MGR_WEDGE_SECONDS`) replaces a fully stuck worker, and an in-process call to a stalled upstream times out. Run it after touching `haproxy_gunicorn_worker.py`, start-up.sh's gunicorn lines, or upgrading gunicorn (the worker overrides gthread internals: `enqueue_req`, `accept`, `on_client_socket_readable`, `murder_keepalived`, `handle`, `handle_request`).
|
||||
- **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
|
||||
|
||||
### Reading stick tables (and why it is easy to get silently wrong)
|
||||
|
||||
+3
-2
@@ -93,8 +93,9 @@ RUN python3 /haproxy/scripts/test-wpadmin-gate.py \
|
||||
&& python3 /haproxy/scripts/test-config-rollback.py \
|
||||
&& python3 /haproxy/scripts/test-cert-write-safety.py \
|
||||
&& python3 /haproxy/scripts/test-cert-scripts.py \
|
||||
&& python3 /haproxy/scripts/validate-rendered-config.py \
|
||||
&& python3 /haproxy/scripts/test-gunicorn-socket-timeouts.py
|
||||
&& python3 /haproxy/scripts/test-waf-body-inspection.py \
|
||||
&& python3 /haproxy/scripts/test-gunicorn-socket-timeouts.py \
|
||||
&& python3 /haproxy/scripts/validate-rendered-config.py
|
||||
# Create log directories
|
||||
RUN mkdir -p /var/log && touch /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||
RUN chmod 755 /var/log/haproxy-manager.log /var/log/haproxy-manager-errors.log
|
||||
|
||||
@@ -26,6 +26,37 @@ applications:
|
||||
# CRS-bundled defaults: recommended Coraza settings + CRS setup +
|
||||
# the rule pack itself (~16 MB of rules embedded in the binary).
|
||||
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
|
||||
|
||||
# JSON nesting limit (the recommended file sets 1024). Coraza's JSON
|
||||
# flattener is quadratic in nesting: a 601-byte `[[[...]]]` of depth 300
|
||||
# cost 297 ms and depth 1000 (2 KB) 3.1 s of CPU -- past the 100 ms SPOE
|
||||
# timeout, so the request passed UNINSPECTED (fail-open) while the WAF
|
||||
# burned CPU. Over the limit the body fails to parse (REQBODY_ERROR,
|
||||
# 0.1 ms), which WHP's enforce-mode body-integrity rule denies on a
|
||||
# complete body. Legitimate depth, measured on the JSON shipped in
|
||||
# WordPress 6.x core, Gutenberg, WooCommerce and Elementor (theme.json,
|
||||
# styles/*.json, block.json, checkout schemas): max 8, so a global-styles
|
||||
# save is ~9; 64 leaves ~7x headroom for Divi/Elementor-style module
|
||||
# trees that could not be sampled. Depth 64 itself costs ~21 ms.
|
||||
SecRequestBodyJsonDepthLimit 64
|
||||
|
||||
Include @crs-setup.conf.example
|
||||
|
||||
# Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default.
|
||||
|
||||
@@ -47,7 +47,17 @@ SecRuleUpdateActionById 933170-933200 "ctl:ruleEngine=On"
|
||||
# vulnerable, but blocking at the edge keeps logs clean and protects
|
||||
# any future Java workloads.
|
||||
# ---------------------------------------------------------------------------
|
||||
SecRuleUpdateActionById 944100-944300 "ctl:ruleEngine=On"
|
||||
# 944110 and 944120 are NOT promoted (changed 2026-10-09, 2026.10.2). Both are
|
||||
# CHAINS whose first link matches bare words anywhere in the request --
|
||||
# `runtime|processbuilder` and `getproperty|forclosure|filewriter|...` -- and
|
||||
# Coraza runs an appended ctl:ruleEngine=On on that first link even when the
|
||||
# chain then fails. Measured (Coraza v3.7.0 + CRS 4.25.0): in detect_only,
|
||||
# `?q=runtime&id=1' or '1'='1` was 403 while the SQLi alone was 200. Same
|
||||
# chain-starter defect as 920440. They still add to the anomaly score.
|
||||
# Log4Shell (944150/944151/944152, ...) stays promoted. WHP mirrors this split
|
||||
# in coraza_baked_promotion_ranges() (web-files/libs/coraza_blocking_set.php).
|
||||
SecRuleUpdateActionById 944100 "ctl:ruleEngine=On"
|
||||
SecRuleUpdateActionById 944130-944300 "ctl:ruleEngine=On"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 920440 — URL file extension restricted by policy
|
||||
@@ -59,7 +69,26 @@ SecRuleUpdateActionById 944100-944300 "ctl:ruleEngine=On"
|
||||
# single GCP-hosted scanner. Zero false positives observed; standard
|
||||
# WP/WooCommerce/Divi/HPR URLs do not end in these extensions.
|
||||
# ---------------------------------------------------------------------------
|
||||
SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"
|
||||
# HOW IT IS PROMOTED (changed 2026-10-09, haproxy-manager-base 2026.10.2).
|
||||
# This used to be `SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"`, which
|
||||
# was WRONG: 920440 is a CHAIN whose first link is
|
||||
# `REQUEST_BASENAME "@rx \.([^.]+)$"` -- it matches EVERY URL with a file
|
||||
# extension -- and Coraza runs actions appended to a chain starter when that
|
||||
# first link matches, even though the chain then fails. Measured (Coraza
|
||||
# v3.7.0 + CRS 4.25.0, in-process and live): every *.php / *.css / ...
|
||||
# request ran with the engine On, so the whole CRS anomaly score (949110) and
|
||||
# 200002 enforced there -- in detect_only mode too, since SecRuleEngine
|
||||
# DetectionOnly cannot override a runtime ctl.
|
||||
#
|
||||
# Now: 920440 itself is untouched (it still sets tx.extension in its first
|
||||
# link and adds the anomaly score in its second), and this separate phase-1
|
||||
# rule, which runs after it, flips the engine only when the extension really
|
||||
# is in tx.restricted_extensions -- the original intent. Removing 920440
|
||||
# (break-glass, a per-host exception) also removes tx.extension, so this rule
|
||||
# goes quiet with it. Id 992920440 is outside WHP's generated range
|
||||
# (990000000-991999999).
|
||||
SecRule TX:EXTENSION "@within %{tx.restricted_extensions}" \
|
||||
"id:992920440,phase:1,pass,nolog,t:none,t:lowercase,ctl:ruleEngine=On"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 930130 — Restricted File Access Attempt
|
||||
|
||||
@@ -2799,6 +2799,7 @@ def generate_config():
|
||||
default_headers = template_env.get_template('hap_header.tpl').render(
|
||||
cluster_secret = get_or_create_cluster_secret(),
|
||||
syslog_target = syslog_target,
|
||||
coraza_spoe_backend = coraza_spoe_backend,
|
||||
)
|
||||
config_parts.append(default_headers)
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/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')
|
||||
dup_ct = index_of(self.lines, r'^http-request deny deny_status 400 if \{ req\.fhdr_cnt\(content-type\) gt 1 \}$')
|
||||
self.assertGreaterEqual(dup_ct, 0, 'duplicate Content-Type must be rejected at the edge')
|
||||
self.assertLess(dup_ct, spoe, 'and before Coraza is asked')
|
||||
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'))
|
||||
|
||||
|
||||
CORAZA_OVERRIDES = os.path.join(MODULE_DIR, 'coraza-spoa', 'overrides.conf')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.path.exists(CORAZA_OVERRIDES),
|
||||
'coraza-spoa/overrides.conf is not shipped in this image; '
|
||||
'this class runs from a repo checkout')
|
||||
class BakedPromotions(unittest.TestCase):
|
||||
"""920440 is a CHAIN whose first link matches every URL with a file
|
||||
extension. `SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"` ran on that
|
||||
first link, so every *.php / *.css request was evaluated with the engine
|
||||
On (full CRS anomaly enforcement, in detect_only mode too). Measured
|
||||
in-process and live, 2026-10-09; fixed by keying the flip on the chain's
|
||||
real condition."""
|
||||
|
||||
def setUp(self):
|
||||
with open(CORAZA_OVERRIDES) as fh:
|
||||
self.lines = active_lines(fh.read())
|
||||
|
||||
def test_920440_is_not_promoted_on_its_chain_starter(self):
|
||||
self.assertFalse([l for l in self.lines
|
||||
if re.match(r'SecRuleUpdateActionById\s+(\S*-)?920440\b', l)],
|
||||
'920440 promoted via SecRuleUpdateActionById again')
|
||||
|
||||
def test_no_chain_starter_is_promoted(self):
|
||||
"""Chain-starter rules in the promoted ranges (CRS 4.25.0): 920440,
|
||||
944110, 944120. None may be covered by a SecRuleUpdateActionById."""
|
||||
chain_starters = (920440, 944110, 944120)
|
||||
promoted = []
|
||||
for l in self.lines:
|
||||
m = re.match(r'SecRuleUpdateActionById\s+(\d+)(?:-(\d+))?\s', l)
|
||||
if m:
|
||||
lo = int(m.group(1))
|
||||
promoted.append((lo, int(m.group(2) or lo)))
|
||||
self.assertTrue(promoted, 'no promotions parsed -- the test is blind')
|
||||
for rid in chain_starters:
|
||||
self.assertFalse([r for r in promoted if r[0] <= rid <= r[1]],
|
||||
f'{rid} is a CRS chain starter and must not be promoted')
|
||||
|
||||
def test_log4shell_range_still_promoted(self):
|
||||
for rid in (944100, 944130, 944150, 944151, 944152, 944300):
|
||||
self.assertTrue([1 for l in self.lines
|
||||
if (m := re.match(r'SecRuleUpdateActionById\s+(\d+)(?:-(\d+))?\s', l))
|
||||
and int(m.group(1)) <= rid <= int(m.group(2) or m.group(1))],
|
||||
f'{rid} lost its promotion')
|
||||
|
||||
def test_920440_flip_keys_on_the_restricted_extension(self):
|
||||
i = self.lines.index('SecRule TX:EXTENSION "@within %{tx.restricted_extensions}" \\')
|
||||
self.assertEqual(self.lines[i + 1],
|
||||
'"id:992920440,phase:1,pass,nolog,t:none,t:lowercase,ctl:ruleEngine=On"')
|
||||
|
||||
|
||||
@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_json_depth_limit_pinned(self):
|
||||
"""Quadratic JSON flattening: depth 300 = 297 ms, past the fail-open
|
||||
timeout. Legitimate WordPress JSON measured at depth <= 9."""
|
||||
depth = int(self.directive('SecRequestBodyJsonDepthLimit'))
|
||||
self.assertGreaterEqual(depth, 32, 'too tight for real block/theme payloads')
|
||||
self.assertLessEqual(depth, 64, 'depth 100 already costs ~40 ms')
|
||||
|
||||
def test_limits_are_set_after_the_recommended_file(self):
|
||||
inc = self.cfg.index('Include @coraza.conf-recommended')
|
||||
for name in ('SecRequestBodyLimit ', 'SecRequestBodyJsonDepthLimit '):
|
||||
lim = [i for i, l in enumerate(self.cfg) if l.startswith(name)][0]
|
||||
self.assertLess(inc, lim, f'the recommended file would override {name.strip()}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@@ -106,6 +106,41 @@ global
|
||||
|
||||
# Stats persistence for zero-downtime reloads
|
||||
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
|
||||
|
||||
@@ -63,7 +63,12 @@ frontend web
|
||||
# host=/ua= identify the vhost and client; %ST/%B/%tsc give status,
|
||||
# bytes and the termination state that distinguishes a rate-limit
|
||||
# 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) ---
|
||||
# Every path-based control in this frontend (the ACME health-check bypass,
|
||||
@@ -611,7 +616,74 @@ frontend web
|
||||
# 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).
|
||||
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
|
||||
|
||||
# Exactly one Content-Type, or 400. With two Content-Type lines Coraza and
|
||||
# the backend disagree about what the body IS: Coraza evaluates every value
|
||||
# (`text/xml` on either line selects its XML processor, which accepts a
|
||||
# form body without error), while Apache merges the lines with ", " and
|
||||
# PHP cuts the media type at the first ',' / ';'. Measured (Coraza v3.7.0
|
||||
# + PHP 8.3): `application/x-www-form-urlencoded` + `text/xml` puts the
|
||||
# payload in $_POST with nothing inspected. req.fhdr_cnt counts header
|
||||
# LINES, so a single comma-joined value (which browsers produce when a
|
||||
# script calls setRequestHeader twice) is not affected here; that form is
|
||||
# parsed consistently enough by both sides (see the report's matrix).
|
||||
# Browsers, curl, PHP-curl and the common HTTP libraries send one line.
|
||||
http-request deny deny_status 400 if { req.fhdr_cnt(content-type) gt 1 }
|
||||
|
||||
# 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 del-header X-WHP-WAF-Body
|
||||
|
||||
# Enforce Coraza's verdict. The SPOA sets var(txn.coraza.action) to
|
||||
# "deny" / "drop" / "redirect" when a rule with the corresponding
|
||||
|
||||
Reference in new issue
Block a user