fix: keep the gitea token out of chezmoi init's argv and .git/config
The endpoint hands back a clone URL with the read-only token inline, and that
URL went straight onto `chezmoi init`'s command line. Two durable exposures
followed, neither of which the existing redaction touched -- it only kept the
token out of the trace line:
- /proc/<pid>/cmdline is world-readable, so any account on the box could read
the live token for as long as the clone ran
- the resulting .git/config recorded the credential-bearing remote and kept
it until the tree was deleted
Split the credential out of the URL before anything executes. The token goes to
$STATE/private-credentials at 600 in git-credential-store format; the URL that
reaches argv and .git/config is clean. The helper is passed via GIT_CONFIG_*
for the clone and then written into the clone's own config, so a later
`chezmoi update` still authenticates without the token being stored.
Verified: clone succeeds with the clean URL, .git/config contains no token, and
a subsequent fetch authenticates from the credential file alone. Suite 101/101.
This commit is contained in:
+30
-1
@@ -130,9 +130,38 @@ is "private init does not use run()" "0" \
|
|||||||
# Two, not one: the dry-run branch traces it too, and a dry run that printed a
|
# Two, not one: the dry-run branch traces it too, and a dry run that printed a
|
||||||
# live token would be the worse leak of the pair.
|
# live token would be the worse leak of the pair.
|
||||||
is "both traced URLs are redacted" "2" \
|
is "both traced URLs are redacted" "2" \
|
||||||
"$(grep -c 'redact_url "\$p_repo"' $D)"
|
"$(grep -c 'redact_url "\$p_clean"' $D)"
|
||||||
|
# Not printing the token was never enough. p_repo is the URL as the endpoint
|
||||||
|
# hands it over, credential inline; putting THAT on chezmoi's command line
|
||||||
|
# publishes it to /proc/<pid>/cmdline, which every account on the box can read
|
||||||
|
# for as long as the clone runs, and then into the clone's .git/config, which
|
||||||
|
# keeps it. Only the split-out p_clean may reach an argv.
|
||||||
|
is "the credential-bearing URL never reaches an argv" "0" \
|
||||||
|
"$(grep -c 'chezmoi init.*\$p_repo' $D)"
|
||||||
|
is "the token goes to a credential file instead" "1" \
|
||||||
|
"$(grep -c "umask 077; printf '%s\\\\n' \"\\\$p_cred\" > \"\\\$PRIV_CRED\"" $D)"
|
||||||
|
is "…at mode 600" "1" \
|
||||||
|
"$(grep -c 'chmod 600 "\$PRIV_CRED"' $D)"
|
||||||
# Exercise the real implementation lifted straight out of dotup. A copy of the
|
# Exercise the real implementation lifted straight out of dotup. A copy of the
|
||||||
# sed expression here would keep passing after someone edited the original.
|
# sed expression here would keep passing after someone edited the original.
|
||||||
|
split() { p_repo=$1; eval "$(sed -n '/p_cred=\$(printf/p;/p_clean=\$(printf/p' $D)"
|
||||||
|
printf '%s %s\n' "${p_cred:-<none>}" "$p_clean"; }
|
||||||
|
is "split lifts the credential out of the URL" \
|
||||||
|
"https://ben:deadbeefcafe@git.example.com https://git.example.com/x.git" \
|
||||||
|
"$(split 'https://ben:deadbeefcafe@git.example.com/x.git')"
|
||||||
|
# A port must survive into the credential line: git matches the store entry on
|
||||||
|
# host AND port, so dropping :3000 would silently stop authenticating.
|
||||||
|
is "…keeping the port" \
|
||||||
|
"http://u:p@example.com:3000 http://example.com:3000/a/b.git" \
|
||||||
|
"$(split 'http://u:p@example.com:3000/a/b.git')"
|
||||||
|
# No userinfo means nothing to store and nothing to strip — ssh remotes and
|
||||||
|
# unauthenticated https must pass through byte-identical.
|
||||||
|
is "…and leaves a credential-free remote untouched" \
|
||||||
|
"<none> https://git.example.com/x.git" \
|
||||||
|
"$(split 'https://git.example.com/x.git')"
|
||||||
|
is "…including scp-style ssh" \
|
||||||
|
"<none> git@git.example.com:ben/x.git" \
|
||||||
|
"$(split 'git@git.example.com:ben/x.git')"
|
||||||
rd() { eval "$(sed -n '/^redact_url()/p' $D)"; redact_url "$1"; }
|
rd() { eval "$(sed -n '/^redact_url()/p' $D)"; redact_url "$1"; }
|
||||||
is "redact_url strips userinfo" "https://<redacted>@git.example.com/x.git" \
|
is "redact_url strips userinfo" "https://<redacted>@git.example.com/x.git" \
|
||||||
"$(rd 'https://ben:deadbeefcafe@git.example.com/x.git')"
|
"$(rd 'https://ben:deadbeefcafe@git.example.com/x.git')"
|
||||||
|
|||||||
@@ -744,6 +744,19 @@ BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-tok
|
|||||||
# are, days later, with nothing connecting it to the install you ran.
|
# are, days later, with nothing connecting it to the install you ran.
|
||||||
PRIV_CFG=${DOTUP_PRIVATE_CFG:-${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml}
|
PRIV_CFG=${DOTUP_PRIVATE_CFG:-${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml}
|
||||||
|
|
||||||
|
# git's credential store for the private remote.
|
||||||
|
#
|
||||||
|
# The clone URL the endpoint hands back carries the token inline, and handing
|
||||||
|
# that to `chezmoi init` puts a live credential in two durable places: the
|
||||||
|
# init process's argv, which ANY account on the box can read out of
|
||||||
|
# /proc/<pid>/cmdline for as long as the clone runs, and the resulting
|
||||||
|
# .git/config, which keeps it until the tree is deleted. Neither is fixed by
|
||||||
|
# not printing it -- the earlier redaction work only covered the trace line.
|
||||||
|
#
|
||||||
|
# Splitting the credential out of the URL fixes both: git reads the secret from
|
||||||
|
# this file at 600, and the remote it records is clean.
|
||||||
|
PRIV_CRED=${DOTUP_PRIVATE_CRED:-$STATE/private-credentials}
|
||||||
|
|
||||||
cmd_private() {
|
cmd_private() {
|
||||||
rows=$(selected_private)
|
rows=$(selected_private)
|
||||||
[ -n "$rows" ] || return 0
|
[ -n "$rows" ] || return 0
|
||||||
@@ -775,7 +788,7 @@ cmd_private() {
|
|||||||
printf ' Password: ' >&2
|
printf ' Password: ' >&2
|
||||||
stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2
|
stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2
|
||||||
# shellcheck disable=SC2064
|
# shellcheck disable=SC2064
|
||||||
trap 'unset P_URL P_USER P_PW P_BLOB 2>/dev/null || :' EXIT INT TERM
|
trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :' EXIT INT TERM
|
||||||
|
|
||||||
# curl -K - reads its config, credentials included, from stdin rather than
|
# curl -K - reads its config, credentials included, from stdin rather than
|
||||||
# the command line. --fail matters too: without it a 401 body is parsed as
|
# the command line. --fail matters too: without it a 401 body is parsed as
|
||||||
@@ -807,31 +820,65 @@ cmd_private() {
|
|||||||
|
|
||||||
if [ "$want_repo" -eq 1 ]; then
|
if [ "$want_repo" -eq 1 ]; then
|
||||||
if [ -n "$p_repo" ]; then
|
if [ -n "$p_repo" ]; then
|
||||||
# NOT `run`. It echoes its whole argv to stderr, and this argv ends
|
# Split the credential out of the URL before anything executes.
|
||||||
# in https://user:TOKEN@host/... -- which would put a live git
|
# p_cred is the bare scheme://user:token@host that
|
||||||
# credential into terminal scrollback, any `dotup 2>log`, and any
|
# git-credential-store wants -- host only, no path. p_clean is the
|
||||||
# agent or CI transcript capturing the run. Trace a redacted form
|
# same URL with the userinfo removed. A remote with no userinfo
|
||||||
# and execute quietly.
|
# (ssh, or an unauthenticated https URL) leaves p_cred empty and
|
||||||
|
# p_clean identical to what arrived, so it is used unchanged.
|
||||||
|
p_cred=$(printf '%s\n' "$p_repo" | sed -n 's|^\(https\{0,1\}://[^@/]*@[^/]*\).*|\1|p')
|
||||||
|
p_clean=$(printf '%s\n' "$p_repo" | sed -e 's|^\(https\{0,1\}://\)[^@/]*@|\1|')
|
||||||
|
if [ -n "$p_cred" ]; then
|
||||||
|
mkdir -p "$(dirname "$PRIV_CRED")"
|
||||||
|
( umask 077; printf '%s\n' "$p_cred" > "$PRIV_CRED" )
|
||||||
|
chmod 600 "$PRIV_CRED"
|
||||||
|
# GIT_CONFIG_* rather than `git -c`: the helper string names
|
||||||
|
# the credential file, and while that path is not itself a
|
||||||
|
# secret, argv is the wrong channel for anything about it.
|
||||||
|
# The value is single-quoted because git runs a helper
|
||||||
|
# containing spaces through a shell -- an unquoted $HOME with
|
||||||
|
# a space in it would split into two arguments.
|
||||||
|
GIT_CONFIG_COUNT=1
|
||||||
|
GIT_CONFIG_KEY_0=credential.helper
|
||||||
|
GIT_CONFIG_VALUE_0="store --file='$PRIV_CRED'"
|
||||||
|
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NOT `run`. It echoes its whole argv to stderr, and even a clean
|
||||||
|
# URL is worth tracing deliberately rather than by accident.
|
||||||
|
# redact_url stays on the trace as a second line of defence: if
|
||||||
|
# the split above ever fails to match, the credential still does
|
||||||
|
# not reach terminal scrollback, `dotup 2>log`, or an agent
|
||||||
|
# transcript capturing the run.
|
||||||
mkdir -p "$(dirname "$PRIV_CFG")"
|
mkdir -p "$(dirname "$PRIV_CFG")"
|
||||||
if [ "$DRYRUN" -eq 1 ]; then
|
if [ "$DRYRUN" -eq 1 ]; then
|
||||||
printf ' + chezmoi init --apply --source %s -c %s %s\n' \
|
printf ' + chezmoi init --apply --source %s -c %s %s\n' \
|
||||||
"$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")"
|
"$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")"
|
||||||
else
|
else
|
||||||
printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \
|
printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \
|
||||||
"$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")" "$R" >&2
|
"$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")" "$R" >&2
|
||||||
chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_repo" \
|
chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_clean" \
|
||||||
|| err "private repo init failed"
|
|| err "private repo init failed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The exported vars above only cover this process. Record the same
|
||||||
|
# helper in the clone's own config so a later `chezmoi update`
|
||||||
|
# still authenticates -- the token stays in PRIV_CRED, and
|
||||||
|
# .git/config learns only where to look for it.
|
||||||
|
if [ -n "$p_cred" ] && [ -d "$PRIV_SRC/.git" ]; then
|
||||||
|
git -C "$PRIV_SRC" config credential.helper \
|
||||||
|
"store --file='$PRIV_CRED'" 2>/dev/null || :
|
||||||
|
fi
|
||||||
|
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
|
||||||
|
|
||||||
# The clone lands with the caller's umask, which on a stock Ubuntu
|
# The clone lands with the caller's umask, which on a stock Ubuntu
|
||||||
# is 022 -- world-readable. This tree holds ssh config, accepted
|
# is 022 -- world-readable. This tree holds ssh config, accepted
|
||||||
# keys and machine identity, and its .git/config stores the
|
# keys and machine identity. On a shared or multi-user box that is
|
||||||
# credential-bearing remote URL in plain text. On a shared or
|
# readable by anyone with an account.
|
||||||
# multi-user box that is readable by anyone with an account.
|
|
||||||
[ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || :
|
[ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || :
|
||||||
else err "the blob carried no PRIVATE_REPO_URL"; fi
|
else err "the blob carried no PRIVATE_REPO_URL"; fi
|
||||||
fi
|
fi
|
||||||
unset p_repo
|
unset p_repo p_cred p_clean
|
||||||
trap - EXIT INT TERM
|
trap - EXIT INT TERM
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user