Résumé

GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination

Détails de l’avis

  • CWE: CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the "escapes intended base directory" sense)
  • Affected component: git/repo/base.py, Repo.unsafe_git_clone_options (class attribute, lines 153-165) and Repo._clone() (lines 1477-1520), reached via the public Repo.clone_from() (line 1626) and Repo.clone() (line 1567) APIs.
  • Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)

Reachability

Repo.clone_from(url, to_path, **kwargs) (and Repo.clone()) forward arbitrary keyword arguments to the underlying git clone invocation. Before forwarding, GitPython builds a candidate option list from the kwargs (Git._option_candidates) and checks it against a denylist, Repo.unsafe_git_clone_options, via Git.check_unsafe_options() — unless the caller passes allow_unsafe_options=True. This denylist mechanism is exactly the guard that the last ~16 published GHSAs against this repo (2026-07-12 → 2026-08-05) have repeatedly found incomplete or bypassable for other options (--template, --upload-pack, --config, --exec, --output, --index-output, --pathspec-from-file, etc.).

git clone also accepts --separate-git-dir=<path>, which redirects the repository's entire .git metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (gitdir: <path>) at the intended destination. This is the exact same primitive already recognized as unsafe by GitPython's own code: Repo.unsafe_git_init_options (line 145-150) blocks --separate-git-dir for Repo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". The Repo._clone()/clone()/clone_from() docstring (line 1450-1452) is even more explicit:

:param allow_unsafe_options:
    Allow unsafe options to be used, such as ``--template`` and
    ``--separate-git-dir``.

i.e. the maintainers' own documentation states that allow_unsafe_options=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafe_git_clone_options does not contain it:

unsafe_git_clone_options = [
    "--upload-pack",
    "-u",
    "--config",
    "-c",
    "--template",
    "--bundle-uri",
]

So any application that forwards a separate_git_dir (or separate-git-dir) kwarg into Repo.clone_from() / Repo.clone() — e.g. a CI/build service, a Git-hosting proxy, or any tool that exposes a subset of clone options to a client, the exact threat model already accepted for the sibling --template/--upload-pack/--config entries in this same list — gets no protection at all for --separate-git-dir, even with the default allow_unsafe_options=False.

Root cause

Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafe_git_init_options correctly lists --separate-git-dir; unsafe_git_clone_options, covering the same option on a different git subcommand that also accepts it, does not — despite the function's own docstring claiming otherwise. This is the same "denylist omits an equally-dangerous sibling option" pattern already responsible for GHSA-539m-9xh6-q6rr (archive denylist missing --add-file/--add-virtual-file) and GHSA-6p8h-3wgx-97gf (clone denylist missing --template, since fixed).

Exploit path

  1. Attacker-controlled input reaches a separate_git_dir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clone_from() / Repo.clone() by the host application, with allow_unsafe_options left at its default False.
  2. Git._option_candidates() renders this as --separate-git-dir and Git.check_unsafe_options() checks it against Repo.unsafe_git_clone_options — no match, no UnsafeOptionError raised.
  3. Git.transform_kwargs() renders the same kwarg into the real command line as --separate-git-dir=<attacker path> and GitPython executes git clone -v --separate-git-dir=<attacker path> -- <url> <dest> via subprocess (no shell).
  4. git itself creates the full repository metadata tree (config, description, HEAD, hooks/, index, objects/, refs/, packed-refs, logs/) at the attacker-specified path — which can be any path outside the intended clone destination that the process has permission to create — and leaves a gitlink file at the intended destination pointing to it.

Impact

Arbitrary directory/file creation at a path fully controlled by the attacker (bounded only by filesystem permissions of the process running GitPython), matching the impact class of the already-published, High-severity GHSA-hmq2-w58f-27jc ("Arbitrary Git Repository Creation Outside the Working Tree", CVSS 8.2). Concretely:

  • Planting a git repository structure (including a hooks/ directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to.
  • If the attacker-chosen path collides with an existing directory the process can write into (e.g. another repository's .git, a shared cache path, a predictable temp location), the clone silently populates/overwrites config, HEAD, hooks/*, refs/*, packed-refs, and index there — an integrity violation of a resource outside the intended destination.
  • Combined with any later operation that runs git against that redirected/colliding directory (common in CI/build systems that reuse or predict working-directory layouts), this can escalate to hook execution, matching the RCE class already accepted for --template in GHSA-9rj7-rf2p-w77r.

Preconditions

  • The calling application forwards a caller-influenced value into a separate_git_dir kwarg of Repo.clone_from()/Repo.clone() (or into the multi_options list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allow_unsafe_options=True intentionally. This is the identical trust model GitPython's own denylist already defends for --template/--upload-pack/--config/--bundle-uri on the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted.
  • No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.

Evidence

  • git/repo/base.py:145-151 — unsafe_git_init_options includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path".
  • git/repo/base.py:153-165 — unsafe_git_clone_options (the list actually enforced on _clone) does not include "--separate-git-dir".
  • git/repo/base.py:1450-1452 — docstring of clone_from/clone explicitly documents --separate-git-dir as one of the options allow_unsafe_options is supposed to gate.
  • git/repo/base.py:1495-1518 — _clone() special-cases separate_git_dir only to Git.polish_url() it (path normalization for URL-like values), then runs it through Git.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options) — which, per the list above, does not flag it.
  • PoC (gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the real git clone subprocess unguarded and creates a full git directory outside the destination path, with allow_unsafe_options at its default False.

False-positive check (adversarial re-read)

  • Is there a value-level check that would still stop this? No — check_unsafe_options only inspects option names (via _canonicalize_option_name) against the denylist; it performs no filesystem/path validation on separate_git_dir's value, and no other guard in _clone() touches this kwarg besides the Git.polish_url() normalization (which does not reject arbitrary paths).
  • **Is --separate-git-dir perhaps a no-op or safely sandboxed fo

Références