From 63bed1d32de8032dd20b94d7e05846d6da0e0110 Mon Sep 17 00:00:00 2001 From: bcherb2 Date: Mon, 17 Aug 2026 20:21:39 -0400 Subject: [PATCH] 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//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. --- .tests/test.sh | 31 ++++++++++++++- dot_local/bin/executable_dotup | 73 ++++++++++++++++++++++++++++------ 2 files changed, 90 insertions(+), 14 deletions(-) diff --git a/.tests/test.sh b/.tests/test.sh index 1aadec5..17c181c 100755 --- a/.tests/test.sh +++ b/.tests/test.sh @@ -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 # live token would be the worse leak of the pair. 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//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 # 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:-}" "$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" \ + " https://git.example.com/x.git" \ + "$(split 'https://git.example.com/x.git')" +is "…including scp-style ssh" \ + " 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"; } is "redact_url strips userinfo" "https://@git.example.com/x.git" \ "$(rd 'https://ben:deadbeefcafe@git.example.com/x.git')" diff --git a/dot_local/bin/executable_dotup b/dot_local/bin/executable_dotup index 9128229..9754786 100755 --- a/dot_local/bin/executable_dotup +++ b/dot_local/bin/executable_dotup @@ -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. 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//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() { rows=$(selected_private) [ -n "$rows" ] || return 0 @@ -775,7 +788,7 @@ cmd_private() { printf ' Password: ' >&2 stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2 # 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 # 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 [ -n "$p_repo" ]; then - # NOT `run`. It echoes its whole argv to stderr, and this argv ends - # in https://user:TOKEN@host/... -- which would put a live git - # credential into terminal scrollback, any `dotup 2>log`, and any - # agent or CI transcript capturing the run. Trace a redacted form - # and execute quietly. + # Split the credential out of the URL before anything executes. + # p_cred is the bare scheme://user:token@host that + # git-credential-store wants -- host only, no path. p_clean is the + # same URL with the userinfo removed. A remote with no userinfo + # (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")" if [ "$DRYRUN" -eq 1 ]; then 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 printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \ - "$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")" "$R" >&2 - chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_repo" \ + "$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")" "$R" >&2 + chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_clean" \ || err "private repo init failed" 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 # is 022 -- world-readable. This tree holds ssh config, accepted - # keys and machine identity, and its .git/config stores the - # credential-bearing remote URL in plain text. On a shared or - # multi-user box that is readable by anyone with an account. + # keys and machine identity. On a shared or multi-user box that is + # readable by anyone with an account. [ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || : else err "the blob carried no PRIVATE_REPO_URL"; fi fi - unset p_repo + unset p_repo p_cred p_clean trap - EXIT INT TERM }