Skip to content

Powered by Grav

Security audit hardening & edge anti-spoofing

Security audit hardening & edge anti-spoofing

Beyond the layered protections in the security model, BOA has run codebase-wide security audits whose outcomes mostly have no knob — they are patch-and-forget. This page collects those operator-relevant results: the filesystem permission baseline, the 5.9.5 audit, the 5.10.1 edge anti-spoofing work, and the edgetest checker that verifies the whole edge.

Filesystem permission baseline

The 5.9.5 audit tightened default file/directory permissions across the board. Notably:

  • The /opt/tmp scratch root is root-owned 0755. It was previously a recursive 777, then sticky 1777; neither was sufficient, because the sticky bit restricts deletion but never creation, and planted directories fall outside fs.protected_regular entirely. Nothing unprivileged writes at the top of the scratch root any more: each install stage signals inside its own /opt/tmp/state/<user> directory, created by root and handed to that user alone (0700).
  • /opt/tmp/boa is forced to 0755 directories / 0644 files.
  • The daily.sh chown -L symlink-following path was closed.
  • The lshell log directory /var/log/lsh is root:lshellg 1770 (sticky; it was 770). Tenants write their own <user>.log there, so the directory is tenant-writable by design; the sticky bit stops one tenant unlinking another's log, root's own readers and writers there act on regular files only (find -type f, never a bare glob — a planted symlink used to redirect root's chmod and the escape monitor's grep), and the three-minute pass sweeps every planted name (see lshell). The sticky bit also arms the shipped fs.protected_regular = 2 for that directory, and fs.protected_fifos moved to level 2 for the same group-writable-sticky case: both gate O_CREAT opens of a foreign file, never plain reads — which is why root readers still need their own regular-file gate.

A later class-wide sweep of every root-run path closed the same defect wherever it appeared: root creating, copying, truncating, chowning or chmodding a path whose final component — or a directory above it — sits somewhere the tenant can write, using a verb that follows symlinks.

Anywhere the tenant can choose a filename — static/, static/trash and static/control, the shell account's own home, the group-writable site files/ and sites/all/libraries trees, and the setgid sites/<uri>/modules and sites/all/modules dirs the per-site and per-platform control INIs live in — a root-run tool must not use a verb that follows symlinks. BOA's root paths now strip a planted link before acting on a root-maintained name, act only once the path is a real directory, prefer chown -h (a no-op on the regular files these normally are), precheck with [ ! -L ] where chmod offers no -h, and drive globs through find -type f, which never matches a link.

Where root writes a file into a tenant-reachable name, the file is built somewhere the tenant cannot tamper with and then moved into place with mv -f -T, so rename() replaces a re-planted link instead of writing through it:

  • The control INIs (boa_site_control.ini, boa_platform_control.ini and their default.* templates) are seeded through a helper that stages in a root-owned 0700 directory under the account root — outside the platform tree, because a freshly built platform can still be group-writable down to its sites/ dir — and refuses a symlinked or non-regular source template.
  • The robots.txt / llms.txt refresh strips the leaf, fetches into a temp in the site directory (owned by the account, not group-writable), then renames over the leaf; the trailing metadata legs are gated on the destination not being a link. A valid robots.txt still lands with the same ownership and mode as before. For llms.txt the refresh also respects provenance: only a copy it fetched itself — recognised by the md5 recorded in a root-owned marker in the site directory — is ever expired, re-fetched or content-gated. An uploaded policy file, whatever its shape, stays in place for as long as the site owner keeps it there, and only has its ownership and mode normalised; editing a fetched copy adopts it as uploaded content the same way.
  • The Solr conf refresh takes a different guard, not a rename: the copy from the tenant's uploaded files/solr, and the chmod/chown that follow it, are all driven through find -type f, so a link uploaded there is never transplanted into the core conf directory and never chmodded or chowned through.

Octopus password-rotation backups are created 0600, and the nightly global cleanup heals any older lax ones and keeps only the newest three per credential file — only the newest can still be current, and the rest only assist password guessing.

There is no knob for any of this.

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.cnf instead of passing the password as an argument, where it was briefly visible in the process list to any local user. Legacy _SQL_PSWD reads were removed throughout, and xmass no longer exposes the SQL root password. This pairs with the hidepid mount (protection #13 on the security model page).
  • Shell-injection hardening. Component downloads default to HTTPS with certificate validation (closing an HTTP-mirror MITM vector); mysql_cleanup uses a strict SQL-identifier allowlist; new internal helpers _is_safe_ident, _validate_safe_dir, _validate_path_prefix and _chmod_safe validate values before they reach the shell. The aegir-shell-injection-audit fixes were applied to Provision and the hosting_civicrm, hosting_git, hosting_tasks_extra modules, closing HTTP-basic-auth-password and special-character injection in drush_shell_exec() paths.
  • HTTP Basic Auth input validation (UI layer). The hosting_tasks_extra bundled 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 with escapeshellarg() before drush_shell_exec() interpolation.
  • mybackup queue-file injection closed. The .ftp → sysuser queue-file path is now validated; lock.inc was rewritten to brace-expansion auto-fd allocation instead of eval.
  • tar-symlink privilege escalation closed. A path where NOPASSWD-sudo tar helpers 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 = 1 and expose_php = Off (cli/FPM templates); MySQL local_infile = OFF (my.cnf.txt); extended sysctl.conf hardening; the insecure wget shell 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-IP plus a BOA-managed, auto-refreshed Cloudflare edge-range list via cloudflare_realip.sh), so rate-limit keys, REMOTE_ADDR, bans and logs reflect the real visitor, not the CDN edge. PHP REMOTE_ADDR is 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 -k was 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 the ip_access, nginx-deny, cloudflare-realip and csf.allow generators: a malformed-but-shape-valid token is skipped fail-closed and can never break configtest fleet-wide (validators reject 0.0.0.0/0 and 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.

--full is not for production. The trap reverts its changes, but the run itself genuinely mutates real CSF state and real control files, and it requires root — run it on a disposable VM only, never on a production box. The invocation contract, precisely:

  • default (local) mode probes the local nginx via --resolve …:127.0.0.1, so it must run on the box that hosts the site — run elsewhere it warns and skips the local checks;
  • --site <SITE> --oct <OCT> is the read-only per-site form; --remote hits the live site over the network from this box's IP;
  • exit 0 means every critical check passed; a 5xx or 403 during a probe scores WARN/inconclusive, never a policy verdict.

The full mode table and the disposable-VM discipline live on Build & test.

Security history — why the auto-patching machinery exists

BOA's reflex-speed patching has a formative event: Drupageddon (SA-CORE-2014-005, 15 October 2014) — an unauthenticated SQL injection in Drupal 7 core's database API, weaponised by automated exploitation within hours of disclosure. The working assumption at the time: a D7 site not patched within about seven hours had to be treated as compromised. That window is what SKYNET (the tag-driven hourly auto-update) was built to beat — with it, a critical core fix can reach every opted-in box on the fleet within the hour, instead of racing the exploit scripts by hand, box by box.

How a new advisory reaches you today, in order:

  1. SKYNET pushes the fix to every host that has it enabled (the default).
  2. Email notification to the configured admin address where applicable.
  3. A CHANGELOG.txt entry records it durably.

For real-time announcements, watch github.com/omega8cc/boa/releases and the CHANGELOG entries on the PRO/LTS branches. Most advisories are patch-and-forget; the machinery above exists precisely so they can be.

© 2026 BOA Documentation. All rights reserved.