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
parent
8a814337ff
commit
d7156ddc96
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
22
install.sh
22
install.sh
|
|
@ -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)"
|
||||
|
|
|
|||
12
update.sh
12
update.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue