diff --git a/coraza-spoa/config.yaml b/coraza-spoa/config.yaml index cd4a82e..70280cf 100644 --- a/coraza-spoa/config.yaml +++ b/coraza-spoa/config.yaml @@ -44,6 +44,19 @@ applications: SecRequestBodyInMemoryLimit 131072 SecRequestBodyLimitAction ProcessPartial + # JSON nesting limit (the recommended file sets 1024). Coraza's JSON + # flattener is quadratic in nesting: a 601-byte `[[[...]]]` of depth 300 + # cost 297 ms and depth 1000 (2 KB) 3.1 s of CPU -- past the 100 ms SPOE + # timeout, so the request passed UNINSPECTED (fail-open) while the WAF + # burned CPU. Over the limit the body fails to parse (REQBODY_ERROR, + # 0.1 ms), which WHP's enforce-mode body-integrity rule denies on a + # complete body. Legitimate depth, measured on the JSON shipped in + # WordPress 6.x core, Gutenberg, WooCommerce and Elementor (theme.json, + # styles/*.json, block.json, checkout schemas): max 8, so a global-styles + # save is ~9; 64 leaves ~7x headroom for Divi/Elementor-style module + # trees that could not be sampled. Depth 64 itself costs ~21 ms. + SecRequestBodyJsonDepthLimit 64 + Include @crs-setup.conf.example # Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default. diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index 8386d8d..9e162bb 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -101,6 +101,9 @@ class ListenerWithCoraza(unittest.TestCase): stamp = index_of(self.lines, r'^http-request set-header X-WHP-WAF-Body %\[var\(txn\.waf_body\)\] if \{ var\(txn\.waf_body\) -m found \}$') spoe = index_of(self.lines, r'^http-request send-spoe-group coraza coraza-req$') deny = index_of(self.lines, r'^http-request return status 403 .*var\(txn\.coraza\.action\) -m str deny') + dup_ct = index_of(self.lines, r'^http-request deny deny_status 400 if \{ req\.fhdr_cnt\(content-type\) gt 1 \}$') + self.assertGreaterEqual(dup_ct, 0, 'duplicate Content-Type must be rejected at the edge') + self.assertLess(dup_ct, spoe, 'and before Coraza is asked') for name, idx in (('client-copy strip', strip1), ('size var', size), ('complete', complete), ('partial', partial), ('no-CL header length', hdrs_len), ('no-CL complete', nocl_complete), @@ -243,10 +246,18 @@ class CorazaBodyLimits(unittest.TestCase): self.assertGreaterEqual(int(self.directive('SecRequestBodyInMemoryLimit')), int(self.directive('SecRequestBodyLimit'))) + def test_json_depth_limit_pinned(self): + """Quadratic JSON flattening: depth 300 = 297 ms, past the fail-open + timeout. Legitimate WordPress JSON measured at depth <= 9.""" + depth = int(self.directive('SecRequestBodyJsonDepthLimit')) + self.assertGreaterEqual(depth, 32, 'too tight for real block/theme payloads') + self.assertLessEqual(depth, 64, 'depth 100 already costs ~40 ms') + def test_limits_are_set_after_the_recommended_file(self): inc = self.cfg.index('Include @coraza.conf-recommended') - lim = [i for i, l in enumerate(self.cfg) if l.startswith('SecRequestBodyLimit ')][0] - self.assertLess(inc, lim, 'the recommended file would override our limits') + for name in ('SecRequestBodyLimit ', 'SecRequestBodyJsonDepthLimit '): + lim = [i for i, l in enumerate(self.cfg) if l.startswith(name)][0] + self.assertLess(inc, lim, f'the recommended file would override {name.strip()}') if __name__ == '__main__': diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index c6f8229..9ae4b63 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -635,6 +635,19 @@ frontend web # 50 MB upload is not stored at the edge. option http-buffer-request + # Exactly one Content-Type, or 400. With two Content-Type lines Coraza and + # the backend disagree about what the body IS: Coraza evaluates every value + # (`text/xml` on either line selects its XML processor, which accepts a + # form body without error), while Apache merges the lines with ", " and + # PHP cuts the media type at the first ',' / ';'. Measured (Coraza v3.7.0 + # + PHP 8.3): `application/x-www-form-urlencoded` + `text/xml` puts the + # payload in $_POST with nothing inspected. req.fhdr_cnt counts header + # LINES, so a single comma-joined value (which browsers produce when a + # script calls setRequestHeader twice) is not affected here; that form is + # parsed consistently enough by both sides (see the report's matrix). + # Browsers, curl, PHP-curl and the common HTTP libraries send one line. + http-request deny deny_status 400 if { req.fhdr_cnt(content-type) gt 1 } + # Tell Coraza whether it got the whole body. Coraza cannot tell on its # own (REQUEST_BODY_LENGTH is only filled by the urlencoded/raw # processors), and the difference matters: a TRUNCATED JSON body, or a