설명
사용자 입력을 git ls-remote의 원격 인자로 직접 전달하면 -로 시작하는 값이 옵션으로 해석될 수 있습니다. execFile이나 spawn으로 셸을 피하더라도 Git 자체의 옵션·전송 방식 처리는 남습니다. --upload-pack 같은 옵션은 실행할 프로그램에 영향을 줄 수 있으며, 실제 명령 실행 여부와 위치는 저장소, 전송 방식과 Git 구성에 따라 달라집니다.
잠재적 영향
- 조작된 옵션이나 전송 방식으로 의도하지 않은 프로그램이 실행될 수 있습니다.
- 프로세스 권한으로 접근 가능한 토큰, 소스 코드나 설정이 노출될 수 있습니다.
- 승인하지 않은 호스트에 연결하거나 장시간 대기해 서비스 자원이 소모될 수 있습니다.
해결 방법
- 클라이언트에서는 저장소 식별자만 받고, 서버의 신뢰하는 구성에서 승인된 원격 URL로 매핑하세요.
- URL을 직접 받는 경우에도 승인된 목적지와 정확히 비교하세요. 프로토콜 접두사나
.git확장자만으로 목적지를 신뢰하지 마세요. ['ls-remote', '--', remote]처럼 옵션 끝을 명시하고 셸 없이 인자 배열을 전달하세요.- Git 실행 파일, 환경과 구성을 신뢰하는 값으로 관리하고, 네트워크 접근 범위와 실행 시간을 제한하세요.
--는 목적지의 신뢰성이나 전송 방식까지 검증하지 않습니다.
예시
변경 전
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") || "";
// 취약: 사용자 입력을 그대로 원격 인자로 전달
execFile("git", ["ls-remote", r], (err, stdout, stderr) => {
res.statusCode = 200;
res.end("done");
});
return;
}
res.end("ok");
});
server.listen(3000);
변경 후
javascript
const express = require("express");
const { execFile } = require("child_process");
const app = express();
// 사전 등록된 안전한 리모트만 허용
const REMOTE_MAP = Object.freeze({
repoA: "https://github.com/example/repoA.git",
repoB: "git@github.com:example/repoB.git",
});
// URL을 직접 받더라도 등록된 원격과 정확히 비교
function isSafeRemote(candidate) {
if (typeof candidate !== "string") return false;
if (/^-/u.test(candidate)) return false; // '-'로 시작 금지
if (/\s/u.test(candidate)) return false; // 공백/개행 금지
if (/(--upload-pack|^-u\b)/u.test(candidate)) return false; // 위험 옵션 차단
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");
}
// 옵션 경계 강제: '--' 뒤에 원격 인자를 둬서 옵션으로 해석되지 않도록 함
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);
변경 전에는 입력이 Git 옵션으로 해석될 수 있습니다. 변경 후의 두 경로는 등록된 원격만 선택하고 -- 뒤에 전달합니다. 객체의 상속 속성도 저장소 키로 허용하지 않습니다. 예시 URL과 실행 환경은 실제 승인된 값으로 구성하고, 저장소별 접근 권한을 별도로 확인하세요.