Context
The site is static pages, a read-only Go API and static files behind nginx and Cloudflare. It
accepts only GET and HEAD, no request bodies, and its only free-text input is the search term
that /proof keeps in the URL. Even so, it is a public site whose reputation matters, it is
scanned constantly, and the origin's port 443 is reachable directly, not only through Cloudflare.
A web application firewall adds a second, independent layer in front of Next.js and the API,
including against vulnerability classes in components we do not control.
Options
- Cloudflare's managed WAF only. Depends on the plan, does not protect direct-to-origin traffic, and cannot be tested in CI.
- ModSecurity v3 with the OWASP Core Rule Set inside the same nginx, in one server block.
Simple, but measured to be a denial-of-service amplifier: the connector evaluates rules in
nginx's rewrite phase, before
limit_req, so a single IP at about 500 requests per second saturated two CPUs on rule evaluation of requests that were then rate-limited anyway. - The same engine and rules in a second nginx server behind a Unix socket, reached only after the public server has applied the connection, rate and method limits.
Decision
The third option.
Engine and rules. libmodsecurity 3.0.17 and the ModSecurity-nginx connector 1.0.4 are compiled in the image build against the exact nginx release and base image in use, with only PCRE2 (JIT), YAJL and libxml2. The Core Rule Set is v4.30.0. All four sources are pinned by SHA-256 in the Dockerfile; their release signatures (OWASP ModSecurity, OWASP CRS and nginx keys) were verified when the pins were set. A weekly workflow reports newer releases.
Mode. Blocking, with CRS anomaly scoring (inbound threshold 5, outbound 4), early blocking, only GET and HEAD allowed, tight argument limits, UTF-8 validation, and request bodies capped at 16 KiB (as in nginx). Blocked requests get a block page (HTML or JSON for the API) with a reference that matches the access log and the firewall log.
Paranoia level 3 of 4, chosen by measurement. With an audit log enabled only for testing,
the full end-to-end suite (about 4,850 legitimate requests, desktop and mobile) matched nothing at
levels 1-2. At level 3 one rule (932237) blocked /proof/fluxa-market-data-pipeline for every
browser and crawler, because "pipeline" is also a command name; it now ignores the URL path and
Referer (paths are fixed by the site; nothing reads the Referer). Level 4 flags Next.js's own
navigation (_rsc, router headers) and is not used. Detection runs at the same level as
blocking: without an audit log, detection-only matches would be invisible.
The /proof search term. Realistic searches ("git worktree", "ssh key", "C# (.NET)",
"p95 < 50ms", "Müller") triggered exactly nine rules. Those nine, and only those, ignore q, only on
/proof. The server never reads q; every other rule, including all XSS rules, still inspects it.
What is not inspected, and why. Static files (/_next/static, /images, /demos) are
served from disk with no code reading the request; inspecting them would cost about 2 ms each
for no protection (a page view loads 15-25 of them). Response bodies are not inspected: CRS's
response rules look for leaked database, PHP, Java, IIS and Ruby errors and web shells, none of
which exist here, and Next.js sends its HTML compressed, so the rules could not read it anyway.
Privacy. No audit log in production: it would record full client addresses and headers, contrary to ADR-0009. A block is logged by nginx as an error-log line (rule, URI, reference).
Consequences
- Measured on the local production stack: zero false positives on the e2e suite, 312 requests from 24 kinds of client (browsers, search and social crawlers, uptime monitors, curl, Postman and other API clients), 45 realistic searches and common tracking parameters.
- Against attacks: a custom battery of 344 requests across 17 categories and GoTestWAF (Wallarm). GoTestWAF's effective block rate on attacks that apply to this site is 97.2%; its API mode blocked every API attack. What still passes cannot reach code that interprets it (base64 blobs, paths that 404, headers nothing reads).
- Cost: about 3-4.5 ms added to page and API requests at light load on the test machine, none for static files; no failures at 360 requests per second. nginx's limits rose to 2 CPUs and 256 MiB. In production (shared AMD EPYC vCPU), measured on the host: the API answers in 3 ms on its own and 11 ms through nginx and the firewall; pages in 10-11 ms and 16 ms; static files in about 1 ms. Visitors' round trip through Cloudflare is about 350 ms from Oman, so the difference is not noticeable. nginx uses about 53 MiB at rest in production.
- During a 2,000 request-per-second flood from one IP, flood requests were refused in about 0.2 ms, nginx used about 21% CPU, and a concurrent visitor saw no slowdown (median 4.6 ms).
- Any new page, parameter or header that CRS dislikes will fail CI (the e2e suite runs through the firewall) before it can reach production. Exclusions are added only for a measured false positive, as narrowly as possible, with a comment naming it.
- Limits: the firewall cannot stop a distributed flood large enough to exhaust the host; restricting port 443 to Cloudflare's address ranges, and Cloudflare's own DDoS protection, are the layers for that.