Modern web applications face a wide range of attack vectors, from SQL injection and XSS to remote code execution. A Web Application Firewall (WAF) inspects and filters HTTP traffic before it reaches your app, adding a safety net alongside secure coding, patching, and least-privilege deployments. It helps reduce exploit windows (virtual patching) while you remediate upstream.

Here you’ll build a lightweight proof-of-concept with Docker Compose that layers ModSecurity v3 and the OWASP Core Rule Set (CRS) in front of a simple Node.js backend. The goal is to stand up a fast, tweakable lab: run CRS defaults, add one custom rule, observe logs, and understand how to move between DetectionOnly and blocking.


Requirements

  • Docker & Docker Compose
  • Basic terminal and container knowledge

Project structure

Example layout (waf-demo/):

waf-demo/
|- docker-compose.yml
|- modsecurity/
|  \- rules/          # custom rules
\- app/
   \- server.js

Docker Compose configuration

Minimal docker-compose.yml that runs a Node.js backend and places a ModSecurity + CRS WAF in front of it:

services:
  app:
    image: node:25-alpine
    working_dir: /usr/src/app
    volumes:
      - ./app:/usr/src/app:ro
    command: ["node", "server.js"]
    expose:
      - "3000"
    ports:
      - "3000:3000"

  waf:
    image: owasp/modsecurity-crs:apache
    depends_on:
      - app
    ports:
      - "8080:8080"
    environment:
      - MODSEC_RULE_ENGINE=On              # switch to DetectionOnly while tuning
      - BACKEND=http://app:3000
      - MODSEC_AUDIT_LOG_FORMAT=Native
      - MODSEC_AUDIT_LOG_TYPE=Serial
      - MODSEC_AUDIT_LOG=/var/log/modsec_audit.log

Notes:

  • owasp/modsecurity-crs ships NGINX + ModSecurity v3 with the CRS already included.
  • The rules directory inside the image is /etc/modsecurity.d/rules/.
  • Exposing 3000:3000 on app is optional but convenient for bypass tests.
  • We define log file and format other than modsecurity’s default values for easier debugging.

How traffic flows:

  • The waf container listens on port 80 (exposed to host 8080) and forwards validated traffic to app:3000.

Sample app (app/server.js)

Tiny Express app for testing:

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Hello from the backend app');
});

app.get('/search', (req, res) => {
  const q = req.query.q || '';
  res.send(`Search results for: ${q}`);
});

app.listen(3000, () => console.log('App listening on 3000'));

For local testing install dependencies once inside app/ and run the application:

# install dependecies
npm init -y
npm i express --no-audit --no-fund
# run the app
node server.js

Running the stack

From the project root:

docker-compose up --build

Services:

  • Backend: localhost:3000
  • WAF proxy: localhost:8080

Testing the WAF

  1. Normal request should pass:
curl -i "http://localhost:8080/search?q=ThisIsTest"

Expected: HTTP 200 and the backend response.

  1. Trigger a simple XSS-like payload (blocked by the example rule):
curl -i "http://localhost:8080/search?q=<script>alert(1)</script>"

Expected: request denied (HTTP 403 or similar) and an audit entry in the WAF log.

Check recent audit log lines:

docker-compose exec waf sh -c "tail -n 200 /var/log/modsec_audit.log"

Native logformat

When ModSecurity runs in DetectionOnly or blocking mode, it generates an audit record for every transaction that matches a rule. In Serial format, each audit entry is split into multiple sections, each describing a different part of the HTTP exchange. A single request/response pair is wrapped in blocks with IDs like:

--4bfe446b-A--
--4bfe446b-B--
--4bfe446b-F--
--4bfe446b-H--
--4bfe446b-Z--

The value 4bfe446b is the unique transaction identifier used across all blocks.

Below is a breakdown of what each section means and how to read it.

A - Transaction metadata

Example:

--4bfe446b-A--
[21/Apr/2025:22:44:01 +0000] aSolscvOyfZOR433OV24RwAAAEI 172.19.0.1 45006 172.19.0.3 8080

This is the “who → where” part of the request. It contains:

  • The timestamp
  • ModSecurity’s internal transaction ID
  • Client IP and port
  • Server IP and port (inside the container)

B - The HTTP request

Example:

--4bfe446b-B--
GET /search?q=%3Cscript%3Ealert(1)%3C/script%3E HTTP/1.1
Host: localhost:8080
User-Agent: Chrome/142.0
...

This block shows the full request exactly as ModSecurity received it:

  • request line (GET /search…)
  • all request headers
  • cookies
  • URL parameters (percent-encoded)

E - Request body (if present)

Empty for GET requests. For POST/PUT requests, this block contains the request body (up to the configured limits).

F - The HTTP response

--4bfe446b-F--
HTTP/1.1 304 Not Modified
ETag: W/"2d-jfpGPh4..."
...

This is the backend’s response (not the WAF’s). It includes:

  • the response status
  • all response headers
  • sometimes the response body (up to limits)

H - Rule hits and analysis

This is the core of the audit log. It lists every rule that fired, why it fired, and how it contributed to the anomaly score.

Message: Warning. detected XSS using libinjection. [id "941100"]
Message: Pattern match "<script>" [id "941110"]
Message: HTML Injection [id "941160"]
Message: Javascript method detected [id "941390"]
Message: Inbound Anomaly Score Exceeded (Total Score: 20) [id "949110"]
...

This block includes:

  • Which CRS rules triggered (id “941100” etc.)
  • Why they matched (matched data string)
  • Severity
  • CRS rule file and line number
  • Tags such as attack type, CRS module and paranoia level
  • The calculated anomaly score (critical for blocking decisions)

Z - End of transaction

A simple boundary marker indicating the end of the log entry.


Tuning and false positives

Start by enabling DetectionOnly mode, which lets ModSecurity log alerts without actually blocking traffic. This is essential during early testing because real users (or your own test traffic) can freely exercise the application while you observe which CRS rules fire. You avoid breaking functionality while still collecting the full audit trail:

Once the tuning phase is complete and you understand which rules are false positives, you can switch this back to On.

A key concept in CRS tuning is the 900-series exclusion files:

  • REQUEST-900-EXCLUSION-RULES.conf
  • RESPONSE-900-EXCLUSION-RULES.conf

These files are intentionally provided by CRS as tuning hooks. They exist so that you can add exclusions, overrides, and app-specific behaviour without ever modifying CRS core rule files. Editing CRS rules directly makes upgrades painful and can unintentionally weaken your WAF. Instead, you place all your application-specific changes in the 900-series files, which are loaded before the main rule sets. This means your exclusions take effect first.

Typical use cases for the 900 files include:

  • disabling a specific rule only for a specific endpoint
  • exempting a noisy parameter (e.g., a field that legitimately contains HTML or SQL-like strings)
  • ignoring CRS SQLi/XSS rules on health checks, WebSocket upgrade paths, or telemetry endpoints
  • turning off a rule for one HTTP method such as OPTIONS or PUT

Example of a tightly scoped exclusion (placed in a 900 file):

# Exclude CRS SQL Injection rule 942100 only for the health endpoint

SecRule REQUEST_URI "@beginsWith /api/health"
"id:900001,phase:1,pass,nolog,ctl:ruleRemoveById=942100"

Notice how the exclusion is narrowly scoped: only rule 942100, only for /api/health. This avoids silencing important detections across the entire application.

When writing custom ModSecurity rules or exclusions, always scope them carefully:

  • by host (SecRule REQUEST_HEADERS:Host "api.example.com")
  • by path (@beginsWith /api/v1/orders)
  • by method (REQUEST_METHOD "@streq POST")

This prevents accidental “global” weakening of the WAF. Broad exclusions should be avoided unless absolutely necessary.

During tuning, you’ll rely heavily on the audit log. Because you’re in DetectionOnly mode, every triggered rule will be logged instead of blocked. Tail the audit log separately from access logs:

docker-compose exec waf sh -c "tail -f /var/log/modsecurity/audit.log"

Each entry will show:

  • the rule ID (id "942100")
  • the endpoint involved (REQUEST_URI)
  • the triggering parameter (ARGS:q, for example)
  • a message explaining what the rule detected (SQL Injection Attack Detected)

From these entries, you decide which ones are legitimate false positives and write targeted exclusions for them.

It’s also important to consider ModSecurity request-body limits. CRS sets fairly strict defaults for protection against resource exhaustion. If your application accepts:

  • large JSON payloads
  • file uploads
  • images or binary blobs
  • very long form fields

…you may hit:

  • SecRequestBodyLimit
  • SecRequestBodyNoFilesLimit

If these are too low, ModSecurity can flag or drop valid requests, or introduce latency as it processes oversized bodies.


References & further reading