diff --git a/coraza-spoa/overrides.conf b/coraza-spoa/overrides.conf index d8a7383..b506f03 100644 --- a/coraza-spoa/overrides.conf +++ b/coraza-spoa/overrides.conf @@ -59,17 +59,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. # --------------------------------------------------------------------------- -# WARNING (measured 2026-10-09, Coraza v3.7.0 + CRS 4.25.0, in-process AND -# live behind haproxy): 920440 is a CHAIN whose first link is +# 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. Coraza runs the appended ctl:ruleEngine=On when that first link -# matches, even though the chain then fails. So on any *.php / *.css / ... -# request the engine is On for the rest of the transaction: 949110 enforces -# the whole CRS anomaly score (a 942100 SQLi GET is 403 on /x.php, 200 on /x) -# and 200002 denies body-parse errors -- in detect_only mode too, because -# SecRuleEngine DetectionOnly cannot override a runtime ctl. Not changed here -# (it would loosen live blocking fleet-wide); see waf-body-hardening-report.md. -SecRuleUpdateActionById 920440 "ctl:ruleEngine=On" +# 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 diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index 9e162bb..05cfb4d 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -217,6 +217,35 @@ class BufsizeCeilings(unittest.TestCase): 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_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')