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