diff --git a/scripts/test-waf-body-inspection.py b/scripts/test-waf-body-inspection.py index ce8830d..0e2fb07 100644 --- a/scripts/test-waf-body-inspection.py +++ b/scripts/test-waf-body-inspection.py @@ -92,7 +92,7 @@ class ListenerWithCoraza(unittest.TestCase): def test_stamp_order_strip_set_inspect_strip(self): strip1 = index_of(self.lines, r'^http-request del-header X-WHP-WAF-Body$') - size = index_of(self.lines, r'^http-request set-var\(txn\.waf_body_size\) req\.body_size if \{ req\.hdr_cnt\(content-length\) eq 1 \}$') + size = index_of(self.lines, r'^http-request set-var\(txn\.waf_body_size\) req\.hdr_val\(content-length\) if \{ req\.hdr_cnt\(content-length\) eq 1 \}$') complete = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(complete\) if \{ var\(txn\.waf_body_size\) -m found \} \{ req\.body_len,sub\(txn\.waf_body_size\) eq 0 \}$') partial = index_of(self.lines, r'^http-request set-var\(txn\.waf_body\) str\(partial\) if \{ var\(txn\.waf_body_size\) -m found \} !\{ req\.body_len,sub\(txn\.waf_body_size\) eq 0 \}$') hdrs_len = index_of(self.lines, r'^http-request set-var\(txn\.waf_hdrs_len\) req\.hdrs,length if !\{ var\(txn\.waf_body_size\) -m found \} \{ req\.body_len gt 0 \}$') @@ -137,6 +137,19 @@ class ListenerWithCoraza(unittest.TestCase): bufsize = int([l for l in header if l.startswith('tune.bufsize')][0].split()[1]) self.assertLessEqual(total_lt, (bufsize - 1024) * 0.85) + def test_stamp_never_derived_from_req_body_size(self): + """req.body_size is "buffered + htx->extra" and HAProxy 3.0's H3 mux + never sets htx->extra, so over HTTP/3 it equals req.body_len and a + body truncated at one buffer reads as `complete` (measured on whp01 + 2026-10-17: six Divi sync-to-server saves of 252-417 KB 403'd by + WHP 991900020; lab-reproduced on haproxy 3.0.11 +QUIC). The + advertised length must come from the Content-Length header.""" + uses = [l for l in self.lines if 'req.body_size' in l] + self.assertEqual(uses, [], 'req.body_size must not feed the X-WHP-WAF-Body stamp') + size = [l for l in self.lines if l.startswith('http-request set-var(txn.waf_body_size) ')] + self.assertEqual(len(size), 1) + self.assertIn('req.hdr_val(content-length)', size[0]) + def test_complete_and_partial_are_mutually_exclusive_conditions(self): """With a Content-Length, `complete` and `partial` must be the same condition, negated -- an inverted or loosened `complete` would enforce diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index f364b63..ed8fb27 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -664,8 +664,17 @@ frontend web # complete -> 200002/200003 deny on multipart/JSON (enforce mode) # partial -> 200002/200003 removed for this request (every mode) # - # With a Content-Length: complete iff req.body_len (buffered) equals - # req.body_size (= Content-Length). + # With a Content-Length: complete iff req.body_len (buffered) equals the + # Content-Length HEADER value (req.hdr_val). NOT req.body_size: that is + # "buffered + htx->extra", and in HAProxy 3.0 only the H1 and H2 muxes + # set htx->extra from Content-Length -- the H3 mux never does (h3.c), so + # over HTTP/3 req.body_size == req.body_len and every body cut at one + # buffer was stamped `complete`. Measured: whp01 logged 73,795 H3 + # `complete` and 0 `partial` (Oct 6-19 2026); a 265,778-byte Divi save + # over H3 reached Coraza as its first ~15 KB, failed to parse and was + # 403'd by WHP's 991900020. H1/H2/H3 all validate Content-Length against + # the DATA actually received (h3.c rejects a mismatch), so the header is + # a protocol-independent "how long the body really is". # Without one (HTTP/1.1 chunked, HTTP/2-3 without content-length) HAProxy # 3.0 has no "end of message" fetch and req.body_size is just what is # buffered. But http-buffer-request only releases the request early when @@ -680,7 +689,7 @@ frontend web # txn.waf_body is also logged (`wafb=` in the log-format above) so the # complete/partial mix is measurable per host. http-request del-header X-WHP-WAF-Body - http-request set-var(txn.waf_body_size) req.body_size if { req.hdr_cnt(content-length) eq 1 } + http-request set-var(txn.waf_body_size) req.hdr_val(content-length) if { req.hdr_cnt(content-length) eq 1 } http-request set-var(txn.waf_body) str(complete) if { var(txn.waf_body_size) -m found } { req.body_len,sub(txn.waf_body_size) eq 0 } http-request set-var(txn.waf_body) str(partial) if { var(txn.waf_body_size) -m found } !{ req.body_len,sub(txn.waf_body_size) eq 0 } http-request set-var(txn.waf_hdrs_len) req.hdrs,length if !{ var(txn.waf_body_size) -m found } { req.body_len gt 0 }