fix: prevent command injection via untrusted git branch names (#1791)

* fix: prevent command injection via untrusted git branch names

Untrusted git branch names (e.g. a fork PR head branch) were substituted
unescaped into executor prepare scripts and run via `eval` in a shell,
yielding command injection (CRITICAL RCE) in the Docker/Sprites/remote
executors. git check-ref-format permits `;`, `|`, `$`, backticks and
`()`, so a branch like `$(touch pwned)` or `main;touch pwned` executed.

- scriptengine: shell-single-quote every data placeholder that lands in
  shell text (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch). Script
  fragments (setup_script, git.identity_setup, github.auth_setup,
  kandev.*) are left untouched.
- default_scripts: wrap each data placeholder in single quotes in the
  postlude, clone lines, and the Sprites echo (double quotes still allow
  $(...) substitution). `'<escaped>'` round-trips to the literal.
- orchestrator: defense-in-depth — reject a PR head branch that fails
  securityutil.IsValidBranchName before it reaches CheckoutBranch, while
  still creating the review task without a checkout.
- tests: PoC-derived regression asserting the injected command no longer
  runs through the real sh -c/eval sink, provider escaping unit tests,
  and ingestion-layer validation coverage.

* fix: harden branch-name escaping for legacy stored prepare scripts

Automated review (Codex, CodeRabbit) flagged that shell-single-quoting the
value while relying on the templates to add the surrounding quotes only
protects the templates we ship. Profiles snapshot the default prepare
script at creation time, so an OLD stored script referencing a placeholder
bare (e.g. `git clone --depth=1 --branch {{repository.branch}} ...`) still
resolved with an unquoted, injectable branch value.

- scriptengine: add shellQuote, which emits a fully self-contained
  single-quoted shell token (surrounding quotes included). Every data
  placeholder (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch) now carries
  its own quoting, so a bare `{{placeholder}}` in any template — including
  legacy stored scripts — is safe. shellSingleQuote is kept for the git
  identity fragment, which supplies its own quotes.
- default_scripts: reference data placeholders bare (the value self-quotes);
  the Sprites echo becomes printf with the values as separate args so a
  hostile URL/branch can't expand inside a double-quoted string.
- tests: assert providers emit fully-quoted tokens that round-trip through a
  real shell; cover base_path/path/repository.path/name/ssh_url; add a
  regression proving a legacy stored script with bare placeholders no longer
  executes injected commands.

* test: strengthen branch-injection regression assertions

Address Greptile review feedback on the new tests.

- assertNoUnquotedPayload now tracks real single-quote state char-by-char
  instead of "some quote before and after", so a payload in an unquoted
  context on a line that happens to contain other quotes is caught.
- Drop the unused wantHasRepoRow table field; repo-row presence is already
  asserted via len(got) != 1.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-07-19 10:33:29 +01:00
committed by GitHub
parent 410b1dd4d1
commit 8f23b8a19e
9 changed files with 505 additions and 39 deletions
@@ -43,17 +43,25 @@ func KandevBranchCheckoutPostlude() string {
# branch off origin, and only as a last resort create the branch off HEAD.
# The previous "git checkout -B feature origin/feature" form was destructive
# for the resume case — overwriting local commits with the remote tip.
#
# SECURITY: the data placeholders below are referenced BARE (no surrounding
# quotes). The scriptengine providers substitute a fully single-quoted,
# self-contained shell token (see scriptengine.shellQuote), so a branch name
# containing shell metacharacters (e.g. "$(...)", backticks, ";") resolves to a
# quoted literal that cannot inject commands. Do NOT wrap these in double
# quotes — double quotes would re-expose $(...) command substitution. Do NOT
# assume they are unquoted data either; the value carries its own quoting.
(
if [ -d "{{workspace.path}}/.git" ] \
&& [ -n "{{worktree.branch}}" ] \
&& [ "{{worktree.branch}}" != "{{repository.branch}}" ]; then
cd "{{workspace.path}}" || exit 0
if git rev-parse --verify "{{worktree.branch}}" >/dev/null 2>&1; then
git checkout "{{worktree.branch}}"
elif git fetch --depth=1 origin "{{worktree.branch}}" 2>/dev/null; then
git checkout -b "{{worktree.branch}}" "origin/{{worktree.branch}}"
if [ -d {{workspace.path}}/.git ] \
&& [ -n {{worktree.branch}} ] \
&& [ {{worktree.branch}} != {{repository.branch}} ]; then
cd {{workspace.path}} || exit 0
if git rev-parse --verify {{worktree.branch}} >/dev/null 2>&1; then
git checkout {{worktree.branch}}
elif git fetch --depth=1 origin {{worktree.branch}} 2>/dev/null; then
git checkout -b {{worktree.branch}} origin/{{worktree.branch}}
else
git checkout -b "{{worktree.branch}}"
git checkout -b {{worktree.branch}}
fi
fi
) || true
@@ -104,6 +112,10 @@ git config --global url."https://github.com/".insteadOf "ssh://git@github.com/"
# The kandev-managed feature-branch checkout is appended as an invariant
# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default
# so old profiles snapshotting this script and the postlude never disagree.
# SECURITY: the providers substitute fully single-quoted tokens (shellQuote) for
# repository.branch / repository.clone_url / workspace.path, so a hostile branch
# name or URL cannot break out of the git clone argument even though the
# placeholders are referenced bare here. Do not add double quotes around them.
git clone --depth=1 --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}
cd {{workspace.path}}
@@ -147,7 +159,11 @@ chmod +x /usr/local/bin/pnpm
# The kandev-managed feature-branch checkout is appended as an invariant
# postlude (see KandevBranchCheckoutPostlude) — keep it out of the default
# so old profiles snapshotting this script and the postlude never disagree.
echo "Cloning {{repository.clone_url}} (branch: {{repository.branch}})..."
# SECURITY: the providers substitute fully single-quoted tokens (shellQuote) for
# these placeholders. printf takes them as separate arguments, NOT inside a
# double-quoted string (which would re-expand a hostile URL/branch). Do not
# reintroduce an echo of these placeholders inside a double-quoted string.
printf 'Cloning %s (branch: %s)...\n' {{repository.clone_url}} {{repository.branch}}
git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}}
cd {{workspace.path}}