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— themap/geodefinitions in the sharedhttp {}block (rendered once per box). Each map turns a request attribute into a flag variable.Inc/vhost_include.tpl.php— theif (…) { return … }enforcement rules in eachserver {}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 feedsscan_nginx's per-IP444-weight, so a 444'd request both costs the attacker a connection and accrues score toward a ban. The base444"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.phpalso emitsreturn 403in several places — but these are not abuse denials. Each is anif ($cache_uid = '')unauthenticated-session gate on a Hostmaster/admin*or/hosting/c/server_*location: an anonymous (no session cookie) request to an admin URL gets403, while a bot in the same block gets444. 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_bannedstays0, so a fresh box or a cleared ban list never errors. - *The `.c
glob 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:
admineris 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/adminand/user. The distributed tail that probes them is handled in aggregate by thescan_nginxUA-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_refereroriginally fired onfulltext + bare-root Refereralone — which falsely blocked a homepage plain-search submission (a real user submitting the search form from the front page sendsReferer: https://example.com/, a bare root, with no facets). The fix adds$has_any_facetas a required third signal, so the block now needsfulltext + 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.mapleaves 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]/searchand/[a-z][a-z]/civicrmlocations rely on the same convention. $request_uri, not$uri. Clean URLs are internally rewritten to/index.phpbefore the map is evaluated, so only the original request URI still carries the language prefix. The?q=pattern cannot match ordinaryq=node/orq=user/values — those are never exactly two letters followed by/.- Logged-in users are never capped.
$boa_is_anonreuses the authoritative$cache_uidsession 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 deny —
map $uri $is_secret_path444's probes for.env/.git/.aws/.ssh,secrets.json,config.json,application.yml,settings.pyand 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 ofmapvariables.fastcgi_cache_use_staleno longer includeshttp_503(it now readserror http_500 invalid_header timeout updating) — a503is 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.sh→nginx_banned_ips.conffeeds the$is_bannedgeo. - 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/geolayer 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_childrenis not the answer to the abusive saturation the Tier-A cap absorbs.