CVE-2026-78677: GitPython clone_from() Path Traversal via --separate-git-dir
GitPython's clone_from() and clone() fail to block the --separate-git-dir option, letting an attacker redirect the entire .git metadata directory to any path on disk outside the intended clone…

The problem
GitPython maintains a denylist, Repo.unsafe_git_clone_options, to block dangerous git clone flags when allow_unsafe_options is left at its default False. In versions up to and including 3.1.58, --separate-git-dir is absent from that list.
git clone --separate-git-dir=<path> writes the full repository metadata tree (config, HEAD, hooks/, objects/, refs/, etc.) to an arbitrary caller-controlled path, leaving only a gitlink text file at the intended destination. Any application that forwards an attacker-influenced separate_git_dir keyword argument into Repo.clone_from() or Repo.clone() gets no protection, even with the default safe settings.
Proof of concept
A working proof-of-concept for CVE-2026-78677 in GitPython, with the exact payload below.
import git
# Attacker controls `target_gitdir` -- any writable path outside the sandbox.
git.Repo.clone_from(
"https://github.com/example/repo.git",
"/safe/sandbox/dest",
separate_git_dir="/tmp/attacker_controlled/redirected.git"
# allow_unsafe_options is False by default -- no UnsafeOptionError is raised
)
# Result: full git directory (hooks/, config, objects/, refs/, ...) created at
# /tmp/attacker_controlled/redirected.git, outside the intended destination.
# /safe/sandbox/dest/.git becomes a gitlink: "gitdir: /tmp/attacker_controlled/redirected.git"The root cause is a parity gap between two sibling denylists in git/repo/base.py. unsafe_git_init_options (lines 145-151) correctly lists --separate-git-dir with the comment 'Redirects the repository metadata to a caller-controlled path'. unsafe_git_clone_options (lines 153-165), which guards the same option on git clone, simply omits it.
The clone_from() docstring even names --separate-git-dir as one of the options allow_unsafe_options is supposed to gate, confirming the maintainers intended it to be blocked. Git.check_unsafe_options() checks only option names against the denylist, so the missing entry means the option passes through unchallenged and reaches the real git subprocess as --separate-git-dir=<attacker path>.
This is the same 'denylist omits a dangerous sibling option' pattern behind several prior GitPython advisories (e.g., GHSA-6p8h-3wgx-97gf for --template on clone). The fix is one line: adding "--separate-git-dir" to unsafe_git_clone_options, matching the already-correct unsafe_git_init_options.
The fix
Upgrade to GitPython 3.1.59 or later. The patch (commit b68afff45af0f49e79a3e2d2162018986b37ad5d, PR #2210) adds "--separate-git-dir" to Repo.unsafe_git_clone_options in git/repo/base.py, making check_unsafe_options() raise UnsafeOptionError if the option is passed without allow_unsafe_options=True.
If an immediate upgrade is not possible, audit all call sites of Repo.clone_from() and Repo.clone() and reject any caller-supplied value for the separate_git_dir keyword argument at the application layer.
Related research
- high · 8.2GitPython Submodule Name Path Traversal to Arbitrary Git Repository Creation
- high · 8.4CVE-2026-78675CVE-2026-78675: GitPython Local File Content Disclosure via .gitmodules [include] Directive
- critical · 9.8CVE-2026-78676CVE-2026-78676: GitPython Multi-line Config Value RCE via Unsafe Re-serialization
- high · 8.8GitPython unsafe-option guard bypass via split_single_char_options=False short-option smuggling