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
.gitsuffix 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
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
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.