From 2da202030571585fc562eca149cb5895c41e84cc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 9 Oct 2026 14:10:41 -0700 Subject: [PATCH] fix(waf): stop promoting 944110/944120 chain starters (round 3) Owner ruling (same mode contract as 920440: no accidental enforcement from bare words). 944110 and 944120 are CRS chains whose first link matches `runtime|processbuilder` / `getproperty|forclosure|filewriter|...` anywhere in the request; the range promotion 944100-944300 put ctl:ruleEngine=On on those starters, so any request containing the word ran with full anomaly enforcement, in detect_only too. Promotion is now 944100 and 944130-944300. Live (haproxy-manager-base :wafbody-r3 + coraza-spoa :wafbody-r3), both modes: `?q=runtime&` and `?q=getproperty&` 403 -> 200 on detect_only; Log4Shell in the query (plain and ${lower:j} obfuscated), User-Agent and a JSON body still 403. test-waf-body-inspection.py: 21 tests (+2: no CRS chain starter 920440/944110/944120 is covered by a promotion; the Log4Shell ids keep theirs). Red on round 2. Co-Authored-By: Claude Opus 5.5 --- coraza-spoa/overrides.conf | 12 +++++++++++- scripts/test-waf-body-inspection.py | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/coraza-spoa/overrides.conf b/coraza-spoa/overrides.conf index b506f03..f44f7f3 100644 --- a/coraza-spoa/overrides.conf +++ b/coraza-spoa/overrides.conf @@ -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 diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index 05cfb4d..7b264aa 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -240,6 +240,28 @@ class BakedPromotions(unittest.TestCase): 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],