2 Commits
Author SHA1 Message Date
shadowdaoandClaude Opus 5.5 77812399d4 chore: VERSION 2026.10.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:59:45 -07:00
shadowdaoandClaude Opus 5.5 2cc6790db1 fix(waf): stamp X-WHP-WAF-Body from the Content-Length header, not req.body_size
req.body_size is "buffered + htx->extra". In HAProxy 3.0 only the H1 and
H2 muxes set htx->extra from Content-Length; the H3 mux never does, so
over HTTP/3 req.body_size == req.body_len and any body cut at one buffer
(~15 KB) was stamped `complete`. Coraza then failed to parse the
truncated JSON (200002) and WHP's 991900020 returned a 403.

Measured on whp01, Oct 6-19 2026: 73,795 H3 `complete` stamps and 0
`partial`; 7 legitimate requests 403'd (6 Divi sync-to-server saves of
265-417 KB, 1 Groundhogg preview of 18 KB). Reproduced in a lab on
haproxy 3.0.11 with QUIC. H1/H2/H3 all reject a Content-Length that
doesn't match the DATA received, so the header is a protocol-independent
body length. No change on H1/H2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:59:29 -07:00
3 changed files with 27 additions and 5 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
2026.10.4
2026.10.5
+14 -1
View File
@@ -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
+12 -3
View File
@@ -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 }