Security model internals
BOA is multi-tenant on one host: many Octopus instances share a kernel, an Nginx, one Percona/MySQL server, and one set of PHP-FPM binaries, yet no tenant may read, write, or execute against another tenant's data or reach a more-privileged identity. This page is the maintainer's view of the code contracts that hold that line: the identity/trust boundary, the tenant-code escalation class the Drush extension filter closes, and the shell-out hardening families a change must not regress. It documents the invariants in the code, not operator knobs.
Trust boundary: who runs what, as which uid
The whole model turns on the effective uid a piece of code runs as. Get this wrong in a patch and you either break a tenant or hand one root.
Three privilege levels, top to bottom:
root— the operator. Provisions everything. BOA itself (Barracuda/Octopus) runs as root. Never handed to a tenant.aegir(HOME/var/aegir) — the Master Ægir control plane. It runs the Hostmaster Drupal frontend and dispatches backend tasks. On a BOA box the master hosts no tenant sites of its own — this is a hard BOA-fork property the filter relies on (boa_extension_filter.inc:60-61). Near-root over the queue, but its own home is not tenant-writable.oN(Octopus instance user, HOME/data/disk/oN) — the per-tenant hosting backend. The tenant's Aegir satellite. Provision/Drush backend tasks for that tenant's sites execute asoN, against/data/disk/oN/. Site code a tenant deploys lives underoN-owned, tenant-writable subtrees.oN.ftp(HOME/home/oN.ftp, jailed lshell/MySecureShell) — the derived limited client. This is the account clients are given for SSH/SFTP/CLI. It runs as its own jailed uid with no escalation path: it can only touch its own instance's data.
The load-bearing distinction — and the one most easily broken by a well-meaning
refactor — is that oN is a backend identity, oN.ftp is not. A backend
identity is one that BOA's dispatch machinery runs tenant-supplied code as. A
client running its own drush @site as oN.ftp escalates to nothing (it already
owns that uid), so its own contributed code is not a threat to anyone else.
su without - leaves HOME stale — key on euid, never $HOME
BOA's backend frequently enters an instance identity with su <user> without
-. That leaves the environment (including HOME) at the caller's value —
typically /root — while the effective uid is correctly oN. Any security
discriminator that reads getenv('HOME') (or $HOME in bash) to decide "who am
I" therefore derives the wrong identity on a real backend and can wrongly skip a
control. Derive identity from the kernel: posix_geteuid() →
posix_getpwuid() → the passwd dir/name (root-owned, not client-spoofable),
mirroring drush_get_username() in includes/environment.inc and
provision_current_user(). See boa_drush_extension_octopus_user()
(boa_extension_filter.inc:102-120) and boa_drush_extension_backend_identity()
(:148-174) for the canonical implementation. A new security gate MUST follow the
same idiom.
The tenant-code escalation class: *.drush.inc auto-loading
Drupal issue #762138 is the concrete escalation the Drush extension filter closes.
Classic Drush 8 discovers contributed command files by scanning for *.drush.inc
across the module search path and loading each one at bootstrap — the file's
top-level PHP executes merely by being found. When the Master or an instance
backend runs a task (Verify, Clone, Migrate, backend cron, …) it bootstraps the
tenant's Drupal with a privileged identity (aegir/oN). A tenant who drops
sites/all/modules/evil/evil.drush.inc into their own (writable) codebase thus
gets arbitrary PHP executed as the backend identity — cross-site authority
they never legitimately hold. Loading is the vulnerability; there is no "run the
command" step required.
The filter: default-deny on tenant-writable paths, backend-identity-gated
The fork's boa_extension_filter.inc is the single source of truth. The public
entry point boa_drush_extension_allowed($path) (:324-336) decides, in order:
- Global kill switch present (
/data/conf/drush_extension_filter_disabled.txt,boa_drush_extension_filter_disabled():185-192) → allow. Re-opens #762138; single-tenant boxes only. - Not a backend identity (
boa_drush_extension_backend_identity()false) → allow. This is theoN.ftp(and any non-privileged) case: its own contrib*.drush.incloads unfiltered because it escalates to nothing. - Path not on tenant-writable territory → allow.
- Otherwise deny, unless the basename is opted-in per instance.
Deny patterns anchor on the BOA canonical tenant-writable roots
(boa_drush_extension_deny_patterns() :65-70):
#^/data/disk/[^/]+/(static|distro|platforms)(/|$)#i
#^/data/disk/[^/]+/\.drush(/|$)#i
with carve-outs for the three BOA-managed Drush subtrees
(boa_drush_extension_deny_exceptions() :79-83):
#^/data/disk/[^/]+/\.drush/(sys|usr|xts)(/|$)#i
.drush/{sys,usr,xts}/ hold Provision and Aegir contrib and must always load; the
positional [^/]+/(static|distro|platforms) anchor keeps the tenant codebase
/data/disk/oN/distro/ denied while /data/disk/oN/aegir/distro/ (Drush's own
install, not tenant-writable) is never matched. /var/aegir/* is not in the deny
set because the master hosts no tenant sites.
Two contracts that make the filter actually deny — do not regress either
The filter's history is a warning: for a period it was a silent no-op. Two mechanisms are load-bearing.
Canonicalise before matching. Drush feeds the discovery scanners paths that
are relative to the Drupal root (drupal_get_path() /
extension.list.module->getPath()) during site/full bootstrap. A relative path
like sites/all/modules/evil/evil.drush.inc matches none of the ^/…-anchored
deny patterns and would load. boa_drush_extension_canonical_path() (:257-275)
resolves to an absolute, symlink-free path first: realpath() collapses ..,
follows symlinks, and absolutises against the cwd (the Drupal root at bootstrap) —
closing the relative-path, .., and symlink evasions in one step. When
realpath() fails on a relative path it is absolutised lexically against the
cwd — fail toward DENY, never silent allow. phar:///stream-wrapper paths are
returned untouched (Drush ships phar-packaged commandfiles legitimately) and fall
through to default-allow. command.inc canonicalises with Path::canonicalize()
before calling the entry point (command.inc:1640-1641).
Filter the array KEY, which is the path. Two of the three discovery sites use
array_filter(..., ARRAY_FILTER_USE_KEY) — the map's key is the path, the value
is a class name that never matches a path pattern. Filtering the value silently
no-ops. The three wire-in sites are the contract; keep all three:
command.inc:1641and:1666—_drush_add_commandfiles(): filters at scan time and re-checks at load time so a stale cached commandfile list (built while an opt-in control file was present, read after it was removed) cannot bypass the deny set.preflight.inc:553—_drush_find_commandfiles_drush(),ARRAY_FILTER_USE_KEY.annotationcommand_adapter.inc:154—annotationcommand_adapter_discover(),ARRAY_FILTER_USE_KEY.
Include is loaded once at preflight.inc:129
(require_once DRUSH_BASE_PATH . '/includes/boa_extension_filter.inc').
Fail-closed defaults and the opt-ins
boa_drush_extension_backend_identity() (:148-174) returns TRUE (filter ON)
when the posix extension is unavailable or the euid is 0 — only client
convenience is ever lost, never the #762138 defence. It returns TRUE for
aegir (dir === '/var/aegir' && name === 'aegir') or any resolved Octopus
instance user. The oN resolver (:102-120) additionally requires the passwd
name to equal the last path segment of /data/disk/<seg> and contain no dot
— which keeps the dotted oN.ftp/oN.<client> users off the instance-user path
even if a home were ever mis-set under /data/disk.
Backend opt-ins are presence-only, root-created control files under /data/conf/,
consulted only after an identity is confirmed backend (:349-359):
/data/conf/<oN>_civicrm.txt→ allowcivicrm.drush.inc,cv.drush.inc,civicrm_drush.drush.inc(allowlist:48-54, gate:203-211)./data/conf/<oN>_elysia_cron.txt→ allowelysia_cron.drush.incso backend-mode cron (hosting_cron_use_backend→@site elysia-cron) keeps Elysia's granularity (gate:223-231). Legacy path — BOA does not use Drush cron on D8+.
These two opt-in files plus the global kill switch
drush_extension_filter_disabled.txt are candidates for the control-file
reference, though none is a _VAR-style variable — see
Variables reference.
The instance user in both opt-in checks is derived from the euid, not $HOME
(fixing an earlier opt-in miss under su-without--, where a present control file
was ignored because HOME pointed at /root).
Filter is caller-aware; the D11 removal is filesystem-level and separate
The current design applies the deny set only to backend identities; oN.ftp
client CLI regained unfiltered contributed commands. An earlier iteration applied
the filter to every Drush invocation regardless of caller (over-blocking client
CLI); a maintainer reading old release notes must not reintroduce that. The
identity gate is the fix.
A separate, complementary mechanism removes *.drush.inc from Drupal 11
platforms at the filesystem level during the bundled permission fix
(fix-drupal-platform-permissions.sh, gated on core/modules/workspaces_ui) —
D11 contrib *.drush.inc are dead weight for site-local Drush 12+ and their
removal is a filesystem companion to the Drush-layer deny-filter. It is not the
identity filter and does not depend on it. Expect contrib *.drush.inc to vanish
from D11 codebases after a permission fix. Retired mechanisms live in
Discontinued features.
Safe-ident / path helper contracts for shell-outs
BOA's privileged shell helpers (the sudo-invoked fix-drupal-* scripts, the SQL
maintenance tools, the nightly workers) all validate before a value reaches the
shell. These are code contracts: a change that adds a new privileged path must
route caller-supplied values through the matching helper, not concatenate them raw.
_validate_path_prefix / _validate_safe_dir — allowed-root gate. A
caller-supplied root is realpath -e-resolved (fails closed if it does not
resolve) and must fall under /var/aegir, /data/disk, or /home; anything else
aborts. Two byte-identical incarnations:
fix-drupal-platform-permissions.sh:31-45(and the siblingfix-drupal-*ownership/site scripts) —_validate_path_prefix "${drupal_root}"runs before any chmod. Rationale is in the header (:22-30): the scripts are invoked via NOPASSWDsudo, so a symlink planted at a known child path (e.g.${drupal_root}/web -> /etc) could otherwise coerce a chmod of system files.night/night.inc.sh:41-51—_validate_safe_dir, samerealpath -e+ allowed- root case, returns non-zero instead of exiting (worker context).
_chmod_safe — symlink-skipping chmod. Wraps every direct chmod with a
symlink precheck (fix-drupal-platform-permissions.sh:47-56): [ -L "$p" ] && continue skips symlinks (so root-managed legacy symlinks stay untouched and
attacker-planted symlinks cannot redirect a chmod), [ -e "$p" ] || continue
skips absent targets. find -type d/-type f predicates already exclude symlinks,
so the find-exec chmod blocks need no wrapper — only the direct chmod calls do.
Any new direct chmod in these scripts must go through _chmod_safe.
_is_safe_ident — SQL identifier allowlist. [[ "${1}" =~ ^[A-Za-z0-9_]+$ ]]
(mysql_cleanup.sh:113-115, mirrored in mysql_backup.sh:149-151,
mysql_cluster_backup.sh:150-152). Every table/database name discovered from
show tables output is checked before it is interpolated into a TRUNCATE/DROP;
an identifier that fails the allowlist is skipped with a WARN, not passed
through — one hostile row can never break the whole run. When you add a code path
that builds SQL from a discovered identifier, gate it with _is_safe_ident.
drush_shell_exec discipline: %s placeholders, never concatenation
Provision's shell-outs go through drush_shell_exec() /
_drush_shell_exec() (exec.inc:80-168). The contract:
-
Every dynamic value is a
%splaceholder passed as a separate argument;_drush_shell_exec()runs each throughdrush_escapeshellarg()(exec.inc:129) and thensprintfs them into the command (:140). Do not build the command string by interpolating a variable into the format — that bypasses the escape.// Correct — value is a placeholder, escaped for you: drush_shell_exec("sudo --non-interactive /usr/local/bin/fix-drupal-platform-ownership.sh --root=%s --script-user=%s --web-group=%s", d()->root, d()->server->script_user, d()->server->web_group); // provision.inc:596 -
The single-argument form is already-escaped and NOT
sprintf'd. Whencount($args) == 1,_drush_shell_exec()treats$args[0]as a finished command string and skipssprintf(exec.inc:136-138) — precisely so a literal%in a pre-escaped string is not mis-consumed as a format token. If you pass a fully-built command as one string, you own its escaping; there is no safety net. Prefer the placeholder form. -
Remote/aliased execution wraps with
escapeshellcmd()around the whole remote command and%sfor the ssh host (Provision/Context/server.php:170,173).
The 5.9.5 shell-injection audit hardened the drush_shell_exec() callers across
Provision and the hosting_civicrm, hosting_git, hosting_tasks_extra modules
(HTTP-basic-auth passwords and special-character injection in URL/credential
paths). Keep new callers on the placeholder form; a raw-concatenated
drush_shell_exec is a regression even if it "works".
Audit-hardening families a change must not regress
The 5.9.5/5.10.1 codebase-wide audit established several classes of contract. Most have no operator knob — they are patch-and-forget, and the regression risk is re-introducing the bad pattern in new code.
Credentials off the command line. MySQL/Percona invocations use
--defaults-extra-file / --defaults-file=/root/.my.cnf, never
-p<pwd>/--password= on argv — the password was briefly visible in /proc to
any local user (boa-private/CHANGELOG.txt:599-603). Legacy _SQL_PSWD argv reads
were removed throughout; xmass no longer exposes the SQL root password. This
pairs with the hidepid=2 /proc mount (gid=adm; aegir is in adm so
Master's pgrep still works; oN/.ftp are deliberately not). Never add a DB
invocation that passes a secret on argv.
tar-symlink privilege escalation. A path where NOPASSWD-sudo tar helpers
could be coaxed into following a symlink to write into root-owned locations was
closed (CHANGELOG.txt:605-608). The general contract for any sudo-reachable
helper that writes: resolve and prefix-validate the target
(_validate_path_prefix), skip symlinks on direct writes (_chmod_safe pattern),
and never let a tenant-plantable symlink redirect a privileged write.
Injection helpers as the required entry point. The audit's new validators —
_is_safe_ident, _validate_safe_dir, _validate_path_prefix, _chmod_safe —
are not optional hygiene; they are the required gate for their value class. A
change that introduces a new caller-supplied identifier, directory, path prefix, or
privileged chmod must reuse the matching helper. Bash-side, watch the
function-local IFS/set/shopt traps that bite path-splitting helpers in the
monitor and nightly scripts — set them function-locally, as those scripts already
do.
Fetch fail-closed; validate CIDRs. External fetches run through the _crlGet
curl option string, which carries --fail (BOA.sh.txt) so an HTTP error yields
an empty body rather than an error page that could be parsed as hostile CIDRs. The
config generators (ip_access.sh, user_admin_access.sh, nginx_deny.sh,
nginx_deny6.sh, cloudflare_realip.sh, migration_proxy_realip.sh) strictly
validate IPv4/IPv6 octet+prefix against a
value-valid regex (_ipv4_octet="(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])"),
and the realip generator additionally rejects the all-zeros host and any /0
prefix (migration_proxy_realip.sh:45-50) — a malformed-but-shape-valid token is
skipped fail-closed (skipped invalid entry) and can never break configtest
fleet-wide. A generator that emits config from fetched or userland input must
validate each token and skip bad ones, never fail the whole file.
Where this connects
- The IDS scores the realip client, never the spoofable
X-Forwarded-For— the same "trust the kernel-derived truth, not client-supplied claims" principle as the euid identity gate. Detector mechanics: Abuse Guard code internals. - BOA bash conventions the helpers follow (
_snake_case,[[ ]], quoted expansions, function-local scope): Code style & conventions. - Consolidated
_VARand control-file table (including the/data/conf/opt-in files): Variables reference and Commands reference. - Retired security mechanisms: Discontinued features.