Skip to content

eilandert/nginx-error-abuse-module

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

36 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nginx-error-abuse-module

Build and Test Valgrind CodeQL Security Scanners Fuzzing

What is this?

Imagine someone keeps poking your website with requests that don't exist — hammering random URLs (lots of 404s), banging on stuff they're not allowed to see (lots of 403s), or spamming requests until your server starts coughing up 500 errors. That's abuse, and it wastes your server's time.

This is a small NGINX module, written in plain C, that watches those error responses and bans the troublemaker automatically. Think of it like fail2ban, except it lives inside NGINX — no extra daemon reading log files, no Lua, no JavaScript. You pick which error codes count, how many are allowed, and for how long the offender gets locked out.

When a client crosses the line, it starts getting 429 Too Many Requests (or any status you choose) until the ban expires. Counters are shared across all NGINX worker processes, can survive a restart (disk snapshots), and can even be shared between multiple servers using Redis.

Full configuration example

load_module modules/ngx_http_error_abuse_module.so;

http {
    # Optional: share bans across servers (see the Redis section).
    error_abuse_redis host=127.0.0.1 port=6379 prefix=ea_ timeout=100ms;

    # Define a zone: a shared-memory area that holds the counters.
    error_abuse_zone zone=client_errors:10m
                     key=$binary_remote_addr
                     statuses=403,404,500-599
                     interval=300s
                     threshold=100
                     block=60m
                     inactive=1h
                     persist=/var/lib/nginx/error-abuse-client_errors.state
                     persist_interval=5s;

    # Handy log line so you can see what the module decided.
    log_format main '$remote_addr $request $status '
                    'error_abuse=$error_abuse_status '
                    'count=$error_abuse_count';

    server {
        location / {
            error_abuse zone=client_errors;   # turn it on here
        }
    }
}

Read that zone line as: "if one IP causes 100 of these error responses within any 5-minute window, ban it for 60 minutes." The persist= file lets bans survive a full NGINX restart; the directory must already exist and be writable by the worker user. Keep that directory private (owned by the worker user, not group/world-writable): the snapshot's CRC32 detects corruption but is not tamper protection, so anyone who can write the file can forge or remove bans. Temp snapshots are created with O_EXCL|O_NOFOLLOW and fsync'd before the atomic rename, so a hostile symlink cannot redirect the write and a crash cannot leave a truncated state file. On builds with --with-threads the snapshot file I/O runs on a thread pool, so a slow disk never stalls the worker event loop. The on-disk format is a portable little-endian byte stream (not a native struct dump). Set persist_secret=<hex> to additionally authenticate the file with HMAC-SHA256.

Client identities are stored as a fixed 32-byte SHA-256 digest of the key, so a large key variable ($request_uri, $http_*) costs the same memory and Redis traffic as a small one — there is no per-key amplification.

Good news: almost everything has a sensible default. The shortest config that actually works is just:

error_abuse_zone zone=client_errors:10m;     # uses all defaults below
location / { error_abuse zone=client_errors; }

Synopsis (directives + defaults)

error_abuse_zone zone=name:size [...] — context: http

Declares a zone. Only zone=name:size is required; the rest default to a deliberately relaxed policy that catches sustained abuse, not the odd 404.

Parameter Default Meaning
zone (required) Name and shared-memory size, e.g. client:10m.
key $binary_remote_addr What identifies a client (an NGINX variable).
statuses 403,404,500-599 Which status codes count. Exact codes or ranges.
interval 300s Sliding time window the counting happens over.
threshold 100 Hits in the window before a ban (max 1024).
block 60m How long the ban lasts.
inactive max(1h, interval, block) Idle clients are forgotten after this. An explicit value must be >= both interval and block (otherwise live windows/bans would expire early — rejected at config time).
redis off Share this zone's state via Redis.
persist (none) File path to snapshot state to disk.
persist_interval 5s How often to write the snapshot.
persist_secret (none) Hex-encoded key; when set, the snapshot is authenticated with HMAC-SHA256 and a tampered/forged file is rejected on load. Requires persist.

error_abuse zone=name [status=code] [dry_run=on|off] [log_level=level] — context: http, server, location

Switches a declared zone on for that location. error_abuse off; turns it back off (a second declaration in the same block is a duplicate error, in either order). Default ban response is 429; log levels are info, notice, warn, error. dry_run=on is observation-only: it logs (at log_level) what would happen but never writes shared-memory or Redis ban state, so an enforcing sibling location on the same zone is never contaminated. The module never counts its own ban responses or subrequests.

Every ban response the module generates carries Cache-Control: private, no-store so a downstream shared cache can never replay one client's ban to another; 429/503 responses also get a Retry-After reflecting the ban deadline.

error_abuse_redis host=[tls://]name [port] [user] [password] [db] [prefix] [timeout] — context: http

Points the module at one Redis server (see below).

Variables

  • $error_abuse_statusBYPASSED, PASSED, COUNTED, BLOCKED, or DRY_RUN.
  • $error_abuse_count — matching responses currently in the window.
  • $error_abuse_blocked_until — Unix timestamp the ban ends, or 0. Populated for both local and Redis bans (Redis stores the absolute deadline as the block value).

About Redis (optional)

By default each NGINX server bans on its own. If you run several servers behind a load balancer, an attacker banned on one could just hit another. Add error_abuse_redis and set redis=on on a zone, and all servers sharing the same prefix and zone settings count together and share bans. It speaks plain RESP, so Valkey works too. Connections are non-blocking, so a slow or dead Redis never freezes a request — the module just falls back to its own local counters (fail-open), and a circuit breaker pauses Redis for 30s after repeated failures so your logs don't fill up. You can lock it down with AUTH (user=/password=), pick a database (db=N), and encrypt with TLS by prefixing the host: host=tls://redis.internal. Treat Redis as a trust boundary — a compromised Redis could inject fake bans, so keep it on a private network.

Excluding clients (allowlists)

The module ignores empty keys. So the cleanest way to allowlist someone is a map that returns an empty string for trusted IPs:

map $remote_addr $error_abuse_key {
    127.0.0.1   "";            # localhost: never banned
    10.0.0.0/8  "";            # internal network: never banned
    default     $binary_remote_addr;
}

error_abuse_zone zone=client_errors:10m key=$error_abuse_key;

Behind a proxy or CDN? Don't trust X-Forwarded-For directly — that's spoofable. Set up the standard realip module so $binary_remote_addr becomes the real client IP:

set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Building from source

apt-get install libhiredis-dev        # provides hiredis + its TLS lib

./configure --with-compat \
    --add-dynamic-module=/path/to/nginx-error-abuse-module
make modules

Copy objs/ngx_http_error_abuse_module.so into your NGINX module directory and load_module it. The full CI/sanitizer matrix lives in .github/CI.md.

See also

About

NGINX module that ratelimits error (404) abusers

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Contributors