GHSA-8mcc-hrx5-hvxc
Dashboard / Vulnerabilities / GHSA-8mcc-hrx5-hvxc
GHSA-8mcc-hrx5-hvxc
Summary: GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination
Details: - **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**: ```python 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 for `clone` specifically (unlike `init`)?** No — confirmed empirically: the option reaches the real `git` binary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path. - **Could this be the exact bug already covered by one of the 26 published GHSAs?** Checked all 26 entries in `_known-advisories.json` (Filter 0): `GHSA-9rj7-rf2p-w77r` covers `--template` in `Repo.init`; `GHSA-6p8h-3wgx-97gf` covers `--template` in clone (already fixed, present in `unsafe_git_clone_options`); `GHSA-hmq2-w58f-27jc` covers arbitrary repo creation via unvalidated **`.gitmodules` submodule names** (a different code path — `Submodule`, not `Repo.clone_from()` kwargs). None reference `--separate-git-dir` on the clone path. This is a distinct, currently-unpatched gap. - **Does this require an unrealistic precondition?** The precondition (host app forwards a kwarg into `clone_from`/`clone`) is identical to the precondition already accepted by the maintainers for the sibling entries in the same list (`--template`, `--upload-pack`, `--config`, `--bundle-uri`) — i.e. it is the same threat model the guard exists to cover, just missing one entry. - Verdict: no concrete blocker found. **CONFIRMED.** ## Remediation Add `"--separate-git-dir"` (and its `-` alias if git ever adds one — currently there is none) to `Repo.unsafe_git_clone_options` in `git/repo/base.py`, matching `unsafe_git_init_options`. Since `Repo._clone()` already special-cases `separate_git_dir` for `Git.polish_url()` normalization, the fix is a one-line addition to the existing list, consistent with how `GHSA-6p8h-3wgx-97gf` added `--template` to the same list. ## Confidence High. Root cause is a one-line, unambiguous omission the maintainers' own docstring contradicts; PoC reproduces cleanly and deterministically against the current HEAD; no plausible false-positive path found. ## Proof-of-Concept source (`gitpython-001-poc.py`) ```python #!/usr/bin/env python3 """ GITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in unsafe_git_clone_options, so it reaches `git clone` unguarded and writes a full git directory (config, hooks/, objects/, refs/, ...) to an attacker-controlled path OUTSIDE the intended destination directory, with allow_unsafe_options left at its default of False. Run against the GitPython source tree under test, e.g.: PYTHONPATH="<repo>:<repo>/gitdb:<repo>/smmap" python3 gitpython-001-poc.py <workdir> Benign: only writes/reads inside the given workdir. No destructive/exfiltrating payload. Exits non-zero and prints "NOT VULNERABLE" if the guard blocks the option or the write does not escape the destination directory. """ import os import sys import subprocess def main(): workdir = sys.argv[1] if len(sys.argv) > 1 else "/tmp/gitpython-001-poc" src = os.path.join(workdir, "src") dest = os.path.join(workdir, "dest") sentinel_dir = os.path.join(workdir, "OUTSIDE_SENTINEL") target_gitdir = os.path.join(sentinel_dir, "redirected.git") for p in (src, dest, sentinel_dir): os.makedirs(p, exist_ok=True) # Minimal benign source repo to clone from. subprocess.run(["git", "init", "-q", "-b", "main", src], check=True) subprocess.run(["git", "-C", src, "config", "user.email", "[email protected]"], check=True) subprocess.run(["git", "-C", src, "config", "user.name", "Test"], check=True) with open(os.path.join(src, "file.txt"), "w") as f: f.write("hello\n") subprocess.run(["git", "-C", src, "add", "file.txt"], check=True) subprocess.run(["git", "-C", src, "commit", "-q", "-m", "init"], check=True) import git # gitpython under test print("unsafe_git_clone_options =", git.Repo.unsafe_git_clone_options) assert "--separate-git-dir" not in git.Repo.unsafe_git_clone_options, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" ) try: repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1) wrote_outside = os.path.isdir(os.path.join(target_gitdir, "hooks")) and os.path.isfile( os.path.join(target_gitdir, "config") ) gitlink_points_outside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlink_points_outside = target_gitdir in gitlink print("repo.git_dir =", repo.git_dir) print("wrote git directory outside dest (sentinel) =", wrote_outside) print("dest/.git gitlink points outside dest =", gitlink_points_outside) if wrote_outside and gitlink_points_outside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {target_gitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1) if __name__ == "__main__": main() ```
References: https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc, https://nvd.nist.gov/vuln/detail/CVE-2026-78677, https://github.com/gitpython-developers/GitPython/pull/2210, https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d, https://github.com/gitpython-developers/GitPython, https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59, https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml, https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir
Affected packages
Package
Name: gitpython
Purl: pkg:pypi/gitpython
Affected ranges
Type: ECOSYSTEM
Events:
