BOA security model — multi-Ægir architecture
BOA's foundational threat model: multiple Octopus tenants share one host but
cannot affect each other or escape their boundaries. This page lays out the
model and the built-in protections that implement it, then the host-level
disable_functions and binary-permission knobs, the codebase-wide security
audit hardening (5.9.5), and the edge/anti-spoofing controls (5.10.1).
The trust model
A BOA host has three trust levels:
1. root (operator) ← full host control
↓ provisions
2. aegir (system user) ← runs Master Ægir; near-root over all
site DBs across all instances
↓ provisions
3. oN + oN.ftp (tenant) ← lshell-restricted; can touch ONLY
their own Octopus instance's data
The architectural invariants:
- Tenants cannot reach the aegir system user. An lshell escape equals catastrophic compromise of every site on the host.
- Tenants cannot see other tenants' files / DBs. Per-Octopus separation is enforced via Unix permissions plus the lshell chroot.
- The web server has no write access to Drupal codebases. Even a compromised PHP application cannot modify its own code.
- Only Aegir-recognised PHP files are served. The Nginx vhost whitelists Drupal entry points.
Never grant anyone access to the aegir system user. Only ever hand out
restricted lshell SSH accounts (oN.ftp per tenant plus extra client
subaccounts) and non-admin Ægir control-panel accounts. The per-tenant
restriction layer is documented in lshell + manage_ltd_users.
The built-in protections
1. Encrypted connections only
Account access is restricted to SSH (ed25519 key auth preferred; password auth with 90-day expiry as fallback), SFTP (FTP over SSH), and FTPS (FTP over SSL). No plain FTP, no telnet, no HTTP-only admin pages.
2. Restricted PHP scripts
The Nginx vhost only allows Drupal's known PHP entry points (index.php,
update.php when enabled, cron.php, the hostmaster install scripts). Random
PHP files dropped into the webroot do not execute. Combined with #3, this blocks
the most common Drupal exploit chain ("vulnerable site takes a file upload →
uploaded PHP runs").
3. Web-server-readonly Drupal codebases
The Drupal platform directories under ~/static/, ~/distro/, ~/platforms/
(the tenant tree at /data/disk/oN/) are owned by the per-Octopus instance
user oN — each tenant owns its own codebase; the master aegir user does not
own tenant codebases. The web server (www-data) has read-only access, so even a
compromised PHP application cannot modify its own code. The files/ and
private/ directories are the exception — they are writable for legitimate
Drupal use, using the sticky-bit pattern.
4. Web-server monitoring (Nginx Abuse Guard)
IPs exhibiting DoS-like access patterns get temporarily blocked for 1 hour,
escalating to a permanent block on repeat offence. Operators and tenants can
whitelist their own IP by keeping an active SSH connection (the SSH-client set is
harvested into every per-site allow-list — see ip_access).
Tunables in /root/.barracuda.cnf:
_NGINX_DOS_LIMIT— per-IP page-view threshold within the scan window (default399)._NGINX_DOS_LINES— number of access-log lines tailed per scan pass (the scan window; default1999). This is a separate variable from_NGINX_DOS_LIMIT— do not conflate the two._NGINX_DOS_IGNORE— substring keyword filter (defaultdoccomment)._NGINX_DOS_IGNORE_PATHS— space-separated webhook/API endpoint URI prefixes the IDS never scores (parsed with a laundering-proof URI normaliser, not a substring match). Default:/shopify/webhook /quickbooks/webhook /stripe/webhook /paypal/webhook /github/webhook /gitlab/webhook /graphql /public-api /oauth2.
For aggressive crawl protection: touch /root/.hr.monitor.cnf. The deep scoring
and ban mechanics — and these knobs' full defaults and rationale — live in the
Abuse Guard topic.
5. Firewall monitoring (CSF + LFD)
Repeated failed SSH/SFTP/FTPS login attempts escalate temporary block → permanent block. Whitelisted IPs are not exempt from LFD login-failure tracking if abuse is detected. CSF lifecycle: CSF + LFD firewall.
6. Load management
When the host's load exceeds its configured ratio, the web server is temporarily
disabled; normal service resumes within ~10 s of load stabilising. This protects
against undetected DoS where the Abuse Guard did not catch the pattern but real
load damage is happening. Driven by loadguard.
7. Port-scan + flood protection
Detected port scans and SYN floods escalate to a temporary 1-hour block then permanent. SYNPROXY-based; see SYNPROXY DDoS protection.
8. Perfect Forward Secrecy + HTTP/2 + HTTP/3
All HTTPS uses PFS cipher suites with HTTP/2 (or HTTP/3 where supported). Non-PFS-capable browsers fall back to classic TLS, so old browsers stay supported while modern browsers get the security upgrade.
9. PHP errors hidden from browsers
display_errors = Off in PHP-FPM. Errors are only visible via the server-side
per-version FPM error log (/var/log/php/php<NN>-fpm-error.log — one host-wide
directory, not per-Octopus), Drupal watchdog (drush watchdog-show), or a
configured .dev. URL alias (a debug mode that enables in-browser errors; not
auto-present — the operator must add the dev.<domain> alias to the site).
10. Password expiration policy
SSH / SFTP / FTPS passwords expire every 90 days. Even with SSH keys in use, the underlying password expires and must be rotated. This is also why BOA sets account passwords with SHA512 (or Bcrypt if migrated — see password hashing).
11. Restricted admin access (uid=1)
The Drupal admin account (uid=1) is unavailable in Aegir to prevent misuse. The Aegir user has privileges equivalent to admin at the Aegir level, but the Drupal-level uid=1 super-user role is intentionally not handed out.
12. Restricted system binaries
BOA modifies access permissions on system binaries that could be abused by web
shells (exec, passthru, etc.) so that even sites running older or vulnerable
Drupal versions cannot reach the most dangerous binaries directly. Combined with
_STRICT_BIN_PERMISSIONS=YES (below), the restriction is operator-configurable.
13. Process-list isolation (hidepid)
/proc is mounted hidepid=2 with only the adm group exempt (gid=adm; the
aegir user is added to adm so Master Ægir's pgrep of system services still
works). One Octopus tenant can no longer inspect another tenant's — or root's —
process list, environment, or /proc/<pid>/cmdline.
This closes a cross-tenant credential-disclosure class: secrets that briefly
appeared on a process command line (mysql -p<pwd>, mydumper --password=,
duplicity s3://KEY:SECRET@…) were readable from /proc by any local user. It
pairs with the credential-off-the-command-line work in the 5.9.5 audit (below).
Applied automatically at install/upgrade, persisted in /etc/fstab, and
live-remounted (idempotent). Per-Octopus system users (o1, o2, …) and .ftp
accounts are deliberately not in adm, so they keep own-process visibility
only.
AI bot policy (default-deny scrapers)
A per-class AI bot policy is enforced at the Nginx edge as a default-deny posture against scrapers and abusive agents:
- AI training scrapers (GPTBot, ClaudeBot, CCBot, Bytespider, Amazonbot,
Meta-ExternalAgent, AI2Bot, Diffbot, cohere-ai, …) are blocked by default
(444) — inverting the old allow-by-default
$is_ai_crawlerhandling. Operators whose sites relied on training crawlers being allowed must now opt in per site. - Search / user / utility classes are allowed but per-vendor aggregate rate-limited (1–2 r/s).
- A new evasive class (
Perplexity-User) is blocked by default with per-site evasive-allow. - Forged opt-out tokens (
Google-Extended,Applebot-Extended— robots.txt directives that never appear as real crawlers) are hard-blocked (444) universally. - Secret/config path probes (
.env,.git,.aws,.ssh, credential*.json, …) are anchored-denied on the normalised URI even for plain-browser UAs.
Per-site control file: /data/disk/<oct>/static/control/ai/policy.txt. The full
class table is in the Abuse Guard topic.
PHP function restrictions — _PHP_FPM_DENY
Custom disable_functions are set per scope. _PHP_FPM_DENY is read and written
only from /root/.barracuda.cnf (host: Master Ægir plus non-Octopus
scripts) and from each per-Octopus /root/.<USER>.octopus.cnf:
| Scope | File | Variable |
|---|---|---|
| Host (Master Ægir + non-Octopus scripts) | /root/.barracuda.cnf |
_PHP_FPM_DENY |
| Per Octopus | /root/.<USER>.octopus.cnf |
_PHP_FPM_DENY |
There is no per-platform or per-site INI key for this — neither
default.boa_platform_control.ini nor default.boa_site_control.ini carries a
_PHP_FPM_DENY (or disable_functions) row, so adding such an INI line is a
no-op. Pool-level disable_functions is applied per user pool by
manage_ltd_users.sh.
If unset or empty, BOA's baseline disable_functions denies passthru (plus a
fixed list of other functions in the pool template). Setting the variable
replaces the leading passthru token rather than adding to it: the deployed
pool template's passthru, is rewritten to ${_PHP_FPM_DENY}, (the rest of the
baseline list is preserved). So passthru is no longer denied unless you list
it explicitly in your value — list it if you want to keep it denied alongside
your additions.
WARNING: Do NOT add shell_exec to _PHP_FPM_DENY — it breaks cron for all
sites, and Collectd Graph Panel (if installed) also requires it.
Example tighter set:
_PHP_FPM_DENY="passthru,popen,system,exec,proc_open"
Be aware that some legitimate Drupal modules use these functions. Test in dev first.
Strict binary permissions
# /root/.barracuda.cnf
_STRICT_BIN_PERMISSIONS=YES
Shipped default YES; recommended when you cannot fully restrict PHP functions
via _PHP_FPM_DENY. Despite the name it drives two hardenings, and both run
only during barracuda upgrade passes — any other run mode forces the setting
off for that pass, so scripts you add or edit between upgrades stay untouched
until the next upgrade run.
Group-read stripping
Strips group-read permissions from many system binaries that have group=root, so web shells cannot reach the most dangerous binaries directly (see #12 above).
WARNING: Aggressive — it can break unmanaged binaries you have installed yourself. BOA only touches binaries with group=root and no setuid / setgid bits. Test in dev.
System-shell switch to dash
The same gate repoints the system shell: /bin/sh and /usr/bin/sh are
symlinked to dash (falling back to bash where dash is absent — the dash and
bash variants of the sweep are kept in lockstep), then /bin/sh references are
rewritten to /bin/dash:
- Explicitly: the shell fields in
/etc/passwd, plus/etc/crontab,/etc/init.d/ssh, and the CSF/etc/csf/uninstall.shand/etc/csf/remove_apf_bfd.shscripts. - By scan: every non-setuid / non-setgid file in a fixed directory list
that carries a
#!/bin/sh-style (or#!/usr/bin/sh) shebang or aSHELL=/bin/shline gets all its/bin/shreferences rewritten — the shebang orSHELL=line is the trigger, the rewrite is file-wide. The list covers all the/etc/cron.*directories (cron.d,cron.daily,cron.hourly,cron.weekly,cron.monthly,cron.yearly),/etc/init.d,/lib/init,/bin,/sbin,/usr/bin,/usr/sbin,/usr/local/binand many other system script locations.cron.hourly,cron.yearlyand/lib/initare recent additions to the scan list; the/etc/rc2.d–rc5.dsymlink directories are no longer scanned.
Exemptions: setuid / setgid files are excluded from the scan, any file whose
name ends in drush, clean-boa-env, dash, bash, ssh, sshd or websh
is skipped wholesale, and /etc/init.d/clean-boa-env plus every websh
install location carry explicit path guards in each rewrite test.
Operator takeaway: custom cron jobs dropped into /etc/cron.hourly,
/etc/cron.yearly or any other scanned directory will have their /bin/sh
shebangs and SHELL= lines rewritten to dash on the next barracuda upgrade
while this hardening is on. Scripts relying on bashisms under a /bin/sh
shebang will break — use an explicit #!/bin/bash shebang to stay untouched.
Node / NPM security gate
Node/NPM is not enabled by default because Node can be used to bypass lshell.
node, npm, npx (and scp, which rides the same gate) are stripped from the
lshell allowed command list unless one of two conditions is met:
- The Octopus instance type is PHANTOM, CLUSTER, ULTRA or MONSTER — for these
instance types Node/NPM/npx and scp are auto-enabled with no control file
required (BOA greps the instance type out of
/root/.<USER>.octopus.cnf). -
The control file exists:
touch /root/.allow.node.lshell.cnf
Only create the control file on hosts with trusted tenants. Shared / public hosting hosts should keep this disabled.
Filesystem permission baseline
The 5.9.5 audit tightened default file/directory permissions across the board. Notably:
- The
/opt/tmpscratch root is sticky1777(it used to be a recursive777, which let one tenant tamper with or delete another's scratch files). The sticky bit prevents cross-uid deletion, matching the model already used for/var/tmp/fpmand/opt/user/{gems,npm}. /opt/tmp/boais forced to0755directories /0644files.- The
daily.shchown -Lsymlink-following path was closed.
Security audit hardening (5.9.5)
BOA 5.9.5 carried a codebase-wide security audit across all repositories. The operator-relevant outcomes (most have no knob — they are patch-and-forget):
- Credentials off the command line. All MySQL/Percona invocations now use
--defaults-extra-file/--defaults-file=/root/.my.cnfinstead of passing the password as an argument, where it was briefly visible in the process list to any local user. Legacy_SQL_PSWDreads were removed throughout, andxmassno longer exposes the SQL root password. This pairs with the hidepid mount (#13 above). - Shell-injection hardening. Component downloads default to HTTPS with
certificate validation (closing an HTTP-mirror MITM vector);
mysql_cleanupuses a strict SQL-identifier allowlist; new internal helpers_is_safe_ident,_validate_safe_dir,_validate_path_prefixand_chmod_safevalidate values before they reach the shell. Theaegir-shell-injection-auditfixes were applied to Provision and thehosting_civicrm,hosting_git,hosting_tasks_extramodules, closing HTTP-basic-auth-password and special-character injection indrush_shell_exec()paths. - HTTP Basic Auth input validation (UI layer). The
hosting_tasks_extrabundled with 5.10.3 adds the user-visible layer of the same audit: the site node's HTTP Basic Auth username, password and message fields reject shell-unsafe input at form-validation time — control bytes (NUL / LF / CR) plus the shell metacharacters< > | & * ? ; ! ( ) ' " \ ` $ { }— because these values flow into Drush subprocess commands and Nginx config templates. A value containing any of these draws an immediate form error instead of being persisted; if a client reports that error, this character set is why. The IP-whitelist field keeps its stricter allowlist (letters, digits, space,/,.,:). On the backend, the health-check task escapes its site-name-derived arguments withescapeshellarg()beforedrush_shell_exec()interpolation. mybackupqueue-file injection closed. The.ftp → sysuserqueue-file path is now validated;lock.incwas rewritten to brace-expansion auto-fd allocation instead ofeval.tar-symlink privilege escalation closed. A path where NOPASSWD-sudotarhelpers could be coaxed into following a symlink to write into root-owned locations was closed.- Config-template hardening (PHP / Nginx / MySQL / SSH / sysctl). PHP
session.use_strict_mode = 1andexpose_php = Off(cli/FPM templates); MySQLlocal_infile = OFF(my.cnf.txt); extendedsysctl.confhardening; the insecurewgetshell alias removed; Nginx-template hardening. SSH specifics are in SSH + SFTP hardening.
Edge / anti-spoofing hardening (5.10.1)
- Cloudflare-aware real client IP. BOA resolves the real visitor behind
Cloudflare (
real_ip_header CF-Connecting-IPplus a BOA-managed, auto-refreshed Cloudflare edge-range list viacloudflare_realip.sh), so rate-limit keys,REMOTE_ADDR, bans and logs reflect the real visitor, not the CDN edge. PHPREMOTE_ADDRis pinned to the original TCP peer ($realip_remote_addr) so Drupal's reverse-proxy handling cannot be fed a spoofed client address. The IDS scores the realip client — see the Abuse Guard topic. - Hardened external fetches, fail-closed. Insecure
curl -kwas dropped (TLS is now verified) and ad-hoc fetches standardised on_crlGet --fail, so an HTTP error yields an empty body rather than an error page parsed as hostile CIDRs. Strict IPv4/IPv6 octet+prefix validation was added to theip_access,nginx-deny,cloudflare-realipandcsf.allowgenerators: a malformed-but-shape-valid token is skipped fail-closed and can never breakconfigtestfleet-wide (validators reject0.0.0.0/0and any/0).
Verifying the edge stack — edgetest
edgetest is a guided one-command pass/fail checker for the whole security edge:
AI bot policy, Cloudflare realip, CSF→nginx web bans, and per-site AI/IP
controls. It is read-only by default; --full runs state-changing proofs
reverted by an idempotent trap, and --remote does cross-box ip_access checks.
Among its assertions it confirms the evasive Perplexity-User block and the
Google-Agent allow.
Related
- lshell + manage_ltd_users — the per-tenant restriction layer.
- CSF + LFD firewall — the network-edge layer.
- SYNPROXY DDoS protection — the SYN-flood layer.
- AppArmor confinement profiles — the per-binary confinement layer.
- SSH + SFTP hardening — the account-access layer.
/admin*URL protection +ip_access— admin path and whole-site IP controls.- Discontinued features — historical incidents BOA's security model defends against.