7.5 WAF-Style Logic and Request Blocking
Not every request deserves a response. Some deserve a 403 before they cost you a single upstream millisecond — and a few deserve silence. Building that policy, with receipts.
Intermediate
What you will learn
The classification→blocking architecture: maps decide, gates act.
The sanctioned
ifpattern — condition +return, nothing else — and why the rewrite phase is the safest place to block.Scanner food: path-based blocking that quiets your logs.
The escalating response ladder: 403 → tarpit → 444 (silence).
The honest boundary between edge policy and a real WAF.
1. Classification first, blocking second
The
/protected/ location, complete:
# http level — the classifiers (pure functions, testable, no side effects):
map $http_user_agent $ua_blocked {
default 0;
~*bot 1;
~*crawler 1;
~*sqlmap 1;
~*nikto 1;
"" 1; # empty User-Agent: hostile or broken
}
map $request_method $method_ok {
default 0;
~^(GET|HEAD|POST|PUT|DELETE|OPTIONS|PATCH)$ 1;
}
# server level — the gate (one decision, expressed once):
location /protected/ {
if ($ua_blocked) { return 403; }
if ($method_ok = 0) { return 405; }
return 200 "clean request accepted (country=$country)\n";
}The live evidence, hostile and friendly traffic side by side:
$ curl -s -o /dev/null -w '%{http_code}\n' -A "Googlebot/2.1" …/protected/
403
$ curl -s -o /dev/null -w '%{http_code}\n' -A "sqlmap/1.5" …/protected/
403
$ curl -s -o /dev/null -w '%{http_code}\n' -A "" …/protected/
403
$ curl -s -A "Mozilla/5.0" …/protected/
clean request accepted (country=US)
$ curl -s -X TRACE -A "Mozilla/5.0" …/protected/ -o /dev/null -w '%{http_code}\n'
405Why the two-layer architecture (and not one big
if chain):
Maps are data: versioned with the config, reviewable as tables, loadable via
includefrom threat feeds (Lesson 2.6), and composable with everything from Module 7.Gates are policy: one place decides what "blocked" means (403? 405? 444?) — change the response without touching classifiers.
Zero runtime branching: map lookups are hash/regex tables; the gate's
ifruns in the rewrite phase — before access checks, buffers, or upstream connections are spent. A blocked request costs microseconds.
2. The sanctioned
if — and the "if is evil" controversy, settled
NGINX's
if (in location context) is a rewrite-phase construct with a deserved reputation for treachery:
if +
proxy_pass in the same location produces famously unpredictable behavior (the "if is evil" page documents a decade of heartbreak). The community's settled guidance — and this series' law:
if
may do exactly two things: evaluate a condition and
return
. Anything else (setting upstreams, rewriting, content) belongs in a
map or a dedicated location.
Why this shape is safe:
return terminates the request
inside the rewrite phase — no later phases run, no content handlers get re-entered, no inheritance surprises (the Lesson 6.5 trap taught the same lesson from the other side: gates before content). Note also the syntax landmine that catches first-timers:
if
conditions don't support
!
negation — write
if ($var = 0), never
if (!$var) (the parser rejects the latter at load).
The negative patterns, so you recognize them in inherited configs:
if ($ua_blocked) { set $pool bad; } # ❌ if + set into content
if ($country = CN) { proxy_pass http://cn; } # ❌ if + proxy_pass = chaos
if ($args ~ id=1) { rewrite ^ /special; } # ⚠️ rewrite-in-if: phase soupEach has a clean equivalent:
map →
$pool; geo-keyed upstreams via variable
proxy_pass (Lesson 7.4's pattern);
map/
split_clients for the routing. Same power, none of the folklore.
3. Scanner food — blocking by path
The internet's background radiation:
/.git/config,
/wp-admin/setup.php,
/phpmyadmin/,
/.env. Every exposure to a public IP collects these within the hour. The answer, four lines:
location ~* /(\.git|wp-admin|phpmyadmin|\.env) {
access_log off; # don't feed the log fire
return 403;
}$ curl -s -o /dev/null -w '%{http_code}\n' …/.git/config
403
$ curl -s -o /dev/null -w '%{http_code}\n' …/wp-admin/setup.php
403Design notes that matter:
access_log off is deliberate (scanner volume is noise; you'll alert on the
aggregate if needed — see the 444 option below); the regex covers whole families, not single paths; and 403-not-404 is a choice (some teams prefer looking like a bare server — module 3.5's dotfile discussion — weigh your threat model's preference for ambiguity vs. clarity).
4. The escalating response ladder
Blocking isn't one response — it's a policy ladder, from gentle to silent:
Rung | Response | Who it's for |
|---|---|---|
1 |
| clearly-bad-but-civil traffic; audit trails matter |
2 |
| wrong method, right request — honest mistake correction |
3 |
| punish with latency, not errors |
4 |
| NGINX's proprietary close-without-response |
5 |
| the IP-range death penalty |
444 deserves its moment: the connection closes mid-handshake/mid- request with
zero bytes written — to a scanner, your server resembles a black hole. It's the standard answer for certificate-SNI-scrapers, credential-stuffers you've given up educating, and the default server of a honeypot. Pair it with
access_log off and it costs you nothing but their time.
5. The honest boundary: edge policy vs. a real WAF
Everything in this lesson is edge policy — classification and gates over request metadata (UA, method, path, address, headers). It stops:
opportunistic scanners (99% of internet noise),
bandwidth/content leeches,
protocol abuse (empty UAs, ancient methods),
geographically-scoped compliance rules.
It does
not stop: SQLi in parameter values, XSS payloads, logic abuse, credential stuffing with realistic UAs. Those need payload inspection — a real WAF (ModSecurity/Coraza with rule sets, or a commercial one)
behind your edge policy, or application-level validation (and Module 11's
auth_request for business-logic gates, Module 15's njs for custom inspection). The professional stack is layered exactly this way: cheap metadata gates at the NGINX edge (this lesson), expensive payload analysis for the traffic that deserves it, application truth at the core.
Try it yourself
Reproduce §1's five probes — then extend the UA map with your own entry (
~*curl— should you block curl? Discuss: what does it protect, what does it break?), reload, test.The
!landmine. Change the method gate toif (!$method_ok)— read the exact parse error. Restore with= 0.Go silent. Change the scanner location to
return 444;and watch curl's confusion (Empty reply from server) — the black hole, felt.Build a tarpit. Give the scanner location
limit_req zone=reqs burst=20;(no nodelay) instead of a return — scanners now queue at 5 r/s. Time 10 requests and smile.Compose everything. Add a geo-gate and a UA-gate and the method gate to one location; write the truth table (which runs first? rewrite-phase gates run in config order — verify it).
Q&A
Q1. Why is
if ($ua_blocked) { return 403; } safe while
if ($ua_blocked) { proxy_pass http://honeypot; } is folklore-dangerous?
Answer
return terminates within the rewrite phase — clean, no later phases, no handler re-entry.
proxy_pass inside
if interacts with the location's real content handler across phase boundaries in ways that have historically been undefined-ish — the "if is evil" corpus. Route with maps/variables into a dedicated location instead.
Q2. What does a client see from
return 444, and why is that sometimes better than 403?
Answer
Nothing — the connection closes with no response bytes. For scanners, it starves them of signal (no banner, no status, no server fingerprint) and wastes their sockets/timeouts; 403 tells them they're being watched and keeps their pipelines efficient.
Q3. Your edge blocks 2M requests/day of scanner noise. What still gets through that this lesson can't stop?
Answer
Payload-level attacks: SQLi/XSS in parameters, realistic-UA credential stuffing, business-logic abuse — anything whose malice lives in content rather than metadata. That's WAF/application territory; this layer's job is to make that layer's bill affordable.
Q4. Design: block the TRACE method globally, with a log entry for the security team. Sketch it.
Answer
map $request_method $trace_seen { default 0; TRACE 1; } at http level; in the server (or an included snippet):
if ($trace_seen) { return 405; } — method metadata, phase-safe gate, default 405 for a method-unsupported signal, and the access log already carries the method line for the security query. (You meet the same shape in §1's
$method_ok.)
Takeaways
Architecture: maps classify, gates act — data-driven rules, one policy point, zero content-phase risk.
The
iflaw: condition +returnonly — andifconditions have no!(a parse error you can reproduce in one edit).Scanner paths get family-regex blocks with
access_log off; the response ladder runs 403 → 405 → tarpit → 444 (silence) → deny.Edge policy is metadata policing — the cheap layer that makes the expensive layers (WAF, app validation) financially viable.
Module 7 complete: you can classify, rate-limit, cap, split, and block — the full policy plane, measured and verified.
Next: Module 8 — the biggest performance lever on the internet: caching. Cache keys, TTLs, stale serving, collapsed requests, and the micro-caching trick that makes dynamic APIs feel static.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.