Files
haproxy-manager-base/scripts/test-multipart-cve-mitigation.py
T
shadowdaoandClaude Opus 5.5 729098a8f9 fix(edge): CVE-2026-109618/-109619 multipart mitigations (2026.10.6)
PHP "RCE via OOM corruption" (GHSA-w8hw-w9fx-j79w) and the session
upload-progress variant (GHSA-6wmf-4rv5-p3rp), interim until patched PHP is
on every customer image.

HAProxy (hap_listener.tpl, every frontend render, WAF on or off):
  deny 400 multipart/form-data with no Content-Length (HTTP/1.1 chunked,
  HTTP/2-3 streamed bodies) -- vector (a), the post_max_size bypass.
  Measured: Chrome 155 (h2 and h3) and Firefox 153 send content-length on
  form submits, FormData fetch and XHR; only a ReadableStream fetch body
  (duplex:'half') has none.

Coraza (coraza-spoa/overrides.conf, 209610-209616):
  209610       ARGS_NAMES/FILES_NAMES with more than 16 '['
  209611-613   part-header budget (4/part, deny 32 below zero)
  209614-615   more than 64 empty-filename file parts
  209616       multipart PHP_SESSION_UPLOAD_PROGRESS field
Ids sit in the engine range 200000-209999 that WHP's break-glass rule
(991000001) already removes, so no WHP release is needed for break-glass
to lift them. Each flips the engine On for itself, like the day-one
promotions, so they deny in every mode the sidecar runs in.

Limitation: Coraza sees only the first 16 KB of a body.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 07:27:42 -07:00

157 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Contract tests for the CVE-2026-109618 / -109619 edge mitigations.
* templates/hap_listener.tpl denies multipart/form-data without a
Content-Length (400), with AND without the Coraza SPOE block rendered.
* coraza-spoa/overrides.conf carries the body rules 209610-209616.
What these rules DO is measured elsewhere: the Coraza verdicts by WHP's
scripts/tests/coraza-body-parse-harness (cve109618.go, run with
HMB_OVERRIDES pointing at this checkout's overrides.conf), the HAProxy verdict
by live curl against a host. This file pins the invariants that make those
measurements stay true:
* the deny is outside the `coraza_spoe_backend` conditional;
* it keys on the Content-Length HEADER count, never req.body_size (see the
H3 note in hap_listener.tpl);
* every 2096xx id is inside 200000-209999 -- WHP's break-glass rule
(991000001) removes exactly that range, so an id outside it would stay
armed for a site in emergency mode;
* no ctl:ruleEngine=On sits on a chain STARTER (Coraza runs it as soon as
the first link matches -- the 920440 defect).
Running
-------
python3 scripts/test-multipart-cve-mitigation.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
CORAZA_OVERRIDES = os.path.join(MODULE_DIR, 'coraza-spoa', 'overrides.conf')
ENGINE_LO, ENGINE_HI = 200000, 209999 # WHP_WAF_ENGINE_RANGE_LO..HI
CVE_IDS = range(209610, 209617)
def render(coraza):
kw = dict(crt_path='/etc/haproxy/certs', suspension_enabled=True,
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('hap_listener.tpl').render(**kw)
def active_lines(text):
return [l.strip() for l in text.splitlines()
if l.strip() and not l.strip().startswith('#')]
DENY_RE = re.compile(
r'^http-request deny deny_status 400 .*\bif is_multipart_form '
r'!\{ req\.hdr_cnt\(content-length\) gt 0 \}$')
class ListenerDeny(unittest.TestCase):
def check(self, coraza):
lines = active_lines(render(coraza))
acl = [i for i, l in enumerate(lines) if l.startswith('acl is_multipart_form ')]
deny = [i for i, l in enumerate(lines) if DENY_RE.match(l)]
self.assertEqual(len(acl), 1, 'is_multipart_form acl missing or duplicated')
self.assertEqual(len(deny), 1, 'multipart no-Content-Length deny missing or duplicated')
self.assertEqual(lines[acl[0]],
'acl is_multipart_form req.fhdr(content-type) -m sub -i multipart/form-data')
self.assertLess(acl[0], deny[0])
self.assertNotIn('body_size', lines[deny[0]])
# after the real-IP resolution, before any backend selection rule
real_ip = max(i for i, l in enumerate(lines) if 'set-var(txn.real_ip)' in l)
self.assertLess(real_ip, deny[0])
if coraza:
spoe = [i for i, l in enumerate(lines) if l.startswith('http-request send-spoe-group')]
self.assertTrue(spoe, 'coraza render has no send-spoe-group -- test is blind')
self.assertLess(deny[0], spoe[0], 'deny must run before the WAF is consulted')
def test_with_coraza(self):
self.check(True)
def test_without_coraza(self):
self.check(False)
@unittest.skipUnless(os.path.exists(CORAZA_OVERRIDES),
'coraza-spoa/overrides.conf is not shipped in this image')
class OverrideRules(unittest.TestCase):
def setUp(self):
with open(CORAZA_OVERRIDES) as fh:
text = fh.read()
# Join backslash continuations so one directive is one string.
joined = re.sub(r'\\\n\s*', '', text)
self.rules = [l.strip() for l in joined.splitlines()
if l.strip().startswith('SecRule') or l.strip().startswith('SecAction')]
def ids(self):
out = []
for r in self.rules:
m = re.search(r'\bid:(\d+)', r)
if m:
out.append(int(m.group(1)))
return out
def test_every_cve_id_present_once(self):
ids = self.ids()
for rid in CVE_IDS:
self.assertEqual(ids.count(rid), 1, f'{rid} missing or duplicated')
def test_every_id_inside_the_break_glass_engine_range(self):
# every rule that belongs to the mitigation, found by what it DOES
# (its tag or its tx variables), not by its id
cve = [int(re.search(r'\bid:(\d+)', r).group(1)) for r in self.rules
if re.search(r'\bid:\d+', r)
and ('whp/cve-2026-1096' in r or 'whp_cve109618' in r)]
self.assertGreaterEqual(len(cve), 7, 'CVE rules not found -- the test is blind')
self.assertTrue(cve, 'no CVE ids parsed -- the test is blind')
for rid in cve:
self.assertTrue(ENGINE_LO <= rid <= ENGINE_HI, rid)
def test_deny_rules_flip_the_engine_for_themselves(self):
denies = [r for r in self.rules if re.search(r'\bid:2096\d\d\b', r) and ',deny,' in r]
self.assertEqual(sorted(int(re.search(r'id:(\d+)', r).group(1)) for r in denies),
[209610, 209613, 209615, 209616])
for i, r in enumerate(self.rules):
if r not in denies:
continue
if ',chain' in r:
self.assertNotIn('ctl:ruleEngine', r, 'engine flip on a chain starter')
self.assertIn('ctl:ruleEngine=On', self.rules[i + 1])
else:
self.assertIn('ctl:ruleEngine=On', r)
def test_no_chain_starter_anywhere_in_the_block_flips_the_engine(self):
for r in self.rules:
if re.search(r'\bid:2096\d\d\b', r) and ',chain' in r:
self.assertNotIn('ctl:ruleEngine', r)
if __name__ == '__main__':
unittest.main(verbosity=2)