GitHub Advisoryadvisory · PyPI
GHSA-8mcc-hrx5-hvxc GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination
HIGH
Official page ↗
- Aliases
- CVE-2026-78677
- Ecosystem
- PyPI
- Packages
- GitPython
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
- Weaknesses
- CWE-22, CWE-73
- Reviewed
- 2026-09-08
Affected packages
• PyPI GitPython: < 3.1.59 → fixed in 3.1.59
Description
• 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=`, which redirects the repository's entire `.git` metadata directory to an arbitrary, caller-controlled filesystem path, leaving only a gitlink text file (`gitdir: `) 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=` and GitPython executes `git clone -v --separate-git-dir= -- ` 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` specificall…
Related
Alias · CVE (1)
- CVE-2026-78677 GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories… vulnerability
References
- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc web
- https://nvd.nist.gov/vuln/detail/CVE-2026-78677 advisory
- https://github.com/gitpython-developers/GitPython/pull/2210 web
- https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d web
- https://github.com/gitpython-developers/GitPython package
- https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59 web
- https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml web
- https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir web