fix(security): harden the update path — signed sentinel, symlink-safe root writes, .env 0600

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>
feat/v1-foundation
boban 2026-07-04 10:25:10 +02:00
parent 8a814337ff
commit d7156ddc96
5 changed files with 93 additions and 15 deletions

View File

@ -290,9 +290,19 @@ class DeploymentService
@mkdir($dir, 0775, true);
}
// Sign the marker so the host updater (watch.sh) can reject a forged/limited write to the
// shared ./run dir before running a root update: line 1 = timestamp, line 2 = its HMAC.
// Falls back to the bare timestamp when no key is configured (dev / pre-HMAC installs), which
// the host accepts too.
$ts = now()->toIso8601String();
$key = (string) config('clusev.update_hmac_key');
$body = $key !== ''
? $ts.PHP_EOL.hash_hmac('sha256', $ts, $key).PHP_EOL
: $ts.PHP_EOL;
// Returns false when the (bind-mounted) sentinel dir is not writable by the app user —
// the caller surfaces that instead of showing a "running" state that never resolves.
$written = @file_put_contents($path, now()->toIso8601String().PHP_EOL) !== false;
$written = @file_put_contents($path, $body) !== false;
if ($written) {
// Best-effort start marker (a failure here must never fail the update): lets the

View File

@ -49,5 +49,11 @@ return [
// (terminal disabled). install.sh generates it alongside the other secrets.
'terminal_secret' => env('TERMINAL_SIDECAR_SECRET', ''),
// Shared secret used to AUTHENTICATE the update-request marker the app drops for the host updater.
// The app signs the marker (HMAC-SHA256) and the host-side watch.sh verifies it before running a
// root update, so a stray/limited write to the shared ./run dir cannot drive one. install.sh
// generates it alongside the other secrets; the host reads the SAME value from .env.
'update_hmac_key' => env('UPDATE_HMAC_KEY', ''),
'license' => 'AGPL-3.0',
];

View File

@ -35,14 +35,42 @@ COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
TAG="clusev-restart"
log() { printf '%s %s: %s\n' "$(date -Is)" "$TAG" "$*"; }
# Publish an "error" stage to the update-progress feed (run/update-phase.json, bind-mounted into the
# app container) so the browser shows the failure AT ONCE instead of spinning to its 10-minute
# timeout. Best-effort — a write failure must never change the update's outcome.
# run_write_json PAYLOAD — write PAYLOAD to run/update-phase.json SYMLINK-SAFELY (as root, into a
# directory the app container can write). ./run is container-writable, so a compromised container
# could pre-plant update-phase.json as a symlink to an arbitrary host path; a plain `> file` would
# follow it and let this root write escape ./run. We write a temp in ./run and rename(2) over the
# target: rename replaces the path itself (link included) with our regular file and NEVER follows the
# link. Best-effort — a write failure must never change the update's outcome.
run_write_json() {
local d="${CLUSEV_DIR}/run" tmp
mkdir -p "$d" 2>/dev/null || true
tmp="$(mktemp "${d}/.phase.XXXXXX" 2>/dev/null)" || return 0
printf '%s\n' "$1" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
chmod 644 "$tmp" 2>/dev/null || true
mv -f "$tmp" "${d}/update-phase.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
return 0
}
# Publish an "error" stage to the update-progress feed so the browser shows the failure AT ONCE
# instead of spinning to its 10-minute timeout.
write_update_error() {
local f="${CLUSEV_DIR}/run/update-phase.json"
mkdir -p "${CLUSEV_DIR}/run" 2>/dev/null || true
printf '{"stage":"error","at":%s}\n' "$(date +%s 2>/dev/null || echo 0)" > "$f" 2>/dev/null || true
chmod 644 "$f" 2>/dev/null || true
run_write_json "$(printf '{"stage":"error","at":%s}' "$(date +%s 2>/dev/null || echo 0)")"
}
# Authenticate an update request marker. The app (DeploymentService) signs it with UPDATE_HMAC_KEY
# (line 1 = ISO8601 timestamp, line 2 = HMAC-SHA256(timestamp)); we recompute and compare. Refusing
# an unsigned/forged marker stops a stray or limited write to ./run from driving a root update.
# (A full app-container compromise holds the key, so this raises the bar rather than being absolute —
# the standing guarantee is that a triggered update only re-installs the TRUSTED remote's code.)
verify_update_request() {
local f="$1" key ts mac expect
key="$(grep -m1 '^UPDATE_HMAC_KEY=' "${CLUSEV_DIR}/.env" 2>/dev/null | cut -d= -f2-)"
[ -n "$key" ] || { log "UPDATE_HMAC_KEY unset — skipping request authentication"; return 0; }
ts="$(sed -n '1p' "$f" 2>/dev/null)"
mac="$(sed -n '2p' "$f" 2>/dev/null)"
[ -n "$ts" ] && [ -n "$mac" ] || { log "update request unsigned/malformed — refusing"; return 1; }
expect="$(printf '%s' "$ts" | openssl dgst -sha256 -hmac "$key" 2>/dev/null | sed 's/^.*= *//')"
[ -n "$expect" ] && [ "$expect" = "$mac" ] || { log "update request HMAC mismatch — refusing"; return 1; }
return 0
}
@ -89,14 +117,24 @@ do_update() {
write_update_error
return 1
fi
# Authenticate BEFORE consuming/running: refuse (and clear, so it does not loop the .path unit) a
# marker that carries no valid HMAC signature.
if ! verify_update_request "$CLUSEV_UPDATE_SIGNAL"; then
rm -f "$CLUSEV_UPDATE_SIGNAL"
write_update_error
return 1
fi
rm -f "$CLUSEV_UPDATE_SIGNAL" && log "update sentinel consumed: $CLUSEV_UPDATE_SIGNAL"
log "update requested — running update.sh in ${CLUSEV_DIR}"
# Tee the whole run to a host log the operator can inspect, and cap it with a generous timeout so
# a truly-stuck step (e.g. a hung build) fails LOUDLY rather than pinning the progress page. On ANY
# non-zero exit (update.sh or its exec'd install.sh — the exit code propagates through exec) publish
# the error stage so the browser stops spinning and shows the failure. `timeout` exit 124 = capped.
local logf="${CLUSEV_DIR}/run/update.log" rc=0
mkdir -p "${CLUSEV_DIR}/run" 2>/dev/null || true
# The log lives at the repo ROOT, NOT under ./run: ./run is the container-writable bind mount, so a
# log there could be pre-planted as a symlink and this root `tee` would follow it; the repo root is
# not mounted into any container, so a container cannot redirect this write.
local logf="${CLUSEV_DIR}/update.log" rc=0
rm -f "$logf" 2>/dev/null || true
timeout -k 30 1800 bash "$updater" 2>&1 | tee "$logf" || rc=${PIPESTATUS[0]}
if [ "$rc" = 0 ]; then
log "update applied"

View File

@ -39,9 +39,14 @@ set_stage() {
# ── .env mutators ────────────────────────────────────────────────────
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
force_kv() {
local key="$1" val="$2"
local key="$1" val esc
# Strip stray CR/LF (a newline would split .env into two entries) and neutralise sed replacement
# metacharacters (& | \) so an operator-supplied domain/email/port can neither corrupt .env nor
# inject a sed command (values reach here unvalidated from CLUSEV_DOMAIN/ADMIN_EMAIL/HTTP_PORT).
val="$(printf '%s' "$2" | tr -d '\r\n')"
esc="$(printf '%s' "$val" | sed -e 's/[&|\\]/\\&/g')"
if grep -qE "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
sed -i "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE"
else
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
fi
@ -154,6 +159,13 @@ HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HO
SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
[ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP"
# Reject a non-numeric / out-of-range HTTP port (an operator typo would otherwise reach compose as a
# bad ${APP_PORT} and fail cryptically). Fall back to 80.
case "$HTTP_PORT" in
''|*[!0-9]*) warn "HTTP-Port '${HTTP_PORT}' nicht numerisch — nutze 80"; HTTP_PORT=80 ;;
*) if [ "$HTTP_PORT" -lt 1 ] || [ "$HTTP_PORT" -gt 65535 ]; then warn "HTTP-Port '${HTTP_PORT}' ausserhalb 1-65535 — nutze 80"; HTTP_PORT=80; fi ;;
esac
# ── domain DNS check (only when a domain is set) ─────────────────────
DOMAIN_OK=0
if [ -n "$DOMAIN" ]; then
@ -202,6 +214,12 @@ fi
phase 3/9 "Secrets generieren"
if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt";
else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi
# .env holds EVERY secret: DB/root/redis passwords, APP_KEY (decrypts the whole fleet's SSH-credential
# vault + 2FA secrets), the terminal sidecar secret and the update HMAC key. Never leave it
# world-readable. Own it by the clusev user (the tree owner) and lock it to 0600 BEFORE any secret is
# written; the subsequent sed -i / >> writes preserve this mode.
chown clusev:clusev "$ENV_FILE" 2>/dev/null || true
chmod 600 "$ENV_FILE"
set_kv DB_PASSWORD "$(rand_hex 24)"
set_kv DB_ROOT_PASSWORD "$(rand_hex 24)"

View File

@ -32,9 +32,15 @@ export GIT_HTTP_LOW_SPEED_TIME="${GIT_HTTP_LOW_SPEED_TIME:-30}"
# Written first as "fetch" so the screen leaves the time-guess behind the moment the pull begins,
# and so a stale stage from a previous update is reset.
set_stage() {
mkdir -p "$REPO_DIR/run" 2>/dev/null || true
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
chmod 644 "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
# Symlink-safe write: ./run is the container-writable bind mount, so update-phase.json could be
# pre-planted as a symlink; write a temp in ./run and rename(2) over the target so this root write
# replaces the link itself and never follows it (mirror of run_write_json in the restart-sentinel).
local d="$REPO_DIR/run" tmp
mkdir -p "$d" 2>/dev/null || true
tmp="$(mktemp "${d}/.phase.XXXXXX" 2>/dev/null)" || return 0
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$tmp" 2>/dev/null || { rm -f "$tmp" 2>/dev/null; return 0; }
chmod 644 "$tmp" 2>/dev/null || true
mv -f "$tmp" "${d}/update-phase.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
return 0
}
set_stage fetch