Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

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.

Levels: Intermediate ~35 min read

Intermediate

What you will learn

  1. The classification→blocking architecture: maps decide, gates act.

  2. The sanctioned if pattern — condition + return, nothing else — and why the rewrite phase is the safest place to block.

  3. Scanner food: path-based blocking that quiets your logs.

  4. The escalating response ladder: 403 → tarpit → 444 (silence).

  5. The honest boundary between edge policy and a real WAF.


1. Classification first, blocking second

The /protected/ location, complete:

nginx
# 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:

bash
$ 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'
405

Why the two-layer architecture (and not one big if chain):

  • Maps are data: versioned with the config, reviewable as tables, loadable via include from 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 if runs 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:

nginx
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 soup

Each 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:

nginx
location ~* /(\.git|wp-admin|phpmyadmin|\.env) {
    access_log off;      # don't feed the log fire
    return 403;
}
bash
$ 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
403

Design 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

403 + logged

clearly-bad-but-civil traffic; audit trails matter

2

405

wrong method, right request — honest mistake correction

3

limit_req tarpit (Lesson 7.2's no- nodelay queue)

punish with latency, not errors

4

return 444;

NGINX's proprietary close-without-response

5

deny + no-log

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

  1. 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.

  2. The ! landmine. Change the method gate to if (!$method_ok) — read the exact parse error. Restore with = 0.

  3. Go silent. Change the scanner location to return 444; and watch curl's confusion ( Empty reply from server) — the black hole, felt.

  4. 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.

  5. 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 if law: condition + return only — and if conditions 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.

Was this page helpful?

Mark this learning resource complete to track your learning progress.