fix(waf): 400 duplicate Content-Type; JSON depth limit 64 (round 1)

- hap_listener.tpl (Coraza block): `http-request deny deny_status 400 if
  { req.fhdr_cnt(content-type) gt 1 }`. With two Content-Type lines 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
  and PHP parses the body as a form. Measured with Coraza v3.7.0 + PHP 8.3:
  `application/x-www-form-urlencoded` + `text/xml` put the payload in
  $_POST uninspected. Live: two lines -> 400 over HTTP/1.1 and HTTP/2; one
  comma-joined line (browser XHR double-set) is unaffected.
- coraza-spoa/config.yaml: SecRequestBodyJsonDepthLimit 64 (was 1024 from
  the recommended file). Coraza's JSON flattening is quadratic in depth:
  depth 300 (601 B) = 297 ms, depth 1000 (2 KB) = 3.1 s, past the 100 ms
  fail-open timeout. Over the limit it is a 0.1 ms parse error, which WHP's
  enforce-mode rule denies on a complete body (live: depth 65/300/1000 ->
  403, depth 64 and a depth-9 global-styles body -> 200). Legitimate depth
  measured on WordPress core, Gutenberg, WooCommerce and Elementor JSON:
  max 8.
- test-waf-body-inspection.py: 17 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-09 11:21:49 -07:00
1 parent 4f21bb2f76
commit cc0652e0c0
3 files changed
+39 -2

No files matched your search

+13
View File
@@ -44,6 +44,19 @@ applications:
SecRequestBodyInMemoryLimit 131072 SecRequestBodyInMemoryLimit 131072
SecRequestBodyLimitAction ProcessPartial 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 Include @crs-setup.conf.example
# Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default. # Runtime-managed PRE-CRS exclusions written by WHP UI. Empty by default.
+13 -2
View File
@@ -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 \}$') 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$') 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') 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), for name, idx in (('client-copy strip', strip1), ('size var', size), ('complete', complete),
('partial', partial), ('no-CL header length', hdrs_len), ('partial', partial), ('no-CL header length', hdrs_len),
('no-CL complete', nocl_complete), ('no-CL complete', nocl_complete),
@@ -243,10 +246,18 @@ class CorazaBodyLimits(unittest.TestCase):
self.assertGreaterEqual(int(self.directive('SecRequestBodyInMemoryLimit')), self.assertGreaterEqual(int(self.directive('SecRequestBodyInMemoryLimit')),
int(self.directive('SecRequestBodyLimit'))) 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): def test_limits_are_set_after_the_recommended_file(self):
inc = self.cfg.index('Include @coraza.conf-recommended') inc = self.cfg.index('Include @coraza.conf-recommended')
lim = [i for i, l in enumerate(self.cfg) if l.startswith('SecRequestBodyLimit ')][0] for name in ('SecRequestBodyLimit ', 'SecRequestBodyJsonDepthLimit '):
self.assertLess(inc, lim, 'the recommended file would override our limits') 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__': if __name__ == '__main__':
+13
View File
@@ -635,6 +635,19 @@ frontend web
# 50 MB upload is not stored at the edge. # 50 MB upload is not stored at the edge.
option http-buffer-request 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 # Tell Coraza whether it got the whole body. Coraza cannot tell on its
# own (REQUEST_BODY_LENGTH is only filled by the urlencoded/raw # own (REQUEST_BODY_LENGTH is only filled by the urlencoded/raw
# processors), and the difference matters: a TRUNCATED JSON body, or a # processors), and the difference matters: a TRUNCATED JSON body, or a