Command injection in git ls-remote

Option and command injection through a git ls-remote repository argument

Description

Passing user input directly as the repository argument to git ls-remote can cause a value beginning with - to be interpreted as an option. Avoiding a shell with execFile or spawn does not remove Git's own option and transport processing. Options such as --upload-pack can influence the program that runs; whether and where execution occurs depends on the repository, transport and Git configuration.

Potential impact

  • Manipulated options or transports may execute an unintended program.
  • Tokens, source code or configuration accessible to the process may be exposed.
  • Connections to unapproved hosts or long waits may consume service resources.

Remediation

  • Accept a repository identifier from the client and map it to an approved remote URL in trusted server configuration.
  • Even when accepting a URL, compare it with approved destinations. A protocol prefix or .git suffix does not establish trust.
  • Use an argument array without a shell, and end option parsing explicitly, as in ['ls-remote', '--', remote].
  • Protect the Git executable, environment and configuration, and limit network access and execution time. -- does not validate the destination or transport.

Examples

Before

javascript
const http = require("http");
const { execFile } = require("child_process");

const server = http.createServer((req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname === "/remote") {
    const r = url.searchParams.get("r") || "";
    // Pass user input directly as the repository argument
    execFile("git", ["ls-remote", r], (err, stdout, stderr) => {
      res.statusCode = 200;
      res.end("done");
    });
    return;
  }
  res.end("ok");
});

server.listen(3000);

After

javascript
const express = require("express");
const { execFile } = require("child_process");
const app = express();

// Allow only previously approved remotes
const REMOTE_MAP = Object.freeze({
  repoA: "https://github.com/example/repoA.git",
  repoB: "git@github.com:example/repoB.git",
});

// Even directly supplied URLs must match a registered remote
function isSafeRemote(candidate) {
  if (typeof candidate !== "string") return false;
  if (/^-/u.test(candidate)) return false; // Reject a leading '-'
  if (/\s/u.test(candidate)) return false; // Reject whitespace and newlines
  if (/(--upload-pack|^-u\b)/u.test(candidate)) return false; // Reject dangerous option strings
  return Object.values(REMOTE_MAP).includes(candidate);
}

app.get("/safe-ls-remote", (req, res) => {
  const key = String(req.query.key || "");
  const remote = Object.hasOwn(REMOTE_MAP, key) ? REMOTE_MAP[key] : undefined;
  if (!remote) {
    return res.status(400).send("invalid key");
  }

  // Put the repository after '--' to end option parsing
  execFile(
    "git",
    ["ls-remote", "--", remote],
    { timeout: 5000 },
    (err, stdout) => {
      if (err) return res.status(500).send("error");
      res.type("text/plain").send(stdout);
    }
  );
});

app.get("/validated-ls-remote", (req, res) => {
  const remote = String(req.query.remote || "");
  if (!isSafeRemote(remote)) return res.status(400).send("bad remote");
  execFile(
    "git",
    ["ls-remote", "--", remote],
    { timeout: 5000 },
    (err, stdout) => {
      if (err) return res.status(500).send("error");
      res.type("text/plain").send(stdout);
    }
  );
});

app.listen(3000);

The first example allows input to be interpreted as a Git option. Both routes in the second choose a registered remote and pass it after --. Inherited object properties are not accepted as repository keys. Replace example URLs and execution settings with approved values, and check repository access permissions separately.

References