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 as oN, against /data/disk/oN/. Site code a tenant deploys lives under oN-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:

  1. 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.
  2. Not a backend identity (boa_drush_extension_backend_identity() false) → allow. This is the oN.ftp (and any non-privileged) case: its own contrib *.drush.inc loads unfiltered because it escalates to nothing.
  3. Path not on tenant-writable territory → allow.
  4. 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:1641 and :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:154annotationcommand_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 → allow civicrm.drush.inc, cv.drush.inc, civicrm_drush.drush.inc (allowlist :48-54, gate :203-211).
  • /data/conf/<oN>_elysia_cron.txt → allow elysia_cron.drush.inc so 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 sibling fix-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 NOPASSWD sudo, 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, same realpath -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 %s placeholder passed as a separate argument; _drush_shell_exec() runs each through drush_escapeshellarg() (exec.inc:129) and then sprintfs 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. When count($args) == 1, _drush_shell_exec() treats $args[0] as a finished command string and skips sprintf (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 %s for 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