WAF Defense — ModSecurity + OWASP CRS (shipped to Wazuh)
Defends against¶
A network-edge defense for the whole WA101 app, without editing application code:
- SQL Injection — strong
- Cross-Site Scripting — strong
- Command Injection — strong
- SSRF — partial
- IDOR — not covered (authorization logic)
- CSRF — not covered (request looks legitimate)
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:
- No code changes. The vulnerable app is byte-for-byte the same. Only the proxy in front changes.
- 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:
# 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
:8081with 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
wa101after replaying an attack.