Skip to content

WAF Defense — ModSecurity + OWASP CRS (shipped to Wazuh)

Defends against

A network-edge defense for the whole WA101 app, without editing application code:

That last point is the most important lesson in this guide — see What a WAF cannot do.

Why this guide exists

The other WA101 defense guides fix the code (parameterized queries, output encoding, CSRF tokens). That is the real fix. But in the field you rarely get to rewrite an app on day one — you inherit it. A Web Application Firewall (WAF) is the control you can deploy in front of an app you cannot (yet) change.

This is also a better fit for a 101 audience: it is an infrastructure and detection exercise, not a JavaScript exercise. You will reuse the nginx reverse proxy that already sits in your Docker stack, swap it for a WAF, run the same attacks from the attack guides, and watch them get caught — then ship those detections to Wazuh, the same SIEM you built in NA101.

Principle

A WAF inspects HTTP requests before they reach the app and matches them against a rule set. We use ModSecurity v3 (the engine) plus the OWASP Core Rule Set / CRS (the rules). CRS is signature- and anomaly-based: each suspicious pattern adds to an anomaly score, and when the score crosses a threshold the request is logged (and optionally blocked).

browser :8081
[ WAF ]  nginx + ModSecurity v3 + OWASP CRS   ← inspect, score, log, (block)
     │   writes JSON audit log ─────────────► Wazuh agent ─► Wazuh manager (alerts)
[ app :4321 ]  Astro SSR (unchanged, still vulnerable)
[ postgres ]

Two things make this powerful for teaching:

  1. No code changes. The vulnerable app is byte-for-byte the same. Only the proxy in front changes.
  2. Two modes. Run in DetectionOnly first (attacks still work, but are logged), then flip to Blocking (attacks return 403). Students see the difference.

Prerequisites

  • Local INSECURE-threat-intel-app stack working (Docker setup).
  • You have completed the six attack guides and can trigger each lab flag.
  • (For the Wazuh section) A reachable Wazuh manager + a Wazuh agent installed on this Docker host, as built in NA101 — Ingest security logs.

The WAF lab files ship with the exercise repo:

exercise-files/web-attacks-101/INSECURE-threat-intel-app/docker/
├── docker-compose.waf.yml        # app + postgres + ModSecurity/CRS WAF
└── waf/
    ├── .env.waf.example          # MODSEC_RULE_ENGINE toggle
    ├── logs/                      # modsec_audit.log is written here
    └── wazuh/
        ├── wa101-modsecurity-localfile.xml   # agent: read the audit log
        └── wa101-modsecurity-rules.xml       # manager: custom alert rules

Step 1 — Start the app behind the WAF (Detection mode)

Stop the plain stack if it is running, then start the WAF stack from the INSECURE-threat-intel-app directory:

docker compose -f docker/docker-compose.yml down
docker compose -f docker/docker-compose.waf.yml up --build

This replaces the nginx service with owasp/modsecurity-crs:nginx-alpine, which forwards clean traffic to the app and writes a JSON audit log to docker/waf/logs/. The default mode is DetectionOnly (set by MODSEC_RULE_ENGINE).

Sign in as usual at http://localhost:8081 (analyst / projectx) and confirm the dashboard still loads normally — the WAF is transparent to legitimate traffic.

Keep using localhost

Browse and POST from the same host name (localhost, not a mix of localhost and 127.0.0.1) so the app's origin check and the WAF see a consistent Host header.


Step 2 — Run the attacks and watch them get detected

In DetectionOnly mode the attacks still succeed (the app is unchanged), but every match is written to the audit log. Tail it in a second terminal:

# Linux/macOS
tail -f docker/waf/logs/modsec_audit.log

# Windows PowerShell
Get-Content docker/waf/logs/modsec_audit.log -Wait

Now fire one payload per attack (curl examples — Burp Repeater works too):

# SQL Injection — catalog search tautology
curl "http://localhost:8081/marketplace?q=' OR '1'='1"

# XSS — script payload in a parameter
curl "http://localhost:8081/marketplace?q=<script>alert(1)</script>"

# Command Injection — shell metacharacters in the Tools artifact field
curl "http://localhost:8081/tools?artifact=feed-2026-01-05.json;id"

# SSRF — cloud metadata URL (CRS inspects the value; in the app this travels inside an uploaded SVG, see SSRF guide)
curl "http://localhost:8081/?probe=http://169.254.169.254/latest/meta-data/"

Each should add a JSON entry to the audit log naming the CRS rule that fired (e.g. an 942xxx rule for the SQLi, 941xxx for the XSS). The app still returns lab results — that is expected in Detection mode.


Step 3 — Flip to Blocking mode

Now make the WAF enforce. Stop the stack, set the engine to On, and restart:

# Linux/macOS
MODSEC_RULE_ENGINE=On docker compose -f docker/docker-compose.waf.yml up --build
# Windows PowerShell
$env:MODSEC_RULE_ENGINE="On"; docker compose -f docker/docker-compose.waf.yml up --build

Re-run the four payloads from Step 2. The injection-class attacks now return HTTP 403 Forbidden before reaching the app:

Test DetectionOnly Blocking (On)
Normal search ?q=Threat 200, results 200, results (unaffected)
SQLi ?q=' OR '1'='1 200, lab flag fires 403 Forbidden
XSS <script>alert(1)</script> 200, payload stored/reflected 403 Forbidden
Command injection ;id 200, command runs 403 Forbidden
SSRF metadata IP 200 (often) 403 or 200 — partial

👉 Why start in DetectionOnly? Blocking mode can also reject legitimate requests that happen to look malicious (false positives). Detection-first is how real WAFs are rolled out: observe, tune, then enforce. If a normal app feature breaks in Blocking mode, that is a tuning lesson, not a failure.


What a WAF cannot do

Re-run all six attacks in Blocking mode and you will find two still work:

Attack Result behind the WAF Why
SQL Injection Blocked Recognizable injection signatures
XSS Blocked Recognizable script/markup signatures
Command Injection Blocked Recognizable shell metacharacters
SSRF Partial CRS flags some targets (e.g. 169.254.169.254), but a creative URL can slip past
IDOR Still works ?id=1011 vs ?id=1001 are both valid requests — only the app knows bob's document is not analyst's
CSRF Still works A forged state-changing POST is shaped exactly like a real one

This is the headline lesson: a WAF blocks injection (the "loud" attacks) but is blind to authorization and business-logic flaws. IDOR and CSRF are defended in code — which is exactly what the IDOR authorization checks and CSRF tokens guides do. A WAF buys you time; it does not replace secure code.


Step 4 — Ship WAF detections to Wazuh

A WAF that only writes a local file is half a control. Forward its alerts into Wazuh so they land on the same dashboards as your NA101 detections (prevent → detect → alert → respond).

waf container ─ JSON audit log ─► docker/waf/logs/modsec_audit.log
                                        │  (read by Wazuh agent on this host)
                              Wazuh agent ──► Wazuh manager ──► alerts / dashboard

1. Point the Wazuh agent at the audit log. Add the contents of waf/wazuh/wa101-modsecurity-localfile.xml to the agent's ossec.conf, editing <location> to the absolute path of docker/waf/logs/modsec_audit.log on your machine:

<localfile>
  <log_format>json</log_format>
  <location>/opt/INSECURE-threat-intel-app/docker/waf/logs/modsec_audit.log</location>
</localfile>

Restart the agent (sudo systemctl restart wazuh-agent, or Restart-Service WazuhSvc on Windows).

2. Add the alert rules on the Wazuh manager. Append waf/wazuh/wa101-modsecurity-rules.xml to /var/ossec/etc/rules/local_rules.xml and restart the manager (sudo systemctl restart wazuh-manager). These rules map CRS rule families back to WA101 attacks and tag them with MITRE ATT&CK techniques:

Wazuh rule Fires on Level
100201 Any CRS rule match 6
100210 SQL Injection (CRS 942xxx) 10
100211 XSS (CRS 941xxx) 10
100212 Command Injection (CRS 932xxx) 12
100213 SSRF / metadata probe (CRS 934xxx) 10

3. Verify. Re-run a payload from Step 2 and confirm an alert appears in Wazuh → Threat Hunting / Discover, filtered on rule.groups:wa101.

If a child rule does not fire

Wazuh's JSON decoder field names can vary by version. Open one rule 100201 alert in Discover, find the field holding the CRS message text, and adjust the <field name="…"> in the rules file to match. The base rule (100200) firing confirms the log pipeline itself is working.

👉 Course integration: this is the WA101 counterpart to the NA101 Wazuh detections and the CA101 AWS log pipeline. The local ModSecurity WAF here mirrors AWS WAF in the production topology — same concept, managed service in the cloud. And it's free.

Defense in depth

The WAF is one layer. Combine it with:

  • The code fixes in the other defense guides — the WAF is a compensating control, not the primary one. Injection signatures can be bypassed; parameterized queries cannot.
  • An egress allowlist for SSRF (SSRF outbound allowlist) — the network-level control that catches what CRS misses.
  • Authorization checks for IDOR and anti-CSRF tokens — the logic flaws no WAF can see.
  • Security headers at the proxy (CSP, X-Frame-Options) — another non-code control you can add at the WAF/nginx layer.
  • Rate limiting at the proxy to blunt automated scanning and brute force.

Verification checklist

  • [ ] App still serves normally at :8081 with the WAF in front (Detection mode).
  • [ ] Each injection attack produces an entry in docker/waf/logs/modsec_audit.log.
  • [ ] In Blocking mode, SQLi / XSS / command injection return 403.
  • [ ] IDOR and CSRF still succeed behind the WAF (and you can explain why).
  • [ ] Wazuh shows alerts tagged wa101 after replaying an attack.