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);
|
@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 —
|
// 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.
|
// 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) {
|
if ($written) {
|
||||||
// Best-effort start marker (a failure here must never fail the update): lets the
|
// 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 disabled). install.sh generates it alongside the other secrets.
|
||||||
'terminal_secret' => env('TERMINAL_SIDECAR_SECRET', ''),
|
'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',
|
'license' => 'AGPL-3.0',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,42 @@ COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml"
|
||||||
TAG="clusev-restart"
|
TAG="clusev-restart"
|
||||||
log() { printf '%s %s: %s\n' "$(date -Is)" "$TAG" "$*"; }
|
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
|
# run_write_json PAYLOAD — write PAYLOAD to run/update-phase.json SYMLINK-SAFELY (as root, into a
|
||||||
# app container) so the browser shows the failure AT ONCE instead of spinning to its 10-minute
|
# directory the app container can write). ./run is container-writable, so a compromised container
|
||||||
# timeout. Best-effort — a write failure must never change the update's outcome.
|
# 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() {
|
write_update_error() {
|
||||||
local f="${CLUSEV_DIR}/run/update-phase.json"
|
run_write_json "$(printf '{"stage":"error","at":%s}' "$(date +%s 2>/dev/null || echo 0)")"
|
||||||
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
|
# 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,14 +117,24 @@ do_update() {
|
||||||
write_update_error
|
write_update_error
|
||||||
return 1
|
return 1
|
||||||
fi
|
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"
|
rm -f "$CLUSEV_UPDATE_SIGNAL" && log "update sentinel consumed: $CLUSEV_UPDATE_SIGNAL"
|
||||||
log "update requested — running update.sh in ${CLUSEV_DIR}"
|
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
|
# 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
|
# 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
|
# 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.
|
# 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
|
# The log lives at the repo ROOT, NOT under ./run: ./run is the container-writable bind mount, so a
|
||||||
mkdir -p "${CLUSEV_DIR}/run" 2>/dev/null || true
|
# 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]}
|
timeout -k 30 1800 bash "$updater" 2>&1 | tee "$logf" || rc=${PIPESTATUS[0]}
|
||||||
if [ "$rc" = 0 ]; then
|
if [ "$rc" = 0 ]; then
|
||||||
log "update applied"
|
log "update applied"
|
||||||
|
|
|
||||||
22
install.sh
22
install.sh
|
|
@ -39,9 +39,14 @@ set_stage() {
|
||||||
# ── .env mutators ────────────────────────────────────────────────────
|
# ── .env mutators ────────────────────────────────────────────────────
|
||||||
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
|
# force_kv: always write (config/derived). set_kv: write only when empty/placeholder.
|
||||||
force_kv() {
|
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
|
if grep -qE "^${key}=" "$ENV_FILE"; then
|
||||||
sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE"
|
sed -i "s|^${key}=.*|${key}=${esc}|" "$ENV_FILE"
|
||||||
else
|
else
|
||||||
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
|
printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE"
|
||||||
fi
|
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)"
|
SERVER_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
|
||||||
[ -n "$SERVER_IP" ] || SERVER_IP="$HOST_IP"
|
[ -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 DNS check (only when a domain is set) ─────────────────────
|
||||||
DOMAIN_OK=0
|
DOMAIN_OK=0
|
||||||
if [ -n "$DOMAIN" ]; then
|
if [ -n "$DOMAIN" ]; then
|
||||||
|
|
@ -202,6 +214,12 @@ fi
|
||||||
phase 3/9 "Secrets generieren"
|
phase 3/9 "Secrets generieren"
|
||||||
if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt";
|
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
|
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_PASSWORD "$(rand_hex 24)"
|
||||||
set_kv DB_ROOT_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,
|
# 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.
|
# and so a stale stage from a previous update is reset.
|
||||||
set_stage() {
|
set_stage() {
|
||||||
mkdir -p "$REPO_DIR/run" 2>/dev/null || true
|
# Symlink-safe write: ./run is the container-writable bind mount, so update-phase.json could be
|
||||||
printf '{"stage":"%s","at":%s}\n' "$1" "$(date +%s 2>/dev/null || echo 0)" > "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
|
# pre-planted as a symlink; write a temp in ./run and rename(2) over the target so this root write
|
||||||
chmod 644 "$REPO_DIR/run/update-phase.json" 2>/dev/null || true
|
# 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
|
return 0
|
||||||
}
|
}
|
||||||
set_stage fetch
|
set_stage fetch
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue