diff --git a/VERSION b/VERSION index d3d8916..7fed436 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.10.5 +2026.10.6 diff --git a/coraza-spoa/overrides.conf b/coraza-spoa/overrides.conf index f44f7f3..3d9fe05 100644 --- a/coraza-spoa/overrides.conf +++ b/coraza-spoa/overrides.conf @@ -105,6 +105,100 @@ SecRule TX:EXTENSION "@within %{tx.restricted_extensions}" \ # --------------------------------------------------------------------------- SecRuleUpdateActionById 930130 "ctl:ruleEngine=On" +# --------------------------------------------------------------------------- +# 209610-209616 -- PHP multipart "RCE via OOM corruption" mitigations +# CVE-2026-109618 (GHSA-w8hw-w9fx-j79w) and CVE-2026-109619 +# (GHSA-6wmf-4rv5-p3rp). Interim edge mitigation until patched PHP is on every +# customer image; remove once it is. haproxy-manager-base 2026.10.6. +# +# The advisory's request-body vectors and what covers each: +# (a) multipart with no usable Content-Length (chunked / H2-H3 without +# content-length) streams past post_max_size -> denied 400 by HAProxy +# itself (templates/hap_listener.tpl), before any of this runs. +# (b) unlimited headers per multipart part -> 209611-209613 (budget) +# (c) empty file controls (do not count toward max_file_uploads) +# + deeply nested field names (a[][][]...) -> 209614-209615, 209610 +# -619: session upload progress during multipart -> 209616 +# +# WHY THESE IDS. 200000-209999 is the Coraza engine range, and WHP's site +# break-glass rule (991000001) removes that WHOLE range for a site in +# emergency mode (coraza_blocking_set_ranges(), WHP_WAF_ENGINE_RANGE_LO..HI in +# web-files/libs/coraza_blocking_set.php). Rules that deny on their own MUST be +# inside the derived blocking set or break-glass leaves them armed; inside the +# engine range they are covered without a WHP release. The engine itself uses +# only 200000-200006 (coraza.conf-recommended); CRS uses none of 2xxxxx. WHP +# also treats the range as staff-only for self-service exceptions. +# +# They switch the engine On for themselves (ctl:ruleEngine=On), exactly like +# the day-one promotions above, so they deny in every mode the sidecar runs in. +# +# LIMITATION, read before trusting them: HAProxy hands Coraza only the first +# buffer of the body (tune.bufsize 16 KB). A request that puts its abusive +# parts after ~16 KB of padding, or whose single part has a >16 KB header +# block, is not seen here. Vector (a) is the one enforced on the full request. +# Coraza's multipart parser (Go mime/multipart) also canonicalises part +# header names and stops at the first part it cannot read. +# --------------------------------------------------------------------------- + +# (c) Field-name nesting. Counts '[' in every ARGS / FILES name (query, +# urlencoded and multipart names are all raw, PHP-shaped here; Coraza's JSON +# processor names args json.a.b and never produces '['). WordPress-ecosystem +# form names nest a few levels (menu-item-settings[12][title], +# variable_regular_price[0], input_3[]); PHP's own max_input_nesting_level +# is 64, so 16 is far above real forms and far below PHP's ceiling. +SecRule ARGS_NAMES|FILES_NAMES "@rx (?:[^\[]*\[){17}" \ + "id:209610,phase:2,deny,status:403,log,t:none,\ + msg:'Parameter name nested more than 16 levels deep (CVE-2026-109618 mitigation)',\ + logdata:'%{MATCHED_VAR_NAME}',tag:'whp/cve-2026-109618',ctl:ruleEngine=On" + +# (b) Part-header budget. A legitimate part carries 1-2 headers +# (Content-Disposition, usually Content-Type; a few clients add +# Content-Transfer-Encoding). Budget = 4 per part - 1 per part header, and +# the request is denied once it is 32 below zero: a single part needs 37+ +# headers, and parts averaging 4 or fewer headers never trip it however many +# there are. Parts are counted by what they BECAME (each part is exactly one +# ARGS_POST or FILES entry), not by Content-Disposition lines, because a part +# can repeat Content-Disposition as easily as any other header. +# @unconditionalMatch runs setvar once PER VALUE (measured), and runs on +# empty values too, so empty fields and empty files are counted. +# No REQBODY_PROCESSOR chain on 209611: a chained link's setvar would run once +# per request, not once per part. On a non-multipart body it only RAISES the +# budget, and MULTIPART_PART_HEADERS (the only thing that lowers it) is empty. +SecRule ARGS_POST|FILES "@unconditionalMatch" \ + "id:209611,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_hdr_budget=+4" +SecRule MULTIPART_PART_HEADERS "@unconditionalMatch" \ + "id:209612,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_hdr_budget=-1" +SecRule TX:whp_cve109618_hdr_budget "@lt -32" \ + "id:209613,phase:2,deny,status:403,log,t:none,\ + msg:'Multipart part(s) with an excessive number of headers (CVE-2026-109618 mitigation)',\ + logdata:'budget %{tx.whp_cve109618_hdr_budget}',tag:'whp/cve-2026-109618',ctl:ruleEngine=On" + +# (c) Empty file controls: parts with an EMPTY filename (`filename=""` or +# `filename=`), which PHP does not count toward max_file_uploads. Coraza turns +# such a part into an ARGS_POST field (no FILES entry), so they are counted +# from the raw part headers. A browser sends one per untouched ; legitimate forms have a handful. Denied above 64. +SecRule MULTIPART_PART_HEADERS "@rx (?i)^content-disposition:.*;\s*filename\s*=\s*(?:\"\"|;|$)" \ + "id:209614,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_empty_files=+1" +SecRule TX:whp_cve109618_empty_files "@gt 64" \ + "id:209615,phase:2,deny,status:403,log,t:none,\ + msg:'Multipart body with more than 64 empty file parts (CVE-2026-109618 mitigation)',\ + logdata:'%{tx.whp_cve109618_empty_files} empty file parts',tag:'whp/cve-2026-109618',ctl:ruleEngine=On" + +# -619: session upload progress. The engine flip sits on the LAST chain link +# on purpose: Coraza runs a chain starter's ctl as soon as the first link +# matches, even if the chain then fails (the 920440/944110 defect above). +# PHP only tracks upload progress for a +# multipart field named session.upload_progress.name (default +# PHP_SESSION_UPLOAD_PROGRESS, compared case-sensitively) that precedes the +# file parts, so that is exactly what is denied. WordPress core, WooCommerce, +# Divi, Gravity Forms and CF7 do not use the feature. +SecRule ARGS_POST_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS" \ + "id:209616,phase:2,deny,status:403,log,t:none,chain,\ + msg:'Multipart session upload-progress field (CVE-2026-109619 mitigation)',\ + tag:'whp/cve-2026-109619'" +SecRule REQBODY_PROCESSOR "@streq MULTIPART" "t:none,ctl:ruleEngine=On" + # --------------------------------------------------------------------------- # Rule families intentionally kept at DETECT-ONLY for v1 — high FP rate # on customer mix. Promote individually after observation: diff --git a/scripts/test-multipart-cve-mitigation.py b/scripts/test-multipart-cve-mitigation.py new file mode 100644 index 0000000..6216dd6 --- /dev/null +++ b/scripts/test-multipart-cve-mitigation.py @@ -0,0 +1,156 @@ +#!/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) diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index ed8fb27..a916303 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -616,6 +616,34 @@ frontend web http-request set-path /suspended if is_suspended_domain use_backend default-backend if is_suspended_domain {%- endif %} + + # CVE-2026-109618 (GHSA-w8hw-w9fx-j79w, PHP "RCE via OOM corruption"), + # vector (a): a multipart/form-data body with no declared length is not + # bounded by post_max_size -- PHP's multipart parser keeps reading it. A + # body WITH a Content-Length is checked against post_max_size before it is + # parsed. So a multipart request must carry exactly what every browser and + # HTTP library already sends for a form: a Content-Length. + # + # req.hdr_cnt(content-length) is the header as the client sent it, on + # every protocol: HTTP/1.1 chunked has none (and the H1 mux DROPS a + # Content-Length sent alongside Transfer-Encoding, so smuggling one in + # beside chunked does not pass either); HTTP/2 and HTTP/3 have none when + # the client streams the body. H1/H2/H3 all check a declared + # content-length against the DATA received, so it cannot lie about the + # length (same reasoning as the WAF body stamp below). + # Browsers send content-length for form posts and FormData uploads on + # h1/h2/h3; the only body a browser sends without one is a fetch() with a + # ReadableStream body (Chrome, h2/h3 only, `duplex: 'half'`), which is not + # what form uploads use. + # + # Not WAF-gated and not exempt for trusted IPs: this is a protocol + # guarantee the vulnerable parser needs, independent of Coraza and its + # mode. req.fhdr (not req.hdr) so a comma inside the value cannot split it. + # The rest of the advisory (part-header floods, empty file controls, deep + # field names) needs the body and lives in coraza-spoa/overrides.conf + # (209610-209616). Remove once patched PHP is on every customer image. + acl is_multipart_form req.fhdr(content-type) -m sub -i multipart/form-data + http-request deny deny_status 400 hdr x-whp-edge-block "multipart-without-content-length" if is_multipart_form !{ req.hdr_cnt(content-length) gt 0 } {%- if coraza_spoe_backend %} # Coraza WAF inspection via SPOE. Runs AFTER rate-limit and IP-block