/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:

  1. An unconditional Nginx guard — the vhost's location ^~ /admin block 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.
  2. 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 the disable_admin_dos_protection opt-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_uid is 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_bot444 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=admin queries); 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 record sqladmin.com 192.168.1.1 when it is absent, producing a sqladmin.conf fragment. 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 by ssl_proxy.conf via include .../ip_access/sqladmin*;. The seed IP 192.168.1.1 is 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-canonical tools/drush marker, so pseudo-dirs like arch, all, legacy, global, static, custom are 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.1 and ::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 an ESTABLISHED :22 connection — the peer address is taken by stripping the trailing :port, and each is validated before it reaches an allow line). BOA uses netstat here, not who --ips, because who --ips is 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, then reload; 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*/2 cron, 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) — a geo classifying the client $remote_addr as allowed, a map flagging a /user|/admin request on the clean $uri, and a final map $ua_deny_<hash> that is 1 only when an admin-area request arrives from a non-allowed address;
  • user_admin_access/<site>.conf (server scope) — a single if ($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 geo 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 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.1 and ::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 source ip_access uses.

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, then reload; on failure, restore the last-good backup and reload. It holds the shared /run/boa_nginx_config.lock so it never overlaps the other Nginx-config writers (ip_access, ai_policy, nginx_deny, cloudflare_realip).
  • Schedule*/2 cron, 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 /admin block and pulls the ip_access per-site fragments.
  • Control files & INIstatic/control/ip/access.txt as a per-site control file and the INI opt-out mechanism.
  • Mailing policy — a sibling default restriction (mail) with a similar opt-out pattern.