fix: fail closed on swallowed WAF return values - #384
Conversation
msc_append_request_body(), msc_process_request_body(), msc_request_body_from_file(), msc_append_response_body() and msc_process_response_body() all signal failure via their return value (1 = success, 0 = failure per libmodsecurity's C API), but every call site discarded it and kept forwarding the request/response as if it had been fully inspected. Check the return value and fail closed with a 500 instead. ngx_http_modsecurity_process_intervention() can also return -1 (NGX_ERROR) when it wants to block or redirect but headers were already sent. Most call sites only checked for a positive return, silently letting the request/response through uninspected instead. ctx->intervention_triggered was likewise only set on some of the blocking paths, so a later filter invocation for the same request could re-run the WAF on an already-finished transaction. Finally, ngx_http_modsecurity_create_ctx() never checked whether msc_new_transaction()/msc_new_transaction_with_id() returned NULL, and three of its error paths returned NGX_CONF_ERROR ((void*)-1) instead of NULL -- the only caller only checks for NULL, so that sentinel was never caught and would have dereferenced a bogus pointer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
process_intervention() returns -1 specifically when r->header_sent is already true (it wants to intervene but can't safely rewrite headers). By the time the body filter runs, header_sent is *always* true -- header_filter() already ran and this connector does not delay response headers until body inspection finishes. So every phase-4 (or phase-5) RESPONSE_BODY deny/block/redirect hit this branch. Both ret < 0 sites in the body filter responded by calling ngx_http_filter_finalize_request(), which calls nginx's own ngx_http_clean_header() and tries to send a *fresh* status line and headers regardless of whether headers already went out. Since they always had, this produced nginx's own "header already sent" [alert] and delivered a corrupted response (the original status/headers followed by however much body had already been forwarded) instead of a clean block. Confirmed with an instrumented build: ret was -1 with header_sent=1 on every phase-4 deny, not the positive/redirect case those branches were apparently written to also cover -- process_intervention() can only return a positive status when header_sent is false, which never holds in the body filter. Fix: return NGX_ERROR instead. nginx's own ngx_http_finalize_request() treats NGX_ERROR as "abort the connection, don't try to build a response" (ngx_http_terminate_request()), which is the correct behavior once headers can no longer be rewritten -- for a response that fits in one buffer this drops the connection before anything goes out; for a larger, multi-buffer response the client sees however much had already been forwarded, then the connection closes, but never anything resembling a complete, successfully-served response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Summary
Added: phase-4 RESPONSE_BODY blocking always corrupted the responseWhile building a local libmodsecurity + nginx stack to actually verify the
Confirmed with an instrumented build: Fix: return Test plan
|
msc_append_request_body()/msc_append_response_body()/ msc_request_body_from_file() return 0 not only on a genuine failure but also -- indistinguishably from the caller's side -- whenever SecRequestBodyLimitAction/SecResponseBodyLimitAction is ProcessPartial and the body exceeds the configured limit: libmodsecurity deliberately truncates at the limit and reports it the same way as a real failure (Transaction::appendRequestBody()/appendResponseBody(); the latter's own docstring is literally "@RetVal false Operation failed, process partial demanded"). requestBodyFromFile() delegates to appendRequestBody(), so it inherits the same ambiguity. That's normal, by-design behavior -- the truncated content is still evaluated by the following process call -- not an inspection bypass, so failing the request closed on it was wrong. Caught by CI: modsecurity-config-merge.t and modsecurity-request-body(-h2).t's "process partial" cases regressed to 500 on every run. msc_process_request_body()/msc_process_response_body() don't have this ambiguity (the reference implementation always returns success regardless of body limit) and keep their fail-closed check from the previous commit, as does the process_intervention() ret<0 handling. Confirmed locally: rebuilt nginx against this fix and re-ran the previously-failing tests (modsecurity-config-merge.t, modsecurity-request-body.t) plus the response-body-deny regression test from the previous commit -- all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
|
Found and fixed a real regression from CI: the fail-closed check on |
There is a pending PR: owasp-modsecurity/ModSecurity#3476, which is related to this one. I'd wait with this until that will be merged. |



Summary
msc_process_request_body()andmsc_process_response_body()signal failure via their return value (1= success,0= failure per libmodsecurity's C API), but every call site inngx_http_modsecurity_access.c/ngx_http_modsecurity_body_filter.cdiscarded it and kept forwarding the request/response as if it had been fully inspected. They now check the return value and fail closed with a 500.ngx_http_modsecurity_process_intervention()can return-1(NGX_ERROR) when it wants to block or redirect but headers were already sent. Most call sites only checked for a positive return, silently letting the request/response through uninspected. All call sites inngx_http_modsecurity_access.c,ngx_http_modsecurity_body_filter.candngx_http_modsecurity_header_filter.cnow handle it.ctx->intervention_triggeredwas only set on some of the blocking paths, so a later filter invocation for the same request could re-run the WAF on an already-finished transaction. It's now set consistently on every early-return path.ngx_http_modsecurity_create_ctx()never checked whethermsc_new_transaction()/msc_new_transaction_with_id()returnedNULL, and three of its own error paths returnedNGX_CONF_ERROR((void*)-1) instead ofNULL— the only caller only checks forNULL, so that sentinel was never caught and would have dereferenced a bogus pointer instead of cleanly failing with a 500.Added: phase-4 RESPONSE_BODY blocking always corrupted the response
While building a local libmodsecurity + nginx stack to actually verify the
ret < 0handling above (rather than just reasoning about the code), I found that the-1case added above was itself broken on arrival.process_intervention()returns-1specifically whenr->header_sentis alreadytrue— it wants to intervene but can't safely rewrite headers. By the time the body filter runs,header_sentis always true (this connector doesn't delay response headers until body inspection finishes), so every phase-4/5RESPONSE_BODYdeny/block/redirect hits this branch. Bothret < 0sites inngx_http_modsecurity_body_filter.ccalledngx_http_filter_finalize_request(), which calls nginx's ownngx_http_clean_header()and tries to send a fresh status line and headers regardless — since headers had already gone out, this produced nginx's own[alert] header already sentand delivered a corrupted response instead of a clean block.Confirmed with an instrumented build:
retwas-1withheader_sent=1on every phase-4 deny I triggered — the positive/redirect case those branches were apparently also written to cover can't actually occur here, sinceprocess_intervention()can only return a positive status whenheader_sentis false, which never holds in the body filter.Fix: return
NGX_ERRORinstead. nginx's ownngx_http_finalize_request()treatsNGX_ERRORas "abort the connection, don't try to build a response" (ngx_http_terminate_request()) — the correct behavior once headers can no longer be rewritten. For a response that fits in one buffer this drops the connection before anything goes out; for a larger, multi-buffer response the client sees however much had already been forwarded, then the connection closes — never anything resembling a complete, successfully-served response. Addedtests/modsecurity-response-body-deny.t, confirmed to fail on the pre-fix code ([alert] header already sent) and pass with this fix.Fixed: CI caught a real regression — reverted the fail-closed check on the
append_*/*_from_filecallsCI (
modsecurity-config-merge.t,modsecurity-request-body.t) failed on everySecRequestBodyLimitAction ProcessPartial/SecResponseBodyLimitAction ProcessPartialscenario, always returning 500 instead of passing.Root cause:
msc_append_request_body()/msc_append_response_body()/msc_request_body_from_file()return0not only on a genuine failure but also — indistinguishably, from the caller's side — whenever the relevant*BodyLimitActionisProcessPartialand the body exceeds the configured limit.Transaction::appendRequestBody()/appendResponseBody()in libmodsecurity deliberately truncate at the limit and report it via the exact same return value as a real failure (appendResponseBody()'s own docstring is literally@retval false Operation failed, process partial demanded).requestBodyFromFile()delegates toappendRequestBody(), so it inherits the same ambiguity. This is normal, by-design behavior — the truncated content is still evaluated by the subsequentprocess_request_body()/process_response_body()call — not an inspection bypass, so my original "fail closed on any non-1 return" was wrong for these three specific functions. Reverted to the original behavior for just those three (ignore the return value);msc_process_request_body()/msc_process_response_body()don't have this ambiguity (the reference implementation always returns success there regardless of body limit) and keep their fail-closed check, as does theprocess_intervention()ret < 0handling.Test plan
curl -vthat the pre-fix build corrupts the response (200 headers already sent, then "header already sent" alert, then truncated/duplicated body) and the fixed build cleanly aborts the connection instead, for both a single-buffer (small) and multi-buffer (large) response.tests/modsecurity-response-body-deny.tadded; ran it directly against both binaries vianginx-tests— fails on the pre-fix code ([alert] header already sent), passes with the fix.ProcessPartialregression onlinux-gcc/linux-clangacross all three nginx versions; reproduced locally, fixed, and re-ranmodsecurity-config-merge.t+modsecurity-request-body.t+modsecurity-response-body-deny.tagainst a rebuilt binary — all pass.windows-nginx-*failures on this and other open PRs are a flaky third-party tarball download intest_new.yml's "Set up third-party libraries" step, not caused by this change).