/admin* URL protection + ip_access
By default, BOA guards Drupal's /admin* paths against anonymous access — both
as anti-brute-force hardening and to prevent crawl-of-admin-pages noise. Two
independent mechanisms are in play, and it matters which is which because only
one of them has an opt-out:
- An unconditional Nginx guard — the vhost's
location ^~ /adminblock refuses an anonymous or bot request outright. It is emitted on every site and is not keyed on any INI variable, so it cannot be turned off per site. - An INI-gated PHP 301 redirect-to-homepage — the BOA front-end include
redirects anonymous requests for
/admin*(and/logout,/privatemsg,/approve) to the site homepage. This is the layer thedisable_admin_dos_protectionopt-out controls.
This page covers both, how to opt out of the redirect per site or per platform,
and the separate whole-site IP allow-list feature (ip_access).
The unconditional Nginx guard
For every Drupal site, the BOA-emitted Nginx vhost contains a
location ^~ /admin block that decides on the recovered client identity
(vhost_include.tpl.php, the /admin block):
location ^~ /admin {
if ($cache_uid = '') {
return 403;
}
if ( $is_bot ) {
return 444;
}
...
try_files $uri @drupal;
}
- Anonymous request (no authenticated-session cookie, so
$cache_uidis empty) →403. - Bot (matched by
$is_bot) →444(connection closed, no response). - Authenticated user → passes through to Drupal (
try_files $uri @drupal).
This block is unconditional — it is not tied to disable_admin_dos_protection
or any other INI toggle, and there is no per-site way to switch it off. It is one
of several sibling per-path guards in the vhost (the redis/cache-backend/reports
paths use the same $cache_uid = '' → 403 / $is_bot → 444 pattern). The
disable_admin_dos_protection opt-out below does not relax this guard.
The INI-gated 301 redirect-to-homepage
Separately, BOA's front-end PHP include (global-front-end.inc, also mirrored in
global.inc) redirects an anonymous visitor away from /admin* to the site
homepage with an HTTP 301 before Drupal renders anything:
if ($is_anon == 'ANONYMOUS') {
if (preg_match("/^\/(?:[a-z]{2}\/)?(?:admin|logout|privatemsg|approve)/", $_SERVER['REQUEST_URI'])) {
if (empty($all_ini['disable_admin_dos_protection'])) {
header("Location: " . $base_url . "/", true, 301);
exit;
}
}
}
The match covers /admin*, /logout, /privatemsg and /approve (with an
optional two-letter language prefix, e.g. /de/admin). This is current, live
BOA behaviour, not a legacy Drupal artefact — it is the mechanism the
disable_admin_dos_protection INI variable turns off.
Note the interaction with the Nginx guard above. A request with a stale or
expired session cookie still matches the SESS* cookie pattern that populates
$cache_uid, so it is not empty and passes the Nginx 403 gate — but the
session is invalid, so PHP classifies the visitor as ANONYMOUS and issues the
301 to the homepage. That is why a user with a stale cookie can be "bounced to
the homepage even though they were logged in": they clear Nginx but hit the PHP
redirect.
Why it exists
- Brute-force attack surface reduction — bots scanning for Drupal admin pages are refused before they can attempt logins.
- Crawl-volume reduction — old Drupal versions exposed
/admin*paths to anonymous users (e.g.?q=adminqueries); the Nginx block short-circuits that pattern. - Defense in depth — even if Drupal itself misbehaves and leaks admin-page content to anonymous users, Nginx refuses the request first.
When you'd opt out
Legitimate workflows that need /admin* reachable by anonymous clients:
- Custom auth flows that depend on a specific admin URL being reachable pre-auth.
- Drupal Webform / Form API integrations with URLs under
/admin/...that anonymous clients must POST to. - Specific application requirements dictated by an upstream software vendor.
If you control the site and know what you are doing, you can opt out per site or
per platform. Note the opt-out only lifts the PHP 301 redirect; a truly
cookieless anonymous client is still refused by the unconditional Nginx guard, so
these workflows must present a well-formed session cookie to reach /admin*.
Per-site / per-platform opt-out — INI
The control is the disable_admin_dos_protection INI variable, defined in
both the site and platform INI templates
(default.boa_site_control.ini and default.boa_platform_control.ini), shipped
commented at its default of FALSE:
;disable_admin_dos_protection = FALSE
To disable the /admin DoS-protection redirect, set it to TRUE
(uncommented). This suppresses the 301 redirect-to-homepage described above so
that an anonymous request for /admin* reaches Drupal instead of being bounced to
the homepage. It does not relax the unconditional Nginx 403/444 guard —
that block ignores this variable entirely (see the caveat under Verify below).
Per site, in <platform-root>/sites/<domain>/modules/boa_site_control.ini:
disable_admin_dos_protection = TRUE
Per platform (applies to every site on the platform), in
sites/all/modules/boa_platform_control.ini:
disable_admin_dos_protection = TRUE
The value is read into the front-end include set (global-ini.inc defines its
FALSE default; global-front-end.inc / global.inc consume it in the redirect
condition) and is applied at the PHP layer on every request — it is not
consumed by any night-maintenance script. There is no admin_url_protection
variable — that key does not exist anywhere in the codebase, so a line such as
admin_url_protection = 0 is a no-op. After saving, wait ~60 s (PHP-FPM opcache)
and run Verify on the site.
Platform-level use case: a custom platform with a non-Drupal codebase behind Aegir for hosting that does not follow Drupal's URL conventions.
Verifying the protection state
Because two layers are involved, what curl reports depends on whether the
request carries a session cookie:
# Anonymous, no cookie at all — hits the unconditional Nginx guard first.
curl -I https://your-site.example.com/admin/
# => 403 (or 444 for a recognised bot UA — connection closed).
# The disable_admin_dos_protection opt-out does NOT change this: the Nginx
# 403/444 guard is unconditional and always fires before Drupal.
# With a valid (or merely well-formed / stale) SESS* cookie — clears the Nginx
# guard because $cache_uid is then non-empty, and reaches the PHP layer:
# - protection on (default): 301 redirect to the site homepage
# - after opting out (TRUE): the request reaches Drupal (e.g. admin login)
BOA also emits an X-Ini-Disable-Admin-Dos-Protection response header carrying
the resolved value, which is a quick way to confirm the INI value that the
front-end include actually loaded for the site.
Key point: setting disable_admin_dos_protection = TRUE opts out of the PHP
301 redirect only. It does not make a cookieless anonymous client reach
Drupal's /admin* — the unconditional Nginx 403/444 guard still refuses that
request first.
Restricting a whole site to an IP allow-list (ip_access)
The /admin* protection above is path-scoped. For a stronger control — locking
an entire site (an intranet, a staging site, an admin-only area) to a set of IP
addresses — BOA ships ip_access, a single global generator that emits per-site
Nginx allow/deny fragments. Everything not on the list gets a 403 (Nginx
deny all;).
It is a single global generator deployed at /var/xdrago/ip_access.sh,
replacing the older per-Octopus nginx_ip_access_<oct>.sh copies. It runs one
shared routine over two kinds of context:
- the master (sqladmin proxy):
/var/aegir/control/ip/access.txt→/var/aegir/config/includes/ip_access/. On each run the generator auto-seeds this control file with the recordsqladmin.com 192.168.1.1when it is absent, producing asqladmin.conffragment. That fragment is what actually guards the SQL-admin proxies — it is pulled in by the Adminer/phpMyAdmin-style vhosts (nginx_sql_adminer.conf,nginx_sql_buddy.conf,nginx_sql_cgp.conf,nginx_sql_chive.conf) and byssl_proxy.confviainclude .../ip_access/sqladmin*;. The seed IP192.168.1.1is a private placeholder, so on a real box only the anti-lockout IPs (loopback, the server's own IP, established SSH clients) can reach the SQL admin tools until an operator adds real public IPs to that control file; - every Octopus instance:
/data/disk/<oct>/static/control/ip/access.txt→/data/disk/<oct>/config/includes/ip_access/. Only real instances are processed (identified by the BOA-canonicaltools/drushmarker, so pseudo-dirs likearch,all,legacy,global,static,customare skipped).
The per-site vhost pulls the fragment via
include $server->include_path/ip_access/<uri>.conf*, so the restriction applies
to that site only (the .conf* suffix anchors the glob to this site's own
fragment; a bare <uri>* would also match a longer site whose name extends it,
e.g. example.com vs example.com.au). A site with no record has no fragment and
is unrestricted. (The sqladmin* master include above is deliberately a broader
prefix glob — it matches the seeded sqladmin.com.conf.)
Control file format
One site per line — the site name followed by space-separated allowed addresses, each an IPv4 or IPv6 address with an optional CIDR prefix:
# /data/disk/o1/static/control/ip/access.txt
intranet.example.com 203.0.113.10 203.0.113.0/24 2001:db8::/32
staging.example.com 198.51.100.42 2001:db8:1::1
#comments and blank lines are ignored.- Each address is validated against a strict subset of what the Nginx access module accepts; a malformed address or an invalid site name is skipped with a logged warning while the rest of the line still applies — one bad token never breaks the box-wide Nginx configtest.
Anti-lockout
Every generated fragment always allows, in addition to the listed IPs, so a typo can never strand the site or the operator:
127.0.0.1and::1— loopback;- the server's own IPv4, read from
/root/.found_correct_ipv4.cnf(BOA tracks no server IPv6); - every established inbound SSH client IP (IPv4 or IPv6), harvested from
netstat -tn(peers on anESTABLISHED:22connection — the peer address is taken by stripping the trailing:port, and each is validated before it reaches anallowline). BOA usesnetstathere, notwho --ips, becausewho --ipsis unavailable on Excalibur and newer.
An admin working over SSH is therefore added to every site's allow-list automatically. The SSH-client set is part of the change-gate, so a newly logged-in admin triggers a regenerate on the next pass.
Generator behaviour
- Change-gate — a context regenerates only when its control file's mtime advanced or the host's SSH-client set changed. No change → no write, no reload.
- Prune-on-removal — deleting a site's line removes its fragment on the next run, lifting the restriction (the site is open again).
- Per-context safety — back up current fragments, regenerate atomically, run
service nginx configtest, thenreload; on a failed configtest or reload, restore the last-good backup and reload (or drop the just-written fragments if there is no last-good yet). - Serialisation — the whole run holds the shared
/run/boa_nginx_config.lock(flock -w 30, then skip and retry next tick) so it never overlaps the other Nginx-config writers (ai_policy,nginx_deny,cloudflare_realip). - Schedule —
*/2cron, so changes take effect within about two minutes.
IPv4, IPv6 and CIDR
ip_access is a pure Nginx allow/deny layer — it keys on the recovered
client IP at the web tier and does not touch CSF — so it accepts both address
families and subnets: each entry may be an IPv4 or IPv6 address, singly or as a
CIDR range (203.0.113.0/24, 2001:db8::/32, …). The validator is a strict subset
of what the Nginx access module accepts, so a validated entry can never break the
box-wide configtest. This is independent of CSF: adding an IPv6 rule here restricts
the site at Nginx but does not add a host-firewall rule (CSF remains a separate
layer, and BOA tracks only the server's own IPv4 for anti-lockout). The restriction
is whole-site; for a path-scoped variant limited to /user + /admin, see the
user_admin_access section below.
Behind Cloudflare (realip)
The allow/deny rules key on $remote_addr. With Cloudflare realip active,
$remote_addr is the real visitor IP, so enter the visitor's real public IP
(what e.g. https://ifconfig.co shows) — which is what an operator naturally
does. In the brief pre-cron window before realip activates, a CF-proxied site
would see the CF edge instead; install-time realip activation closes that window
on a normal box.
Verify
# inspect a site's generated allow/deny fragment
cat /data/disk/o1/config/includes/ip_access/intranet.example.com.conf
# from a non-allowed IP -> 403; from a listed IP (or over the
# server / SSH) -> 200
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/
service nginx configtest
The script is aegir/tools/system/ip_access.sh, deployed as
/var/xdrago/ip_access.sh.
Restricting /user + /admin to an IP allow-list (user_admin_access)
ip_access above locks a whole site. Often you instead want the public site to
stay open to everyone while only the login and admin surface — Drupal's
/user and /admin URIs — is reachable from a trusted set of addresses.
user_admin_access does exactly that: a per-site Nginx allow-list scoped to those
paths, returning 403 to anyone else who requests them and leaving the rest
of the site public.
Like whole-site ip_access, it is a pure Nginx allow/deny layer with no CSF
involvement, so it accepts IPv4 and IPv6, single addresses and CIDR subnets.
The two features are independent and compose — a site may use either, both, or
neither; the only difference is scope (whole-site vs the /user + /admin
surface).
It is a single global generator deployed at /var/xdrago/user_admin_access.sh,
run over every real Octopus instance (the hostmaster front-end has its own
vhost and is out of scope); there is no master/sqladmin context. For each listed
site it writes two Nginx includes into the instance's config/includes/:
user_admin_access_map/<site>.conf(http scope) — ageoclassifying the client$remote_addras allowed, amapflagging a/user|/adminrequest on the clean$uri, and a finalmap$ua_deny_<hash>that is1only when an admin-area request arrives from a non-allowed address;user_admin_access/<site>.conf(server scope) — a singleif ($ua_deny_<hash>) { return 403; }.
BOA enforces Drupal clean URLs, so /user and /admin always arrive as the real
request path in $uri (which Nginx percent-decodes and normalises); the match is
keyed there. The legacy ?q=admin query form is not a clean path — not served by
default on BOA — and is deliberately not matched, because Nginx's $arg_q cannot
be reliably gated (it is neither percent-decoded nor de-duplicated the way Drupal
reads $_GET['q']); this matches BOA's existing location ^~ /admin guard, which
likewise keys on the clean path.
The per-site vhost pulls the first once at the file head and the second inside
each serving server block (next to ip_access), both via a wildcard include
anchored on the fragment suffix (<uri>.conf*, not a bare <uri>* — otherwise a
longer site whose name extends this one, e.g. example.com vs example.com.au,
would have its fragment pulled in here too). A site with no fragment is a no-op —
the feature is strict opt-in. <hash> is a short digest of the site name, so each
site's variables are unique in the shared http{}.
Control file format
One site per line — the site name followed by space-separated allowed addresses, each an IPv4 or IPv6 address with an optional CIDR prefix:
# /data/disk/o1/static/control/ip/user_admin.txt
intranet.example.com 203.0.113.10 203.0.113.0/24 2001:db8::/32
staging.example.com 198.51.100.42 2001:db8:1::1
#comments and blank lines are ignored.- Each address is validated against a strict subset of what Nginx
geoaccepts; a malformed address or an invalid site name is skipped with a logged warning while the rest of the line still applies — one bad token never breaks the box-wide Nginx configtest.
Anti-lockout
Every generated geo always allows, in addition to the listed addresses, so a
typo can never lock the operator out of the admin surface:
127.0.0.1and::1— loopback;- the server's own IPv4, read from
/root/.found_correct_ipv4.cnf(BOA tracks no server IPv6); - every established inbound SSH client IP (IPv4 or IPv6), harvested from
netstat -tn— the same sourceip_accessuses.
Generator behaviour
- Change-gate — a context regenerates only when its control file's mtime advanced, the host's SSH-client set changed, or the emitted-directive version bumped. No change → no write, no reload.
- Prune-on-removal — deleting a site's line removes both its fragments on the next run, lifting the restriction (the admin surface becomes open again).
- Safety — per context: back up the current fragments, regenerate, run
service nginx configtest, thenreload; on failure, restore the last-good backup and reload. It holds the shared/run/boa_nginx_config.lockso it never overlaps the other Nginx-config writers (ip_access,ai_policy,nginx_deny,cloudflare_realip). - Schedule —
*/2cron, so changes take effect within about two minutes.
Defence in depth, not the sole control
This gate narrows who can reach the /user and /admin URL paths at the edge;
Drupal's own login and permission checks still apply on top of it. The match is on
the decoded, normalised $uri, so it is encoding- and multi-slash-safe. The legacy
?q=admin query form is not gated here (it is not a clean path and not served by
default on BOA, and $arg_q cannot be reliably matched at the Nginx layer) —
Drupal's authentication remains the control for that vector, exactly as with BOA's
existing /admin guard.
Verify
# inspect a site's generated gate
cat /data/disk/o1/config/includes/user_admin_access_map/intranet.example.com.conf
cat /data/disk/o1/config/includes/user_admin_access/intranet.example.com.conf
# from a non-allowed IP: /admin + /user -> 403, everything else -> 200
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/admin
curl -sS -o /dev/null -w '%{http_code}\n' https://intranet.example.com/
service nginx configtest
The script is aegir/tools/system/user_admin_access.sh, deployed as
/var/xdrago/user_admin_access.sh; the per-site includes are emitted by the vhost
generator (provision-private/http/Provision/Config/Nginx/vhost.tpl.php and the
SSL template).
Related
- Security model — protection #11 (restricted admin access, uid=1).
- Nginx internals — the vhost generator that emits
the
/adminblock and pulls theip_accessper-site fragments. - Control files & INI —
static/control/ip/access.txtas a per-site control file and the INI opt-out mechanism. - Mailing policy — a sibling default restriction (mail) with a similar opt-out pattern.