nginx-error-abuse-module

August 19, 2026 · View on GitHub

Build&Test Security Scanners Fuzzing Valgrind CodeQL A/UBSan Lint Windows CI Deep

CI

WorkflowWhat it gates
ci.ymlorchestrator; the only pull_request entry point, calls the eight members below
build-test.ymlbuild, .so dlopen, bad-config rejection, -Werror, Test::Nginx runtime suite
security-scanners.ymlflawfinder, clang-tidy, Semgrep
fuzzing.ymlrecorded-regression replay, then a fresh time-boxed libFuzzer run against the parse targets
valgrind.ymlmemcheck soak
codeql.ymlCodeQL over the module TU
asan.ymlASan+UBSan request-storm soak (static --add-module build), single-process + multi-worker/reload lanes
lint.ymlci/linter/ — shellcheck, ruff, perlcritic, yamllint/actionlint/zizmor, nginx-convention and repo-policy checks; same entry point as .githooks/pre-commit
windows.ymlMSVC and MinGW-w64 static builds; native persistence, restart and two-worker ownership controls under MSVC
ci-deep.ymlmonthly schedule; long fuzz + memcheck + helgrind sweep, not a PR-lane member

CI caching

ci/tools/ci-build.sh is the single chokepoint every build-test/asan/codeql/ fuzzing/ci-deep(fuzz) job goes through — no workflow duplicates cache logic. Layers, cheapest first: apt/package presence check, ccache (CCACHE_COMPILERCHECK=content), mold (skipped under asan), eatmydata (wraps ./configure only), the per-mode build tree (.build/<flavor>-<version>-<mode>/, exact-match cache key — hashFiles(ci/tools/ci-build.sh, config, src/**), deliberately no restore-keys ladder), the source tarball (keyed on version alone). .github/actions/build-cache/action.yml documents each layer's key and why.

Measured locally on this build host (/proc/loadavg 1.3–1.6 at the time, network to nginx.org unthrottled — CI's numbers will differ, this is the mechanism, not a promised CI time):

RunWall clock
Fully cold (no tarball, no build tree, no ccache)6.7s
Warm re-run, nothing changed (build tree hit, configure skipped, make no-op)0.02s
One source file touched, warm tree (configure skipped, one TU recompiles via ccache)0.1s

nginx's configure ignores a bare CC=, so ccache is wired through the --with-cc="ccache $BASE_CC" argument instead. Verified by measurement, not by reading the log: with the build tree removed and ccache warm, a rebuild reports 132/132 hits (100.0%). Under those same conditions (warm cache, build tree removed, unchanged compiler and flags) a 0% rate means ccache is not wired through configure at all. A cold cache, a changed CC_OPT/LD_OPT, or a different CCACHE_DIR also produce 0% for their own reasons, so rule those out before reading it as a wiring fault.

Note that ci/tools/ci-build.sh sets CCACHE_DIR=$HOME/.cache/ccache; a bare ccache --show-stats reads $HOME/.ccache instead and reports a different cache. Export CCACHE_DIR before checking these numbers by hand.

The honest win here is the warm no-op case, not a claimed multi-minute save — this module's from-scratch build is already only a few seconds on a fast network, so the layers mostly buy back CI minutes on repeat PR pushes and the fuzzing/codeql "module" mode (configure-only, no core make), where skipping configure is the whole cost. Invalidation was verified directly: a one-byte source edit changes the hashFiles() build-tree key (proven with a standalone hash check) and, within an already-warm tree, make recompiles exactly the changed translation unit rather than serving a stale object.

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 log_level=notice; # 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. The key must be at least 16 bytes (32 hex characters); 32 bytes (64 hex characters) is recommended. Generate with openssl rand -hex 32.

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. Log records use the 64-character lowercase digest as client_sha256; raw identity bytes are never written to the log. Redis and hiredis failures retain numeric status, reply type and detail length for diagnosis, but omit backend-provided error text.

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.

ParameterDefaultMeaning
zone(required)Name and shared-memory size, e.g. client:10m.
key$binary_remote_addrWhat identifies a client (an NGINX variable).
statuses403,404,500-599Which status codes count. Exact codes or ranges.
interval300sSliding time window the counting happens over.
threshold100Hits in the window before a ban (max 1024).
block60mHow long the ban lasts.
inactivemax(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).
redisoffShare this zone's state via Redis.
persist(none)File path to snapshot state to disk.
persist_interval5sHow often to write the snapshot.
persist_secret(none)Hex-encoded HMAC-SHA256 key (min. 32 hex chars / 16 bytes, recommended 64 hex chars / 32 bytes); when set, the snapshot is authenticated and a tampered/forged file is rejected on load. Requires persist. Generate with openssl rand -hex 32.
on_fullallowWhat happens to a new (not-yet-tracked) identity's error response when the zone's shared memory is full: allow passes that one response through untracked (the historical behaviour; identities already tracked with an active ban are still rejected as normal); reject applies the location's configured rejection status instead, so a full zone fails closed. An active ban is never evicted to make room, under either setting — only unblocked, aged-out identities can be reclaimed.

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. If nginx cannot allocate either rejection header, the header filter returns NGX_ERROR and aborts the response instead of sending a partial ban response; Retry-After is advisory, but follows nginx's normal allocation- failure convention rather than being silently omitted.

An error_page internal redirect (either a URI target or a named @location) does not lose enforcement: the identity, counter and ban status earned at the ORIGIN location follow the redirect to the destination even when the destination has error_abuse off, is bound to a different zone, or serves a custom rejection page. The destination's dry_run= is ignored in that case — the origin's dry_run wins for the whole request.

error_abuse_redis host=name|tls://name|rediss://name [port=number] [user=name] [password=value] [db=number] [prefix=value] [timeout=time] — context: http

Points the module at one Redis server (see below). host= is required; every other option is optional and must use the key=value form shown above. The tls:// and rediss:// host schemes both enable TLS.

host= is resolved to a numeric address once, at config load, so the worker event loop never blocks on getaddrinfo() when (re)connecting. Two consequences: a name with several A records is pinned to its first address for the life of the config (no client-side DNS failover — reload to re-resolve), and if the name cannot be resolved at load time the module logs a warning and falls back to resolving it in the worker (which can briefly block the event loop on reconnect during an outage) rather than failing the reload. Prefer a numeric IP for a Redis behind volatile DNS.

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 (or host=rediss://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;

Requirements

Build against an nginx or Angie source tree with a C compiler and the usual PCRE2 and zlib development headers. The module also links against hiredis with TLS support and OpenSSL (libhiredis-dev and libssl-dev on Debian/Ubuntu).

Windows supports a static nginx module with either MSVC or MinGW-w64. MSVC uses vcpkg's hiredis[ssl]:x64-windows-static triplet and /MT; MinGW-w64 uses the SSL-enabled MSYS2 hiredis package. A Win32 dynamic module is supported only by nginx's GCC/Clang build path, not by MSVC.

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.

For MSVC, set NGX_ERROR_ABUSE_INCS to the vcpkg include directory and NGX_ERROR_ABUSE_LIBS to absolute paths for the hiredis and OpenSSL static libraries plus crypt32, advapi32, and ws2_32. Configure nginx with --crossbuild=win32 --with-cc=cl, an x64 vcvars64 environment, and /MT. Nginx 1.31.x also needs its private XP-compatibility WSAPoll pointer renamed before compilation because static hiredis imports the SDK symbol with that name; the Windows workflow contains the exact source-local substitution. The windows.yml workflow is the executable build recipe for both Windows toolchains.

Linting

Enable the repository hook once with git config core.hooksPath .githooks, then stage the files you want checked. Run bash ci/linter/run-all.sh for a full-tree pass. The checker inventory, requirements, and selective-run syntax are documented in ci/linter/README.md.

See also