The re-audit's top finding (HIGH): update.sh runs as root, pulled + re-exec'd its own new copy +
ran install.sh's `docker compose build` on the pulled tree with NO signature check, so a
compromised/redirected origin or a transport MITM could feed attacker commits into a root RCE. The
branch-pin (bc2f2b5) stops a local `git checkout` redirect but not a remote origin compromise —
only a signature does.
- update.sh: after the pull and BEFORE the self-update re-exec / install.sh build, verify the pulled
HEAD with `git -c gpg.ssh.allowedSignersFile=<anchor> verify-commit HEAD`. Opt-in and safe by
default — skipped with a warning when no anchor is configured (existing installs keep updating),
but FAIL CLOSED once configured. The anchor is resolved from .env CLUSEV_ALLOWED_SIGNERS or the
gitignored .clusev-allowed-signers; a SET-but-missing anchor dies (no silent downgrade), and a
git-TRACKED anchor is refused (a pulled tree could otherwise swap in the attacker's own key).
- clusev-release.sh: opt-in SSH signing of the release commit + tag when CLUSEV_SIGNING_KEY is set,
so the verify side has provenance to check. Unsigned (identical behaviour) when unset.
- .gitignore /.clusev-allowed-signers (the trust anchor must never be pull-overwritable);
.env.example documents CLUSEV_ALLOWED_SIGNERS.
verify-commit + allowed-signers proven end-to-end in a scratch repo (signed→pass, unsigned→fail,
wrong-key→fail, tracked-anchor→refuse, set-but-missing→fail-closed). shellcheck clean. Activation
(provision the maintainer keypair + sign releases) is a documented maintainer step.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed-security quick-wins from a full re-audit (6-agent subsystem fan-out,
Codex-reviewed). Each item ships with a regression test proven to fail pre-fix.
- files: gate file CONTENT reads (download / edit / FileEditor::load) behind the
`operate` ability so a read-only viewer keeps LISTING/browse access but can no
longer pull file bytes over the server credential (root -> /etc/shadow, keys,
.env). Hide the download/edit/delete controls from viewers in the blade, and
basename() Files::open() for parity with the other path builders.
- fail2ban: reject a leading dash/dot in validJail(). A jail name like "-h" or
"--help" was parsed by fail2ban-client as an OPTION (argument-injection) rather
than a positional jail; internal dashes ("nginx-http-auth") still pass.
- bruteforce: release the atomically-claimed ban-audit dedup slot when the
AuditEvent write throws, so a transient DB failure no longer suppresses the
auth.ip_banned audit for the whole 60s window (the ban itself is unaffected).
- update path: verify_update_request now FAILS CLOSED when UPDATE_HMAC_KEY is
unset (was fail-open -> any ./run marker could drive a root update). Pin the
self-update `git pull` to the recorded CLUSEV_BUILD_BRANCH and refuse a diverged
checkout, so a `git checkout` in the tree cannot redirect the next root update.
- wireguard: normalise the DNS input to a single comma list and QUOTE the WG_DNS
assignment in wg.env; an unquoted space previously split the assignment and
broke the next `. "$WG_ENV"`.
13 new regression tests. 614 tests, Pint, shellcheck, Codex review all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first hardening pass created the update-phase.json temp with mktemp INSIDE ./run (the
container-writable bind mount) and reopened it by pathname (`> "$tmp"`). A container racing the root
writer could unlink that temp and drop a symlink in its place before the reopen, redirecting the root
write outside ./run. All THREE status-writers now build the temp at the repo ROOT — same filesystem as
./run (so the follow-up mv -f is an atomic, symlink-safe rename(2)) but NOT mounted into any container,
so it cannot be tampered mid-write: watch.sh run_write_json, update.sh set_stage, AND install.sh
set_stage (reachable as root directly and via update.sh -> exec install.sh). The transient /.phase.*
temps are gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Security audit (12-angle adversarial workflow) findings on the container→host boundary:
- .env was created world-readable (cp of a 644 .env.example, never chmod'd) exposing the APP_KEY
that decrypts the whole fleet's SSH vault. Now chown clusev + chmod 0600 before any secret write.
- The root updater followed attacker-planted symlinks in the container-writable ./run (update-phase.json,
update.log) → arbitrary root file write. Status writes now go via a temp + rename(2) (never follow a
link); the update transcript moved to the repo root (not the ./run bind mount).
- The generated UPDATE_HMAC_KEY was dead. The app now HMAC-signs the update-request marker
(config/clusev.php update_hmac_key; DeploymentService) and watch.sh verifies it before running a
root update, so a stray/limited write to ./run can't drive one (a full container compromise holds
the key, so this is a bar-raise + integrity check; the standing guarantee is the marker only ever
re-installs the trusted remote's code, never attacker code).
- force_kv/set_kv wrote unescaped values into a sed replacement — a & | or newline in an operator's
domain/email/port could corrupt .env or inject a sed command. Values are now CR/LF-stripped and
sed-escaped, and the HTTP port is validated numeric/in-range.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dashboard-triggered update that failed on the host left the /update-progress
page spinning to a vague 10-minute timeout — no error was ever shown, because
nothing wrote a failure marker for the page to read. Fix: make failures visible
and prevent the credential-prompt hang class.
- docker/restart-sentinel/watch.sh: run update.sh under `timeout -k 30 1800` with
its output tee'd to run/update.log; on ANY non-zero exit (update.sh or its
exec'd install.sh) — and on the early compose-missing / updater-missing paths —
write {"stage":"error"} to run/update-phase.json via write_update_error().
- update.sh: export GIT_TERMINAL_PROMPT=0 + GIT_HTTP_LOW_SPEED_* and wrap the pull
in `timeout 300`, so a private-repo credential miss fails fast instead of
hanging on a non-interactive prompt.
- DeploymentService::updatePhase(): whitelist the 'error' stage.
- update-progress.blade.php: add an error state (#js-status-error) + showError();
the status-feed poll now stops and shows it on stage=error, marking the active
phase red — instead of looping.
- lang/{en,de}/update.php: error_heading / error_hint / back_button.
- UpdateProgressTest: feed surfaces stage=error; page renders the error branch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Update-progress phases were a pure time guess, so a fast update sat on "fetch"
then snapped all four green at once. Now the host updater publishes its real
macro-stage and the page tracks it:
- update.sh / install.sh write the current stage (fetch|build|restart|migrate|
done) to run/update-phase.json — best-effort (|| true, never aborts an update).
- new public GET /update-status.json serves that stage (whitelisted) to the page.
- update-progress.blade.php drives the checklist from the feed in REAL order
(fetch → build → restart → migrate), falling back to the time heuristic when no
feed is present, and completes on the feed's 'done' (or the version flip, with a
25s grace fail-safe). Like the 502 fix, the live experience lands one update
after this ships (the page shown DURING an update is the old version's).
Also: Versions changelog series + page are now #[Url]-synced (?series=&page=),
so a series/page is shareable and survives reload / back-button.
Tests: /update-status.json (null / whitelisted / rejected stage), the page polls
the feed, and the changelog deep-link reads ?series=&page=.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add update.sh (sudo ./update.sh): git pull --ff-only (never discards local
changes), self-update via re-exec if the script itself changed, then hand off
to the idempotent install.sh non-interactively. Sets safe.directory so a
root-run pull works on the clusev-owned tree.
Fix install.sh so a non-interactive re-run (an update) no longer wipes a
configured domain / ACME e-mail: both now default to the existing .env value
when not supplied. First install (empty = bare IP) is unchanged.
README documents 'sudo ./update.sh'; the MOTD shows the update command.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>