vercel/turborepo

Command injection via unescaped git ref in turbo-ignore

Summary

  • Context: The turbo-ignore package is a CLI tool used in CI/CD pipelines (especially on Vercel) to determine if a build should proceed based on whether a workspace or its dependencies have changed.

  • Bug: The getComparison.ts file contains a command injection vulnerability where unescaped user input is interpolated directly into shell commands executed via execSync and exec.

  • Actual vs. expected: The code uses string interpolation to build shell commands with user-controlled input (the fallback CLI argument and VERCEL_GIT_PREVIOUS_SHA environment variable) without any sanitization or escaping. The expected behavior is to safely validate git references and use them in commands without allowing arbitrary command execution.

  • Impact: An attacker who can control the fallback argument or the VERCEL_GIT_PREVIOUS_SHA environment variable can execute arbitrary shell commands with the privileges of the CI/CD process, potentially leading to credential theft, code injection, or supply chain compromise.

Code with bug

In getComparison.ts

export function validateSHAExists(ref: string): boolean {
  try {
    execSync(`git cat-file -t ${ref}`, {
      stdio: "ignore",
    });
    // <-- BUG 🔴 Unescaped ref in shell command

    return true;
  } catch (e) {
    return false;
  }
}

In ignore.ts

const command =
  `npx -y ${turbo} run ${task} ` +
  `--filter="${workspace}...[${comparison.ref}]" ` +
  `--dry=json`; 
// <-- BUG 🔴 comparison.ref contains user input

Example

  • Run:

npx turbo-ignore --fallback='HEAD]"; touch /tmp/pwned; echo "'
  • Flow:

    • The fallback value is used as comparison.ref without sanitization.

    • The command constructed in ignore.ts becomes:

      npx -y turbo run build --filter="my-app...[HEAD]
    • The shell interprets this as multiple commands, executing touch /tmp/pwned.

  • Result: /tmp/pwned is created, demonstrating arbitrary command execution via the fallback path.

Recommended fix

  • Use non-shell APIs with argument arrays to avoid interpolation:

// getComparison.ts
import { execFileSync } from "node:child_process";

export function validateSHAExists(ref: string): boolean {
  try {
    execFileSync(
      "git",
      ["cat-file", "-t", ref],
      { stdio: "ignore" },
    );

    return true;
  } catch (e) {
    return false;
  }
}


// ignore.ts
import { execFile } from "node:child_process";

const args = [
  "-y",
  turbo,
  "run",
  task,
  `--filter=${workspace}...[${comparison.ref}]`,
  "--dry=json",
];

execFile("npx", args, execOptions, (err, stdout) => {
  // ...
});

Alternatively, strictly validate refs against an allowlist pattern before use.