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/tmpscratch root is root-owned0755. It was previously a recursive777, then sticky1777; neither was sufficient, because the sticky bit restricts deletion but never creation, and planted directories fall outsidefs.protected_regularentirely. 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/boais forced to0755directories /0644files.- The
daily.shchown -Lsymlink-following path was closed. - The lshell log directory
/var/log/lshisroot:lshellg 1770(sticky; it was770). Tenants write their own<user>.logthere, 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'schmodand the escape monitor'sgrep), and the three-minute pass sweeps every planted name (see lshell). The sticky bit also arms the shippedfs.protected_regular = 2for that directory, andfs.protected_fifosmoved to level 2 for the same group-writable-sticky case: both gateO_CREATopens of a foreign file, never plain reads — which is why root readers still need their own regular-file gate.
Planted-symlink hardening
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.iniand theirdefault.*templates) are seeded through a helper that stages in a root-owned0700directory under the account root — outside the platform tree, because a freshly built platform can still be group-writable down to itssites/dir — and refuses a symlinked or non-regular source template. - The
robots.txt/llms.txtrefresh 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 validrobots.txtstill lands with the same ownership and mode as before. Forllms.txtthe 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 thechmod/chownthat follow it, are all driven throughfind -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.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 (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_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.
--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;--remotehits the live site over the network from this box's IP;- exit
0means every critical check passed; a5xxor403during 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:
- SKYNET pushes the fix to every host that has it enabled (the default).
- Email notification to the configured admin address where applicable.
- A
CHANGELOG.txtentry 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.
Related
- Security model — the layered protections these audits reinforce, and the hidepid mount (protection #13) the credential work pairs with.
- Strict binary permissions — the
_STRICT_BIN_PERMISSIONSgroup-read and shell-switch hardening. - SSH + SFTP hardening — the SSH-side config-template outcomes.
- Whole-site IP allow-list (
ip_access) — one of the generators whose IP validation the 5.10.1 work hardened. - Abuse Guard — the realip-scored IDS layer.