diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
new file mode 100644
index 0000000..d3f8f9b
--- /dev/null
+++ b/.github/workflows/docker-build.yml
@@ -0,0 +1,49 @@
+name: Docker build
+
+on:
+ push:
+ branches: [master]
+ paths:
+ - Dockerfile
+ - docker-compose.yml
+ - test-connector.sh
+ - src/**
+ - .github/workflows/docker-build.yml
+ pull_request:
+ paths:
+ - Dockerfile
+ - docker-compose.yml
+ - test-connector.sh
+ - src/**
+ - .github/workflows/docker-build.yml
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-and-smoke-test:
+ name: Build and smoke test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ # Compose is the only supported way to run this: the smoke tests read
+ # the ModSecurity debug log through the bind mount it sets up.
+ - name: Build and start container
+ run: docker compose up -d --build
+
+ - name: Run smoke tests
+ run: ./test-connector.sh
+
+ - name: Show container logs
+ if: always()
+ run: |
+ docker compose logs
+ cat logs/modsec_debug.log 2>/dev/null | tail -50 || true
diff --git a/.gitignore b/.gitignore
index 47188a7..21b48ae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,6 @@
.libs/*
src/.libs/*
t/htdocs/index.html
+
+# Test harness output (docker-compose bind mount)
+logs/
diff --git a/DOCKER_TEST.md b/DOCKER_TEST.md
new file mode 100644
index 0000000..1d5779a
--- /dev/null
+++ b/DOCKER_TEST.md
@@ -0,0 +1,104 @@
+# Docker Testing Guide for the ModSecurity Apache Connector
+
+A smoke-test harness for the ModSecurity v3 Apache connector. It builds
+libmodsecurity and the connector from source, loads a two-rule test set, and
+lets connector behaviour be observed directly. It is not a production
+configuration.
+
+## Quick Start
+
+```bash
+docker compose up -d --build
+./test-connector.sh
+```
+
+Compose is the supported way to run this: the tests read the ModSecurity debug
+log through the bind mount it sets up, so a bare `docker run` will not work.
+
+## Manual Testing
+
+```bash
+# Normal request (200)
+curl http://localhost:8080/
+
+# Query string rule, id 1001 (403)
+curl -v "http://localhost:8080/?test=evil"
+
+# Request body rule, id 1002 (403)
+curl -X POST http://localhost:8080/ -d "data=malicious"
+
+# Large body, no match (200)
+curl -X POST http://localhost:8080/ -d "$(head -c 100000 /dev/zero | tr '\0' 'A')"
+
+# Large body spanning multiple buckets, with a match at the end (403)
+curl -X POST http://localhost:8080/ -d "$(head -c 100000 /dev/zero | tr '\0' 'A')malicious"
+```
+
+Bodies stay under the 128KB `SecRequestBodyNoFilesLimit` from the recommended
+configuration; larger ones are rejected with 413 before the rules run. A 10KB
+body arrives in a single bucket, so it does not exercise multi-bucket handling.
+
+## Observing rule evaluation
+
+Denied requests are **not** written to the Apache error log — that is upstream
+issue #67, not a misconfiguration here. Two other signals are available:
+
+- `logs/modsec_audit.log` — one entry per transaction, showing which rule
+ matched. It does not tell you how many times a rule was evaluated.
+- `logs/modsec_debug.log` — one line per phase invocation. This is the only
+ signal that shows how often a phase actually ran.
+
+`test-connector.sh` uses the debug log to report how many times the
+request-body phase ran for a single large POST:
+
+```text
+request-body phase invocations for that request: 26 (KNOWN BUG: expected 1, ...)
+```
+
+A correct connector assembles the whole body and evaluates it once. The
+current source re-runs the phase for every bucket, which is the defect behind
+the request-body work; the count is reported rather than asserted so this
+branch stays green. Once the fix lands it becomes a hard assertion.
+
+## Debugging
+
+```bash
+# Live logs
+docker compose logs -f
+
+# Shell into the container
+docker compose exec modsec3-apache bash
+
+# Confirm the module loaded
+apache2ctl -M | grep security3
+
+# Module dependencies
+ldd /usr/lib/apache2/modules/mod_security3.so
+
+# Active configuration
+cat /etc/modsecurity/modsecurity.conf
+cat /etc/modsecurity/test-rules.conf
+```
+
+## Expected Results
+
+All 6 checks in `test-connector.sh` pass:
+
+1. Normal request — 200
+2. Query string block — 403
+3. Request body block — 403
+4. Normal POST — 200
+5. Large POST — 200
+6. Large POST with a match — 403
+
+Test 6 additionally reports the request-body phase count described above.
+
+## What's Included
+
+- **libmodsecurity** v3.0.16, built from the pinned release tag
+- **Apache HTTP Server** 2.4.68, from Debian bookworm
+- **ModSecurity Apache Connector**, built from this working tree
+
+The recommended ModSecurity configuration is copied out of the same
+libmodsecurity source tree that was built, so it cannot drift from the
+version in the image.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a1c323c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,145 @@
+# Dockerfile for testing the ModSecurity v3 Apache connector.
+# Builds libmodsecurity3 and the connector against Debian's Apache.
+
+FROM debian:bookworm-slim AS builder
+
+ARG MODSECURITY_VERSION=v3.0.16
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ # Build essentials
+ build-essential \
+ ca-certificates \
+ automake \
+ autoconf \
+ libtool \
+ pkg-config \
+ git \
+ # Apache module build support (apxs2, plus the httpd binary configure probes for)
+ apache2 \
+ apache2-dev \
+ # libmodsecurity dependencies
+ libcurl4-openssl-dev \
+ libyajl-dev \
+ libgeoip-dev \
+ liblmdb-dev \
+ libxml2-dev \
+ libpcre2-dev \
+ libmaxminddb-dev \
+ libfuzzy-dev && \
+ rm -rf /var/lib/apt/lists/*
+
+# Build libmodsecurity v3 from a pinned release tag
+WORKDIR /build
+
+RUN git clone --depth 1 --branch ${MODSECURITY_VERSION} \
+ https://github.com/owasp-modsecurity/ModSecurity.git libmodsecurity && \
+ cd libmodsecurity && \
+ git submodule update --init --recursive && \
+ ./build.sh && \
+ ./configure \
+ --prefix=/usr/local/modsecurity \
+ --with-pcre2 \
+ --with-yajl \
+ --with-geoip \
+ --with-lmdb && \
+ make -j$(nproc) && \
+ make install && \
+ ldconfig
+
+# Build the connector; configure finds Debian's apxs2 on its own
+WORKDIR /build/connector
+
+COPY . .
+
+RUN ./autogen.sh && \
+ ./configure --with-libmodsecurity=/usr/local/modsecurity && \
+ make -j$(nproc) && \
+ make install
+
+FROM debian:bookworm-slim
+
+LABEL description="Apache with the ModSecurity v3 connector, for smoke testing"
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ apache2 \
+ wget \
+ libcurl4 \
+ libyajl2 \
+ libgeoip1 \
+ liblmdb0 \
+ libxml2 \
+ libpcre2-8-0 \
+ libmaxminddb0 \
+ libfuzzy2 && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY --from=builder /usr/local/modsecurity /usr/local/modsecurity
+COPY --from=builder /usr/lib/apache2/modules/mod_security3.so /usr/lib/apache2/modules/
+
+RUN echo "/usr/local/modsecurity/lib" > /etc/ld.so.conf.d/modsecurity.conf && \
+ ldconfig
+
+# Take the recommended config from the same source tree we built, so it can
+# never drift from the pinned libmodsecurity version.
+COPY --from=builder /build/libmodsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
+COPY --from=builder /build/libmodsecurity/unicode.mapping /etc/modsecurity/unicode.mapping
+
+RUN sed -i 's/SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf
+
+RUN cat > /etc/modsecurity/test-rules.conf << 'EOF'
+# Fires on the query string, to check phase 1 / ARGS handling
+SecRule ARGS:test "@contains evil" \
+ "id:1001,phase:2,deny,status:403,msg:'Test rule triggered'"
+
+# Fires on the request body, to check that a multi-bucket body is assembled
+# and evaluated exactly once
+SecRule REQUEST_BODY "@rx malicious" \
+ "id:1002,phase:2,deny,status:403,msg:'Request body rule triggered'"
+
+# The connector does not write denied requests to the Apache error log
+# (upstream issue #67), and the audit log records one entry per transaction
+# rather than one per rule evaluation. The debug log is the only signal that
+# shows how many times a phase actually ran, which is what the request-body
+# tests need to check.
+SecDebugLog /var/log/apache2/modsec_debug.log
+SecDebugLogLevel 4
+SecAuditLog /var/log/apache2/modsec_audit.log
+EOF
+
+RUN cat > /etc/apache2/mods-available/security3.load << 'EOF'
+LoadModule security3_module /usr/lib/apache2/modules/mod_security3.so
+
+
+ modsecurity on
+ modsecurity_rules_file /etc/modsecurity/modsecurity.conf
+ modsecurity_rules_file /etc/modsecurity/test-rules.conf
+
+EOF
+
+RUN a2enmod security3 && \
+ sed -i 's/^Listen 80$/Listen 8080/' /etc/apache2/ports.conf && \
+ sed -i 's///' \
+ /etc/apache2/sites-available/000-default.conf && \
+ echo "ServerName localhost" >> /etc/apache2/apache2.conf
+
+RUN cat > /usr/local/bin/start.sh << 'EOF'
+#!/bin/bash
+set -e
+
+if ! apache2ctl -M 2>&1 | grep -q security3_module; then
+ echo "ERROR: ModSecurity module not loaded!"
+ ldd /usr/lib/apache2/modules/mod_security3.so
+ exit 1
+fi
+
+echo "ModSecurity module loaded, starting Apache on :8080"
+exec apache2ctl -DFOREGROUND
+EOF
+
+RUN chmod +x /usr/local/bin/start.sh
+
+EXPOSE 8080
+
+CMD ["/usr/local/bin/start.sh"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..a512d21
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ modsec3-apache:
+ build: .
+ container_name: modsec3-apache-test
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./logs:/var/log/apache2:rw
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://localhost:8080/"]
+ interval: 10s
+ timeout: 5s
+ retries: 3
+ start_period: 5s
diff --git a/test-connector.sh b/test-connector.sh
new file mode 100755
index 0000000..a38d65b
--- /dev/null
+++ b/test-connector.sh
@@ -0,0 +1,139 @@
+#!/bin/bash
+# Test script for ModSecurity v3 Apache Connector
+# Tests the fixes for request body processing and other bugs
+
+set -e
+
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+BASEURL="http://localhost:8080"
+DEBUGLOG="${DEBUGLOG:-./logs/modsec_debug.log}"
+PASSED=0
+FAILED=0
+
+echo "======================================"
+echo "ModSecurity v3 Apache Connector Tests"
+echo "======================================"
+echo ""
+
+# Function to test requests
+test_request() {
+ local name="$1"
+ local url="$2"
+ local expected_status="$3"
+ local method="${4:-GET}"
+ local data="${5:-}"
+
+ echo -n "Testing: $name ... "
+
+ if [ "$method" = "POST" ]; then
+ actual_status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -d "$data" "$url")
+ else
+ actual_status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
+ fi
+
+ if [ "$actual_status" = "$expected_status" ]; then
+ echo -e "${GREEN}PASS${NC} (got $actual_status)"
+ PASSED=$((PASSED + 1))
+ else
+ echo -e "${RED}FAIL${NC} (expected $expected_status, got $actual_status)"
+ FAILED=$((FAILED + 1))
+ fi
+}
+
+# Wait for service to be ready
+echo "Waiting for Apache to be ready..."
+for i in {1..30}; do
+ if curl -s "$BASEURL" > /dev/null 2>&1; then
+ echo -e "${GREEN}Apache is ready!${NC}"
+ echo ""
+ break
+ fi
+ if [ "$i" -eq 30 ]; then
+ echo -e "${RED}Timeout waiting for Apache${NC}"
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Running tests..."
+echo ""
+
+# Test 1: Normal request (should work)
+test_request "Normal request" "$BASEURL/" "200"
+
+# Test 2: Query string rule trigger (should be blocked)
+test_request "Query string rule (should block)" "$BASEURL/?test=evil" "403"
+
+# Test 3: POST with malicious body (should be blocked)
+test_request "Request body rule (should block)" "$BASEURL/" "403" "POST" "data=malicious"
+
+# Test 4: Normal POST (should work)
+test_request "Normal POST request" "$BASEURL/" "200" "POST" "data=normal"
+
+# Test 5: Large POST (body spans multiple buckets)
+echo -n "Testing: Large POST (multi-bucket) ... "
+large_data=$(head -c 100000 /dev/zero | tr '\0' 'A')
+actual_status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -d "$large_data" "$BASEURL/")
+if [ "$actual_status" = "200" ]; then
+ echo -e "${GREEN}PASS${NC} (got $actual_status)"
+ PASSED=$((PASSED + 1))
+else
+ echo -e "${RED}FAIL${NC} (expected 200, got $actual_status)"
+ FAILED=$((FAILED + 1))
+fi
+
+# Test 6: Large POST with malicious content spanning multiple buckets
+echo -n "Testing: Large POST with evil content ... "
+: > "$DEBUGLOG" 2>/dev/null || true
+large_evil_data="$(head -c 100000 /dev/zero | tr '\0' 'A')malicious"
+actual_status=$(curl -s -o /dev/null -w "%{http_code}" -X POST -d "$large_evil_data" "$BASEURL/")
+if [ "$actual_status" = "403" ]; then
+ echo -e "${GREEN}PASS${NC} (got $actual_status - rule fired on multi-bucket body)"
+ PASSED=$((PASSED + 1))
+else
+ echo -e "${RED}FAIL${NC} (expected 403, got $actual_status)"
+ FAILED=$((FAILED + 1))
+fi
+
+# How many times did phase 2 actually run for that one request? A correct
+# connector assembles the whole body and evaluates it once; the current one
+# re-runs the phase for every bucket. Reported rather than asserted because
+# the fix lives in a follow-up branch and this suite has to stay green here.
+# ponytail: diagnostic only -- turn into a hard "-eq 1" assertion in the PR
+# that lands the request-body fix, otherwise the regression can silently return.
+body_phases=$(grep -c "Starting phase REQUEST_BODY" "$DEBUGLOG" 2>/dev/null || echo "?")
+echo -n " request-body phase invocations for that request: $body_phases "
+if [ "$body_phases" = "1" ]; then
+ echo -e "${GREEN}(correct - evaluated once)${NC}"
+else
+ echo -e "${YELLOW}(KNOWN BUG: expected 1, body re-evaluated per bucket)${NC}"
+fi
+
+echo ""
+echo "======================================"
+echo "Test Results"
+echo "======================================"
+echo -e "Passed: ${GREEN}$PASSED${NC}"
+echo -e "Failed: ${RED}$FAILED${NC}"
+echo ""
+
+if [ $FAILED -eq 0 ]; then
+ echo -e "${GREEN}All tests passed!${NC}"
+ echo ""
+ echo "Verified:"
+ echo " - Rules fire on query string and request body"
+ echo " - Blocking returns the configured status (403)"
+ echo " - Multi-bucket POST bodies are assembled and matched"
+ exit 0
+else
+ echo -e "${RED}Some tests failed!${NC}"
+ echo ""
+ echo "Check logs:"
+ echo " docker compose logs"
+ echo " cat logs/error.log logs/modsec_debug.log"
+ exit 1
+fi