fix(install): validate release-image digests as ghcr.io/<owner>/<name>@sha256:<64-hex>

Codex R15 flagged that _clusev_ref_ok accepted malformed digest refs (short/non-hex,
which trigger a doomed pull instead of a build fallback) and refs carrying an embedded
newline in ANY segment (force_kv would then split .env, injecting an attacker-chosen key).
Anchor the check to an exact 64-hex sha256 digest and two ghcr.io path segments that each
start alphanumeric and otherwise contain only [A-Za-z0-9._-] — so no newline, "=",
whitespace or slash can appear anywhere in the ref. Malformed or tampered refs now fall
back to a local build and can never reach force_kv. Tests: +6 cases (short, non-hex,
path-traversal, ".." name, newline in digest, newline in owner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-03 21:37:56 +02:00
parent 4af1633ca9
commit 8a814337ff
2 changed files with 67 additions and 20 deletions

View File

@ -20,11 +20,15 @@ clusev_json_str() {
fi fi
} }
# _clusev_ref_ok REF -> true ONLY for a ghcr.io digest-pinned ref (ghcr.io/<owner>/<name>@sha256:…). # _clusev_ref_ok REF -> true ONLY for a fully-formed ghcr.io digest ref:
# Anything looser — a bare tag, a non-ghcr host, a truncated or empty/garbage value — is rejected so # ghcr.io/<owner>/<name>@sha256:<64 lowercase hex>, where each of <owner>/<name> starts with an
# the caller falls back to a local build instead of a doomed or attacker-directed pull. The promote # alphanumeric and otherwise contains only [A-Za-z0-9._-] — so no slash, whitespace, newline, "=" or
# CI always emits exactly this shape, so a rejected ref means a corrupt/tampered lock. # any other shell/dotenv metacharacter can appear ANYWHERE in the ref. Anything looser — a bare tag, a
_clusev_ref_ok() { case "$1" in ghcr.io/*/*@sha256:?*) return 0 ;; *) return 1 ;; esac; } # non-ghcr host, a short/non-hex digest, an extra or relative path segment (…/../…), or an embedded
# newline in ANY segment — is rejected so the caller falls back to a local build instead of a doomed
# or attacker-directed pull, and no malformed ref can reach force_kv to corrupt .env. The promote CI
# always emits exactly this shape, so a rejected ref means a corrupt/tampered lock.
_clusev_ref_ok() { [[ "$1" =~ ^ghcr\.io/[a-zA-Z0-9][a-zA-Z0-9._-]*/[a-zA-Z0-9][a-zA-Z0-9._-]*@sha256:[0-9a-f]{64}$ ]]; }
# clusev_resolve_release_images [LOCK] -> sets + exports CLUSEV_IMAGE, CLUSEV_TERMINAL_IMAGE, # clusev_resolve_release_images [LOCK] -> sets + exports CLUSEV_IMAGE, CLUSEV_TERMINAL_IMAGE,
# CLUSEV_PULL and CLUSEV_RELEASE_MODE (env|pull|build). Always leaves a coherent pin. # CLUSEV_PULL and CLUSEV_RELEASE_MODE (env|pull|build). Always leaves a coherent pin.

View File

@ -11,12 +11,23 @@ ok() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1)); printf
reset_env() { unset CLUSEV_IMAGE CLUSEV_TERMINAL_IMAGE CLUSEV_PULL CLUSEV_RELEASE_MODE; } reset_env() { unset CLUSEV_IMAGE CLUSEV_TERMINAL_IMAGE CLUSEV_PULL CLUSEV_RELEASE_MODE; }
mklock() { printf '{ "version": "v0.11.0", "app": "%s", "terminal": "%s" }\n' "$1" "$2" > "$3"; } mklock() { printf '{ "version": "v0.11.0", "app": "%s", "terminal": "%s" }\n' "$1" "$2" > "$3"; }
h64() { printf '%064d' 0 | tr 0 "$1"; } # 64 copies of hex char $1 (a valid sha256 body)
valid_lock="$tmp/valid.lock"; mklock "ghcr.io/o/clusev@sha256:aaa" "ghcr.io/o/clusev-terminal@sha256:bbb" "$valid_lock" app_ref="ghcr.io/o/clusev@sha256:$(h64 a)"
empty_lock="$tmp/empty.lock"; mklock "" "ghcr.io/o/clusev-terminal@sha256:bbb" "$empty_lock" term_ref="ghcr.io/o/clusev-terminal@sha256:$(h64 b)"
slash_lock="$tmp/slash.lock"; mklock "hello/world" "ghcr.io/o/clusev-terminal@sha256:bbb" "$slash_lock" preset_ref="ghcr.io/preset/clusev@sha256:$(h64 c)"
notag_lock="$tmp/notag.lock"; mklock "ghcr.io/o/clusev" "ghcr.io/o/clusev-terminal@sha256:bbb" "$notag_lock"
evil_lock="$tmp/evil.lock"; mklock "evil.io/o/clusev@sha256:aaa" "ghcr.io/o/clusev-terminal@sha256:bbb" "$evil_lock" valid_lock="$tmp/valid.lock"; mklock "$app_ref" "$term_ref" "$valid_lock"
empty_lock="$tmp/empty.lock"; mklock "" "$term_ref" "$empty_lock"
slash_lock="$tmp/slash.lock"; mklock "hello/world" "$term_ref" "$slash_lock"
notag_lock="$tmp/notag.lock"; mklock "ghcr.io/o/clusev" "$term_ref" "$notag_lock"
evil_lock="$tmp/evil.lock"; mklock "evil.io/o/clusev@sha256:$(h64 a)" "$term_ref" "$evil_lock"
short_lock="$tmp/short.lock"; mklock "ghcr.io/o/clusev@sha256:aaa" "$term_ref" "$short_lock"
nonhex_lock="$tmp/nonhex.lock"; mklock "ghcr.io/o/clusev@sha256:$(h64 g)" "$term_ref" "$nonhex_lock"
trav_lock="$tmp/trav.lock"; mklock "ghcr.io/o/../clusev@sha256:$(h64 a)" "$term_ref" "$trav_lock"
nl_lock="$tmp/nl.lock"; mklock "$(printf 'ghcr.io/o/clusev@sha256:a\nCLUSEV_PULL=0')" "$term_ref" "$nl_lock"
nlown_lock="$tmp/nlown.lock"; mklock "$(printf 'ghcr.io/o\nEVIL=x/clusev@sha256:%s' "$(h64 a)")" "$term_ref" "$nlown_lock"
dotname_lock="$tmp/dotname.lock"; mklock "ghcr.io/o/..@sha256:$(h64 a)" "$term_ref" "$dotname_lock"
# 1) no lock -> build with local default tags, no pull # 1) no lock -> build with local default tags, no pull
reset_env reset_env
@ -26,12 +37,12 @@ ok "no-lock image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
ok "no-lock terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod" ok "no-lock terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod"
ok "no-lock pull" "${CLUSEV_PULL:-}" "0" ok "no-lock pull" "${CLUSEV_PULL:-}" "0"
# 2) valid ghcr digest lock -> pull the exact digests # 2) valid ghcr 64-hex digest lock -> pull the exact digests
reset_env reset_env
clusev_resolve_release_images "$valid_lock" clusev_resolve_release_images "$valid_lock"
ok "valid mode" "${CLUSEV_RELEASE_MODE:-}" "pull" ok "valid mode" "${CLUSEV_RELEASE_MODE:-}" "pull"
ok "valid image" "${CLUSEV_IMAGE:-}" "ghcr.io/o/clusev@sha256:aaa" ok "valid image" "${CLUSEV_IMAGE:-}" "$app_ref"
ok "valid terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "ghcr.io/o/clusev-terminal@sha256:bbb" ok "valid terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "$term_ref"
ok "valid pull" "${CLUSEV_PULL:-}" "1" ok "valid pull" "${CLUSEV_PULL:-}" "1"
# 3) empty app ref -> build fallback # 3) empty app ref -> build fallback
@ -41,7 +52,7 @@ ok "empty mode" "${CLUSEV_RELEASE_MODE:-}" "build"
ok "empty image" "${CLUSEV_IMAGE:-}" "clusev-app:prod" ok "empty image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
ok "empty pull" "${CLUSEV_PULL:-}" "0" ok "empty pull" "${CLUSEV_PULL:-}" "0"
# 4) slash-shaped garbage (has "/", no @sha256) -> build fallback (not a doomed pull) # 4) slash-shaped garbage (has "/", no @sha256) -> build fallback
reset_env reset_env
clusev_resolve_release_images "$slash_lock" clusev_resolve_release_images "$slash_lock"
ok "slash mode" "${CLUSEV_RELEASE_MODE:-}" "build" ok "slash mode" "${CLUSEV_RELEASE_MODE:-}" "build"
@ -51,24 +62,56 @@ reset_env
clusev_resolve_release_images "$notag_lock" clusev_resolve_release_images "$notag_lock"
ok "notag mode" "${CLUSEV_RELEASE_MODE:-}" "build" ok "notag mode" "${CLUSEV_RELEASE_MODE:-}" "build"
# 6) non-ghcr host, even digest-pinned -> build fallback (registry allowlist) # 6) non-ghcr host, even 64-hex digest -> build fallback (registry allowlist)
reset_env reset_env
clusev_resolve_release_images "$evil_lock" clusev_resolve_release_images "$evil_lock"
ok "evil mode" "${CLUSEV_RELEASE_MODE:-}" "build" ok "evil mode" "${CLUSEV_RELEASE_MODE:-}" "build"
ok "evil image" "${CLUSEV_IMAGE:-}" "clusev-app:prod" ok "evil image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
# 7) env preset wins over lock; terminal defaults when unset # 7) short digest (<64 hex) -> build fallback
reset_env reset_env
export CLUSEV_IMAGE="ghcr.io/preset/clusev@sha256:zzz" clusev_resolve_release_images "$short_lock"
ok "short mode" "${CLUSEV_RELEASE_MODE:-}" "build"
# 8) non-hex 64-char digest -> build fallback
reset_env
clusev_resolve_release_images "$nonhex_lock"
ok "nonhex mode" "${CLUSEV_RELEASE_MODE:-}" "build"
# 9) extra path segment (…/../…) -> build fallback (no traversal-shaped ref pulled)
reset_env
clusev_resolve_release_images "$trav_lock"
ok "trav mode" "${CLUSEV_RELEASE_MODE:-}" "build"
# 10) newline-injecting digest ref -> build fallback (never reaches force_kv -> no .env injection)
reset_env
clusev_resolve_release_images "$nl_lock"
ok "nl mode" "${CLUSEV_RELEASE_MODE:-}" "build"
ok "nl image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
# 11) newline embedded in the OWNER segment (not the digest) -> build; never reaches force_kv
reset_env
clusev_resolve_release_images "$nlown_lock"
ok "nlown mode" "${CLUSEV_RELEASE_MODE:-}" "build"
ok "nlown image" "${CLUSEV_IMAGE:-}" "clusev-app:prod"
# 12) relative/dotted image name (..) -> build (no traversal-shaped ref pulled)
reset_env
clusev_resolve_release_images "$dotname_lock"
ok "dotname mode" "${CLUSEV_RELEASE_MODE:-}" "build"
# 13) env preset wins over lock; terminal defaults when unset
reset_env
export CLUSEV_IMAGE="$preset_ref"
clusev_resolve_release_images "$valid_lock" clusev_resolve_release_images "$valid_lock"
ok "env mode" "${CLUSEV_RELEASE_MODE:-}" "env" ok "env mode" "${CLUSEV_RELEASE_MODE:-}" "env"
ok "env image" "${CLUSEV_IMAGE:-}" "ghcr.io/preset/clusev@sha256:zzz" ok "env image" "${CLUSEV_IMAGE:-}" "$preset_ref"
ok "env terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod" ok "env terminal" "${CLUSEV_TERMINAL_IMAGE:-}" "clusev-terminal:prod"
ok "env pull" "${CLUSEV_PULL:-}" "1" ok "env pull" "${CLUSEV_PULL:-}" "1"
# 8) json_str: known + missing key # 14) json_str: known + missing key
reset_env reset_env
ok "json known" "$(clusev_json_str "$valid_lock" app)" "ghcr.io/o/clusev@sha256:aaa" ok "json known" "$(clusev_json_str "$valid_lock" app)" "$app_ref"
ok "json missing" "$(clusev_json_str "$valid_lock" nope)" "" ok "json missing" "$(clusev_json_str "$valid_lock" nope)" ""
printf '\n%d passed, %d failed\n' "$pass" "$fail" printf '\n%d passed, %d failed\n' "$pass" "$fail"