Request guards

The request guards are the real-time half of the Abuse Guard. Where scan_nginx is a post-hoc log scorer that decides who to ban, the guards are a wall of map/geo directives in the rendered vhost config that classify every incoming request — by client IP, URI shape, User-Agent, Referer and query string — and drop or downgrade hostile ones before they reach PHP-FPM, at effectively zero backend cost.

They live in two source files in provision-private:

  • server.tpl.php — the map/geo definitions in the shared http {} block (rendered once per box). Each map turns a request attribute into a flag variable.
  • Inc/vhost_include.tpl.php — the if (…) { return … } enforcement rules in each server {} block (rendered per vhost). These read the map variables and act.

Because the maps are evaluated lazily and the if checks are cheap string tests, this whole layer runs before any try_files, any @drupal fallback, and any FastCGI round-trip.

The 444-vs-404 convention

BOA uses two distinct refusal codes deliberately, and the choice is load-bearing:

  • return 444 — Nginx's "close the connection, send no response". Used for abuse denials: a banned IP, a malformed asset-chain flood, a no-referer print/search probe, a forged/training AI crawler, a scanner pattern, a foreign-CMS admin probe, a TLS handshake on the plain port. It gives the attacker no signal (no status line, body, or timing leak) and is the cheapest refusal. It also feeds scan_nginx's per-IP 444-weight, so a 444'd request both costs the attacker a connection and accrues score toward a ban. The base 444 "close without response" semantics are on Rewrites & locations.
  • return 404 — a normal, cacheable "not found". Reserved for the cheap content-shape misses where a recoverable error keeps the false-positive blast radius small: the node-chain / lang-chain / content-chain URL-mutation floods, and the special .php-probe URLs. A 404 still avoids a PHP bootstrap, so it is nearly as cheap as 444 but safer when the pattern could (rarely) match real content.

A note on return 403. vhost_include.tpl.php also emits return 403 in several places — but these are not abuse denials. Each is an if ($cache_uid = '') unauthenticated-session gate on a Hostmaster /admin* or /hosting/c/server_* location: an anonymous (no session cookie) request to an admin URL gets 403, while a bot in the same block gets 444. In short: 403 is reserved for admin/Hostmaster session gates; abuse denials use 444.

Keying on the real client (realip)

Every guard that tests the client IP — and scan_nginx itself — keys on the true client address, not on a spoofable X-Forwarded-For. On Cloudflare-fronted vhosts BOA plumbs the realip module in the shared http {} block:

real_ip_header    CF-Connecting-IP;
real_ip_recursive on;
include /data/conf/nginx_cloudflare_real_ip.c*;

The trusted CF source ranges are supplied by the BOA-managed wildcard include — written and refreshed by cloudflare_realip.sh — so a missing file never breaks nginx -t; with no trusted ranges the CF-Connecting-IP header is ignored and $remote_addr is left unchanged (no spoofing risk). After realip runs, $remote_addr is the real visitor, which is what the $is_banned geo and the IP-counting in scan_nginx both score.

One subtlety on the FastCGI side: BOA pins

fastcgi_param REMOTE_ADDR $realip_remote_addr;

so the PHP global sees the original TCP peer (the CF edge), while Nginx's own $remote_addr stays realip-rewritten to the real client for rate-limit keys, logs and the deny geo. This keeps Provision's own PHP-side real-client resolution correct and the Nginx-side guards correct at the same time. This is the request-path counterpart of scan_nginx's real-client resolution; the full CF range refresh and the per-vendor realip plumbing are on Edge policy.

$is_banned — the closing link of the pipeline

The closing link of the ban pipeline is a geo keyed on the realip'd client:

geo $remote_addr $is_banned {
  default 0;
  include /data/conf/nginx_banned_ips.c*;
}

enforced near the top of every vhost:

if ($is_banned) {
  return 444;
}

This closes the loop: nginx_deny.sh regenerates /data/conf/nginx_banned_ips.conf from the current CSF state, the wildcard .c* include picks it up on the next reload, and the next request from a banned client is 444'd at zero backend cost. The same geo also includes nginx_banned_ips.conf6, written by nginx_deny6.sh from the nginx-native IPv6 ban store — csf is IPv4-only, so IPv6 offenders (only reachable via the trusted realip proxy) are banned here at Nginx and 444'd by this identical guard.

Two safety properties matter here:

  • Absent/empty file is safe. With no entries $is_banned stays 0, so a fresh box or a cleared ban list never errors.
  • *The `.cglob is leading-dot-safe.**nginx_deny.shwrites its in-flight and rollback copies as **dot-prefixed** names —.nginx_banned_ips.tmp.$$and .nginx_banned_ips.last_good.conf— precisely so the.c*include never picks up a half-written temp or a backup. Only the finalnginx_banned_ips.conf` is matched.

Because the deny is keyed on the realip'd $remote_addr, it bites a Cloudflare-proxied attacker at the origin's Nginx — where an origin CSF/iptables ban on a CF-fronted IP would only ever see the CF edge and miss.

Chain-mutation flood maps

A distributed botnet that exploits broken relative-URL resolution appends Drupal asset references onto deep content URLs, producing self-mutating chains. BOA classifies the family with purpose-built maps, split by whether the mutated URL ends in a static asset (444) or a content segment (404).

$is_static_chain → 444

if ($is_static_chain) {
  return 444;
}

It matches a Drupal asset-dir marker (sites/all/modules, ui/external, …) buried under a content path, or a canonical Drupal core asset file (system.base.css, drupal.js, …) buried the same way, or the same asset-dir token repeated. Legitimate Drupal asset URLs are root-anchored, so these can only be the broken-relative-URL flood. The map is validated against 44k real flood requests with zero false positives on root-anchored assets, aggregated files, image styles and /system/files private files. The 444 fires before the /(?:external|system)/ asset router would route the absent file to @drupal → /index.php → php-fpm.

$is_content_chain → 404

The content-path twin: the same mutation, but the URL ends in a content segment (no static asset), so without a guard it falls through to Drupal and renders a full themed page (200).

if ($is_content_chain) {
  return 404;
}

It matches only when both signals hold: a Drupal code-dir marker (sites/all/modules, modules/system, ui/external…) appears as a path segment and some path segment repeats 3+ times (the relative-URL accumulation signature). It is deliberately conservative — it covers the clear majority of the variant, not the 2x-repeat tail — and uses a cheap 404 rather than 444 because these are content URLs where a recoverable error keeps the false-positive blast radius small. The complete cure is a source-side <base href>/theme fix that stops the site emitting root-relative-without-leading-slash links.

Deliberate omission on subdir vhosts

Both chain guards apply on full-domain vhosts only. They are intentionally not present in subdir.tpl.php: a subdir site legitimately serves /<subdir>/sites/all/... assets, which $is_static_chain would match as buried-under-content. The node-chain / lang-chain guards (which match on node/<id> repetition and language-prefix runs, not asset paths) do still apply on subdir vhosts.

Print no-referer gate → 444

A printer-friendly or email-this-page request is always a click from a page, so it carries a Referer; a Referer-less hit to a /print* path is the distributed botnet (100% of the observed flood had no Referer). The gate composes $is_print_path (a /print… URI shape, anchored on a numeric node id or an export-format segment) with $has_no_referrer:

map $is_print_path$has_no_referrer $block_print_no_referer {
  default 0;
  "11"  1;
}

enforced as if ($block_print_no_referer) { return 444; }. It is referrer- and path-shape only — no module or version detection — so it is FP-safe and version-agnostic: it covers D7 print / print_mail / print_pdf / printer_and_pdf, D10+ entity_print + printable, and Backdrop, while content slugs (/printing-services, /print/about-us, /printable-maps) never match. The same $has_no_referrer map also feeds the search-amplification family below.

TLS-on-plain → 444

map $request $tls_on_plain {
  default '';
  ~*^\x16\x03 tls_on_plain;
}

matches a TLS ClientHello frame (record type 0x16, TLS version 0x03…) arriving on the plain HTTP port, enforced as if ($tls_on_plain) { return 444; }. It silently drops a TLS handshake mistakenly or maliciously sent to port 80 instead of returning an error that would feed scanner automation. Shipped in BOA-5.9.3.

$is_cms_probe — foreign-CMS admin probes → 444

if ($is_cms_probe) {
  return 444;
}

map $uri $is_cms_probe matches WordPress / Joomla / phpMyAdmin path tokens that can never exist on a Drupal / Backdrop / Aegir-Hostmaster docroot, on any UA: wp-(admin|login|content|includes|json|config|cron|signup|mail|register|links-opml|trackback|comments-post), administrator and phpmyadmin. Each token matches only as a whole path segment: the wp-* and phpmyadmin tokens must be followed by /, ., ? or end-of-path, and administrator by / or end only — so a legitimate alias like /wp-content-strategy or /site-administrator does not match (nor does /administrator.php).

The guard exists because of the FPM sink these probes used to hit: the extensionless variant (/cms/wp-admin, /ru/administrator) misses every static location, falls through try_files → @drupal → /index.php → php-fpm, and pays a full Drupal bootstrap just to render a 404 — the exact sink that let a distributed auth-probe flood saturate a small VM's FPM pool. The 444 drops the probe pre-bootstrap and is scored by scan_nginx's per-IP 444-weight (the 301/extensionless-404 routing these requests previously hit was not scored), so offenders now accrue IDS score with every probe.

Two deliberate omissions:

  • adminer is excluded — BOA ships Adminer.
  • Generic auth words (login, signin, admin, user, account) are not matched — they collide with real customer URL namespaces and with Drupal's own /admin and /user. The distributed tail that probes them is handled in aggregate by the scan_nginx UA-burst detector instead (scan_nginx scoring).

There is no operator knob and no opt-out. The guard takes effect on a vhost once its templates are re-rendered after the Provision update.

Bot, crawler and botnet maps

Several UA-keyed maps hard-block known-bad agents:

Map Variable Enforcement
$is_crawler scraper/SEO/abusive bots (Ahrefs, MJ12, Semrush, PetalBot, Sogou…) if ($is_crawler) return 444
$is_botnet semalt/kambasoft referrer-spam family if ($is_botnet) return 444
$is_bot generic crawler tokens return 444 inside the /search and /user/login blocks

AI-vendor traffic is classified separately by the $is_ai_* maps and the per-class AI policy — those tokens are deliberately kept out of $is_crawler so they don't bypass that policy. See Edge policy. A separate $deny_on_high_load UA map (crawl/spider/google/yahoo/yandex/baidu/bing) is the load-shedding variant: it denies almost all crawlers only while the box is under high load.

Stale-Chrome botnet detection

Chrome auto-updates aggressively, so a genuine consumer install more than ~12 months stale is extremely rare. Search-amplification bots fake a "moderately outdated but not obviously fake" Chrome UA to dodge $is_bot while still being detectably stale:

map $http_user_agent $is_stale_chrome {
  default 0;
  ~*Chrome/1([0-2][0-9]|3[01])\.  1;   # Chrome/100–131: > 12 months stale
}

$block_stale_chrome_search combines a stale Chrome UA with fulltext/facet search params and fires only in search location blocks (so no impact on non-search requests from the same UA class). The standalone $is_catalina_stale_chrome adds macOS Catalina (10.15.7, EOL Nov 2022) + Chrome ≤ 131 — the exact combination of every confirmed Solr search-amplification bot observed May 2026 — and is applied directly in the /search blocks, so it needs no $has_fulltext_search dependency. Both shipped in BOA-5.9.3.

Maintenance caveat (carry verbatim). These dated regexes are self-flagging. The in-source note instructs: when Chrome/132 exceeds 12 months (≈ Feb 2027), widen the upper bound to 3[0-2] and update the comment. The ceiling must move forward as Chrome versions age, or the maps will eventually match current browsers (false positives) rather than stale ones.

Scanner-pattern maps: $is_denied / $ua_denied

Two maps scan the request for attack payloads and 444 it. $is_denied (keyed on $args) is value-scoped — each pattern is anchored to a single query-string parameter value ((?:^|&)[^=&]+=…) to avoid base64/aggregate false positives — and covers:

  • SQLi: union…select, select…from/where, insert…into, delete…from (with whitespace / %20 / %2B / /**/ variants);
  • blind/timing: waitfor delay, declare @, benchmark/sleep/pg_sleep(;
  • hex-literal (0x… after =/char/cast/convert) and comment-obfuscated SQLi (/**/ after a SQL keyword);
  • XSS: <script, %3Cscript, javascript:, vbscript:, data:text/html, onload=, document.cookie (raw and percent-encoded);
  • PHP-source probes (.php?…src/source/highlight);
  • shell injection (system();
  • path traversal raw and single/double percent-encoded (../, %2e%2e/, %252e%252e/).

$ua_denied (keyed on $http_user_agent) catches the same WAITFOR/declare/ benchmark injection payloads when smuggled inside the User-Agent header itself. Both shipped/expanded in BOA-5.9.3.

Search-amplification family

Solr / Search-API full-text search is expensive, so a botnet that hammers it (even one request per IP) can amplify load far beyond its request rate. BOA defends the /search and /user/login location blocks with a layered map family, all keyed off $has_fulltext_search (matches search_api_views_fulltext, search_api_fulltext, im_taxonomy_vid in the query string):

Tier Composed map Signal
Tier 1 $block_search_no_referrer fulltext params and no Referer
Tier 2 $has_excessive_facets 6+ facets (f[5]+), encoded or literal
Tier 2 $block_search_root_referer fulltext and bare-root Referer and a facet present
login $block_login_search_destination search payload in /user/login?destination= and no Referer

These apply as return 444 inside the /search block, the language-prefixed /xx/search block and the /user/login block, alongside limit_req search-rate zones.

5.9.5 facet-required refinement. Tier 2's $block_search_root_referer originally fired on fulltext + bare-root Referer alone — which falsely blocked a homepage plain-search submission (a real user submitting the search form from the front page sends Referer: https://example.com/, a bare root, with no facets). The fix adds $has_any_facet as a required third signal, so the block now needs fulltext + root Referer + at least one facet param. The plain homepage submission has no facet and is no longer a false positive.

$block_login_search_destination closes a bypass: bots send /user/login?destination=search%2F... so the request path is /user/login and the /search guards never run. The map detects the URL-encoded search components (apachesolr_search, search_api, im_taxonomy_vid) inside the destination= value, combined with $has_no_referrer. The search-amplification family landed in BOA-5.9.3.

Tier-A guardrail: capping anonymous localized concurrency (boa_i18n_anon)

Every guard above refuses requests by shape. This one bounds a request class by concurrency: a distributed scraper crawling localized pages drives each uncached page through expensive synchronous backend work, holding a PHP-FPM worker per request — and FPM pools are shared per account, so enough concurrent localized requests collapse every site on the pool. The source spreads across thousands of IPs at one or two requests each, so per-IP limits never trip. The Tier-A cap therefore bounds the aggregate in-flight count of the class per vhost instead of chasing rotating IPs.

The shared http {} block declares limit_conn_zone $boa_i18n_anon_key zone=boa_i18n_anon:10m plus three maps that build the key:

Map Keyed on 1 / ON when
$boa_i18n_guard $host always, default 1 (ON) — per-host opt-out via the wildcard-included /data/conf/boa_i18n_guard.map*
$boa_i18n_path $request_uri the URI starts with a two-letter language prefix — ~*^/[a-z][a-z](-[a-z]+)?/ covers /pt-br/, /zh-hans/ — or carries the D7 form ~*[?&]q=/?[a-z][a-z](-[a-z]+)?/
$boa_is_anon $cache_uid the session map is empty — no Drupal session cookie

Three design points make the maps safe:

  • Default-on is safe fleet-wide. An absent or empty /data/conf/boa_i18n_guard.map leaves every host guarded, because a leading two-letter path prefix is Drupal's URL language-negotiation convention, never a content subdirectory — the existing /[a-z][a-z]/search and /[a-z][a-z]/civicrm locations rely on the same convention.
  • $request_uri, not $uri. Clean URLs are internally rewritten to /index.php before the map is evaluated, so only the original request URI still carries the language prefix. The ?q= pattern cannot match ordinary q=node/ or q=user/ values — those are never exactly two letters followed by /.
  • Logged-in users are never capped. $boa_is_anon reuses the authoritative $cache_uid session map, so an editor working in /de/admin/… is invisible to the zone.

The composite key $boa_i18n_anon_key is $host only when all three flags read 111, otherwise empty — and empty keys are not counted. The cap is thus per-vhost and constant-keyed: English, static, authenticated and opted-out traffic never touches it.

Enforcement sits at location = /index.php — the single chokepoint every dynamic request funnels through:

limit_conn        boa_i18n_anon 24;
limit_conn_status 444;

The 24 comes from the Provision-side drush option nginx_i18n_anon_conn (default 24; values below 1 are clamped back to 24) — a provision/drush option, not a .barracuda.cnf _VAR. The in-source sizing note pegs 24 at roughly 1/8 of a 192-worker FPM pool. Static files under /xx/ are served by their own locations and never reach this chokepoint, so they are correctly excluded.

The cap itself bans nobody: a shed request is answered 444 by Nginx and the shed creates no CSF entry — nothing appears in csf -t / csf -g for the shed as such (a heavy single IP can still accrue per-IP scan_nginx score from its logged 444s via the normal 444-weight, a separate mechanism). The windowed count of these 444s is also the earliest trip signal (the C444 threshold) for the log-side Tier-B i18n-flood detector in scan_nginx.

Opt a vhost out by adding a "host" 0; line to /data/conf/boa_i18n_guard.map and reloading Nginx.

One capacity cross-note: when this request class saturates a pool, raising pm.max_children is not the cure — see FPM capacity sizing.

The edge-policy layer (defined here, documented separately)

Three further request-path defences are defined in the same server.tpl.php / vhost_include.tpl.php pair but belong to BOA's edge-policy layer, documented on Edge policy rather than redefined here:

  • AI-class policy maps$is_ai_training, $is_ai_search, $is_ai_evasive, $is_ai_forged. Training and evasive AI fetchers are blocked by default (444) with a per-site opt-in; forged AI UAs (robots.txt-only tokens a real client never sends) are universally 444'd.
  • Secret-path denymap $uri $is_secret_path 444's probes for .env / .git / .aws / .ssh, secrets.json, config.json, application.yml, settings.py and similar, on any UA.
  • Cloudflare realip ranges — the trusted-range include refreshed by cloudflare_realip.sh (see "Keying on the real client" above); per-site IP access control lives on the same Edge policy page.

These share this map/geo layer but are policy-configurable per site, so they are documented with their own control files on Edge policy rather than as fixed guards here.

Where each guard fires (ordering)

Within a vhost, the guards run roughly in this order — earliest = cheapest / most universal:

$is_node_chain          → 404
$is_lang_chain          → 404
$is_static_chain        → 444
$is_content_chain       → 404
$block_print_no_referer → 444
SA-CORE-2018-002 RCE    → 444
$is_banned              → 444   ← ban-pipeline closing guard
=PHP… version probe     → 404
$is_secret_path         → 444   edge-policy
$is_cms_probe           → 444
$is_ai_forged           → 444   edge-policy
AI training / evasive   → 444   edge-policy
$is_crawler             → 444
$is_botnet              → 444
bad request method      → 444
$is_denied              → 444
$ua_denied              → 444
$tls_on_plain           → 444
… then per-location: /search, /xx/search, /user/login families,
  and the Tier-A boa_i18n_anon cap at location = /index.php

Config-template tunables (5.9.3)

Two shared http {}-block tunables were adjusted alongside these maps and are worth noting at the request-guard layer, though they are config rather than guards:

  • variables_hash_max_size 2048 — raised to accommodate the growing set of map variables.
  • fastcgi_cache_use_stale no longer includes http_503 (it now reads error http_500 invalid_header timeout updating) — a 503 is no longer served from stale cache.

Related

  • scan_nginx scoring engine — the post-hoc scorer that produces the bans these guards enforce.
  • The ban pipeline — how web.log → guest-fire / guest-water → CSF → nginx_deny.shnginx_banned_ips.conf feeds the $is_banned geo.
  • Rewrites & locations — the base return 444 "close without response" semantics and the location-matching model.
  • Edge policy — the per-class AI bot policy, Cloudflare realip range refresh, and secret-path deny that share this map/geo layer but are policy-configurable per site.
  • Security & isolation — CSF + LFD firewall — the firewall lifecycle that consumes the scorer's output.
  • FPM capacity sizing — why raising pm.max_children is not the answer to the abusive saturation the Tier-A cap absorbs.