Merge pull request 'fix(edge): CVE-2026-109618/-109619 multipart mitigations (2026.10.6)' (#13) from fix/cve-2026-109618-multipart-edge into main
Merge pull request #13: CVE-2026-109618/-109619 multipart edge mitigations (2026.10.6)
This commit was merged in pull request #13.
This commit is contained in:
commit
4345732dad
4 files changed
+279
-1
No files matched your search
@@ -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 <input
|
||||
# type=file>; 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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user