fix: correctness gaps in response header/intervention handling - #385
fix: correctness gaps in response header/intervention handling#385fzipi wants to merge 2 commits into
Conversation
Content-Length: 0 was never forwarded to the WAF (the check was content_length_n > 0, dropping the legitimate zero-length case), so a RESPONSE_HEADERS:Content-Length rule could never match an empty body. The synthesized Server header used sizeof() on a string literal, which includes the trailing NUL, embedding a literal \0 byte into the header value handed to the WAF -- breaking exact-match rules on RESPONSE_HEADERS:Server. The synthesized Connection (and Keep-Alive) response header was fed to the WAF unconditionally, including on HTTP/2, where nginx never actually sends either header (RFC 9113 SS8.2.2). A RESPONSE_HEADERS:Connection rule would false-positive on every HTTP/2 response. The response protocol string passed to msc_process_response_headers() only distinguished HTTP/1.1 and HTTP/2.0, defaulting to HTTP/1.1 for HTTP/3 requests -- unlike the request side, which already reports the real protocol generically. When a phase:3 "redirect:" action turns an already-populated 200 response into a 3xx, only the Location header was synthesized; the discarded response's Content-Length/Content-Type/Last-Modified/ETag/ Content-Encoding/Accept-Ranges were left in place, describing a body that is no longer sent (RFC 9110 SS15.4 / SS8.3-8.8). modsecurity_transaction_id was declared NGX_CONF_1MORE but its handler only ever reads the first argument, so a config with extra arguments was silently accepted with the extras discarded instead of rejected. ngx_http_modsecurity_create_ctx() allocated an unused sizeof(ctx) data buffer per request via ngx_pool_cleanup_add()'s size argument; nothing reads it since cln->data is set to the already-existing ctx pointer separately. Matches the ngx_pool_cleanup_add(cf->pool, 0) pattern already used elsewhere in this file. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ngx_http_modsecurity_header_filter() reported a phase-3/4 "redirect:" intervention via ngx_http_filter_finalize_request(), which calls nginx's own ngx_http_clean_header() and memzeroes the entire headers_out struct -- Location included -- before nginx regenerated its own generic redirect page. The client never actually got redirected: it received a Location-less "302 Found" boilerplate page, and the discarded response's Content-Length leaked into it instead of being cleared (which made the entity-header-clearing fix from the previous commit a no-op on the one path it was meant to cover). Confirmed empirically: a redirect: action fired from access.c (phase 1/2, returns straight from the phase handler) worked correctly; the identical rule at phase:3 (via header_filter.c) did not -- the response was byte-for-byte identical whether or not the entity-header clearing fix was present, because ngx_http_clean_header() wipes everything anyway. nginx's own fix for this same problem (ngx_http_send_error_page(), used by the `error_page` directive) isn't usable here: it calls the static ngx_http_send_special_response(), which isn't part of the public module API. Fix: when process_intervention() has built a Location header, skip ngx_http_filter_finalize_request() and forward the already-correct headers_out through the normal chain instead. The original response body may already be flowing from the content handler by this point, so body_filter() now drops it (ctx->response_replaced) rather than sending it alongside the redirect. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Summary
Added: WAF-triggered redirects were silently dropped entirelyWhile building a local libmodsecurity + nginx stack to actually verify the entity-header-clearing fix above (rather than just reasoning about the code), I found the redirect path doesn't work at all, which makes that fix a no-op on the one path it was meant to cover.
nginx's own fix for this same problem ( Fix: when Test plan
|
|
Note on the failing Windows checks: they're unrelated to this PR's changes and split into two separate, pre-existing issues in
Neither is something a ModSecurity-nginx source change can fix; the Linux checks (which do exercise the actual code changes here) are green. |
|
Filed upstream: owasp-modsecurity/ModSecurity#3604 (yajl/CMake |



Summary
Content-Length: 0was never forwarded to the WAF: the check wascontent_length_n > 0, dropping the legitimate zero-length case. ARESPONSE_HEADERS:Content-Lengthrule could never match an empty body.Serverheader usedsizeof()on a string literal, which includes the trailing NUL, embedding a literal\0into the header value handed to the WAF — breaking exact-match rules onRESPONSE_HEADERS:Server.Connection(andKeep-Alive) response header was fed to the WAF unconditionally, including on HTTP/2, where nginx never actually sends either header (RFC 9113 §8.2.2). ARESPONSE_HEADERS:Connectionrule would false-positive on every HTTP/2 response.msc_process_response_headers()only distinguished HTTP/1.1 and HTTP/2.0, defaulting to HTTP/1.1 for HTTP/3 requests — unlike the request side, which already reports the real protocol generically.redirect:action turns an already-populated 200 response into a 3xx, only theLocationheader was synthesized; the discarded response'sContent-Length/Content-Type/Last-Modified/ETag/Content-Encoding/Accept-Rangeswere left in place, describing a body that is no longer sent (RFC 9110 §15.4 / §8.3-8.8).modsecurity_transaction_idwas declaredNGX_CONF_1MOREbut its handler only ever reads the first argument, so a config with extra arguments was silently accepted with the extras discarded instead of rejected.ngx_http_modsecurity_create_ctx()allocated an unusedsizeof(ctx)data buffer per request viangx_pool_cleanup_add()'s size argument; nothing reads it sincecln->datais set to the already-existingctxpointer separately. Matches thengx_pool_cleanup_add(cf->pool, 0)pattern already used elsewhere in this file.Added: WAF-triggered redirects were silently dropped entirely
While building a local libmodsecurity + nginx stack to actually verify the entity-header-clearing fix above (rather than just reasoning about the code), I found the redirect path doesn't work at all, which makes that fix a no-op on the one path it was meant to cover.
ngx_http_modsecurity_header_filter()reports a redirect viangx_http_filter_finalize_request(), which calls nginx's ownngx_http_clean_header()— thisngx_memzeros the entireheaders_outstruct, Location included, before nginx regenerates its own generic redirect page. Confirmed empirically: the identicalredirect:rule at phase:1/2 (viaaccess.c, which returns straight from the phase handler and never touchesngx_http_filter_finalize_request) produces a correctLocationheader; the same rule at phase:3 (viaheader_filter.c) produces a Location-less, boilerplate "302 Found" page — response is byte-identical whether or not the entity-header-clearing fix is present.nginx's own fix for this same problem (
ngx_http_send_error_page(), used by theerror_pagedirective) isn't reusable here — it calls thestaticngx_http_send_special_response(), which isn't part of the public module API.Fix: when
process_intervention()has built a Location header, skipngx_http_filter_finalize_request()and forward the already-correctheaders_outthrough the normal chain instead. The original response body may already be flowing from the content handler by this point, sobody_filter()now drops it (ctx->response_replaced) rather than sending it alongside the redirect. Addedtests/modsecurity-response-redirect.t, confirmed to fail onmaster(missingLocation, staleContent-Length) and pass with this fix.Test plan
masterand this branch; confirmed the redirect fix end-to-end with curl (status, headers, and body-suppression) and confirmed no regression on plaindeny(still goes throughngx_http_filter_finalize_requestas before) and plain passthrough requests.tests/modsecurity-response-redirect.tadded; ran it directly against both binaries vianginx-tests— fails onmaster(2/5 assertions), passes on this branch (5/5).tests/*.tsuite (prove modsecurity*.t) on Linux to confirm no platform-specific regression (local verification was on macOS/kqueue, CI runs Linux/epoll).modsecurity_transaction_id "a" "b";is now rejected at config-test time.