Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ COPY coraza-waf.conf /etc/coraza/coraza-waf.conf
# SSE test endpoint (streaming CGI) for the header-delay skip test
COPY tests/cgi-bin/sse /usr/local/apache2/cgi-bin/sse
RUN chmod +x /usr/local/apache2/cgi-bin/sse
COPY tests/cgi-bin/bulk /usr/local/apache2/cgi-bin/bulk
RUN chmod +x /usr/local/apache2/cgi-bin/bulk

# Apache config: load module, enable coraza with CRS, FallbackResource for test URLs
RUN { \
Expand Down Expand Up @@ -188,6 +190,7 @@ RUN { \
echo '# --- SSE streaming: header delay must be skipped (never sends EOS) ---'; \
echo 'ScriptAlias "/sse-stream" "/usr/local/apache2/cgi-bin/sse"'; \
echo 'ScriptAlias "/sse-nearmiss" "/usr/local/apache2/cgi-bin/sse"'; \
echo 'ScriptAlias "/bulk-delayed" "/usr/local/apache2/cgi-bin/bulk"'; \
echo '<Directory "/usr/local/apache2/cgi-bin">'; \
echo ' Require all granted'; \
echo ' Options +ExecCGI'; \
Expand Down
13 changes: 13 additions & 0 deletions src/mod_coraza.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,23 @@ typedef struct {
} coraza_server_conf_t;


/*
* Cap on how much response body is buffered while the header delay holds the
* response for phase-4 inspection. A large download or an open-ended stream
* would otherwise accumulate without limit and inflate worker memory. Once the
* buffered body passes this cap the headers and everything buffered so far are
* flushed and the remainder streams through. Overridable at build time with
* -DCORAZA_MAX_DELAYED_BODY=<bytes>.
*/
#ifndef CORAZA_MAX_DELAYED_BODY
#define CORAZA_MAX_DELAYED_BODY (1024 * 1024) /* 1 MiB */
#endif

/* Per-request context */
typedef struct {
coraza_transaction_t transaction;
apr_bucket_brigade *pending_brigade; /* buffered body for header delay */
apr_size_t pending_len; /* bytes buffered while headers are delayed */
int headers_delayed; /* response held back pending body inspection */
int phase2_done; /* request body processed */
int phase3_done; /* response headers processed */
Expand Down
27 changes: 26 additions & 1 deletion src/mod_coraza_filter_out.c
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,30 @@ coraza_output_filter(ap_filter_t *f, apr_bucket_brigade *bb)
return rv;
}

/*
* Bound worker memory while the header delay holds the response. The
* loop reads (and thus buffers) every bucket before forwarding; a large
* download or a long stream would otherwise be read into memory in full
* before EOS. Once the buffered body passes the cap, stop delaying:
* flush the buffered headers + everything read so far and let the rest
* stream through uninspected. A later phase-4 match can then no longer
* render a clean error page (headers are on the wire) -- the same
* trade-off the SSE and 101 Switching Protocols paths accept.
*/
if (ctx->headers_delayed && len > 0) {
ctx->pending_len += len;
if (ctx->pending_len > CORAZA_MAX_DELAYED_BODY) {
ap_log_rerror(APLOG_MARK, APLOG_WARNING, 0, r,
"coraza: delayed response body exceeded %"
APR_SIZE_T_FMT " bytes; flushing headers early",
(apr_size_t) CORAZA_MAX_DELAYED_BODY);
ctx->headers_delayed = 0;
APR_BRIGADE_PREPEND(bb, ctx->pending_brigade);
ctx->pending_brigade = NULL;
return ap_pass_brigade(f->next, bb);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (len > 0 && ctx->response_body_processable) {
coraza_append_response_body(ctx->transaction,
(unsigned char *)data, (int)len);
Expand Down Expand Up @@ -268,7 +292,8 @@ coraza_output_filter(ap_filter_t *f, apr_bucket_brigade *bb)

/* Not the last buffer yet */
if (ctx->headers_delayed) {
/* Accumulate into pending brigade during header delay */
/* Accumulate into pending brigade during header delay. The total is
* bounded by the per-bucket cap check in the phase-4 loop above. */
APR_BRIGADE_CONCAT(ctx->pending_brigade, bb);
return APR_SUCCESS;
}
Expand Down
43 changes: 43 additions & 0 deletions test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,37 @@ check_stream() {
fi
}

# Assert the number of body bytes downloaded (e.g. a large response arrives
# intact through the delayed-buffering cap, not truncated).
check_size() {
desc="$1"
url="$2"
expected="$3"

size=$(curl -s -o /dev/null -w "%{size_download}" --max-time 20 "$url")
if [ "$size" = "$expected" ]; then
printf " PASS %s -> %s bytes\n" "$desc" "$size"
PASS=$((PASS + 1))
else
printf " FAIL %s -> %s bytes (expected %s)\n" "$desc" "$size" "$expected"
FAIL=$((FAIL + 1))
fi
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Assert a pattern appears in the container's stderr log (Apache error_log).
check_container_log() {
desc="$1"
pattern="$2"

if docker logs "$CONTAINER" 2>&1 | grep -q "$pattern"; then
printf " PASS %s\n" "$desc"
PASS=$((PASS + 1))
else
printf " FAIL %s (pattern '%s' not in container log)\n" "$desc" "$pattern"
FAIL=$((FAIL + 1))
fi
}

echo "Coraza WAF test suite"
echo "Target: $URL"

Expand Down Expand Up @@ -473,6 +504,13 @@ check_stream "SSE near-miss: text/event-streamx delayed" "$URL/sse-nearmiss"
check_curl "SSE: phase-1 rule still blocks (no bypass)" "$URL/sse-stream?attack=1" 403 --max-time 5
echo ""

echo "--- Delayed response cap (bound worker memory) ---"
# A large delayed response must arrive intact: the cap flushes headers early
# and streams the rest rather than buffering the whole body (or truncating it).
check "Large delayed response: completes 200" "$URL/bulk-delayed" 200
check_size "Large delayed response: full 4 MiB body" "$URL/bulk-delayed" 4194304
echo ""

echo "--- Config merging tests ---"
check "Engine off: SQLi passes" "$URL/merge-engine-off/?id=1%20OR%201=1" 200
check "Engine off: normal passes" "$URL/merge-engine-off/" 200
Expand Down Expand Up @@ -565,6 +603,11 @@ echo ""

# Audit log tests (require --container)
if [ -n "$CONTAINER" ]; then
echo "--- Delayed response cap log ---"
# The large delayed response above must have tripped the cap's early flush.
check_container_log "Cap: flushed delayed headers early" "flushing headers early"
echo ""

echo "--- Audit log tests ---"
# Generate a blocked request that will appear in audit log
clear_audit_log
Expand Down
16 changes: 16 additions & 0 deletions tests/cgi-bin/bulk
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/sh
# Emits a large application/octet-stream body in paced chunks, sending EOS only
# when it finishes. The short sleep between chunks makes Apache deliver several
# separate output-filter brigades before EOS -- without pacing the CGI writes
# fast enough that mod_cgid hands the whole body + EOS to the filter at once,
# which reaches the has-EOS path before the not-last-buffer cap can trip.
#
# Total: 16 * 256 KiB = 4 MiB, comfortably over the 1 MiB cap.
/usr/bin/printf 'Content-Type: application/octet-stream\r\n\r\n'

i=0
while [ "$i" -lt 16 ]; do
dd if=/dev/zero bs=262144 count=1 2>/dev/null | tr '\0' 'A'
i=$((i + 1))
sleep 0.2
done