git ls-remoteでのコマンドインジェクション

git ls-remoteのリポジトリ引数を通じたオプション・コマンドインジェクション

説明

利用者の入力を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のオプションとして解釈される可能性があります。変更後の2つのルートは登録済みのリモートだけを選び、--の後に渡します。継承したオブジェクトのプロパティもリポジトリのキーとして認めません。例のURLと実行環境は実際に承認された値に置き換え、リポジトリへのアクセス権限を別途確認してください。

参考資料