GHSA-8mcc-hrx5-hvxc: Path Traversal
- 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.unsafegitcloneoptions (class attribute, lines 153-165) and Repo.clone() (lines 1477-1520), reached via the public Repo.clonefrom() (line 1626) and Repo.clone() (line 1567) APIs. - Affected version: GitPython at HEAD (9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION 3.1.58)
Reachability Repo.clonefrom(url, topath, 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.optioncandidates) and checks it against a denylist, Repo.unsafegitcloneoptions, via Git.checkunsafeoptions() — unless the caller passes allowunsafeoptions=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.unsafegitinitoptions (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()/clonefrom() docstring (line 1450-1452) is even more explicit:
:param allowunsafeoptions: Allow unsafe options to be used, such as --template and --separate-git-dir.
i.e. the maintainers' own documentation states that allowunsafeoptions=False (the default) is supposed to block --separate-git-dir for clone. But Repo.unsafegitcloneoptions does not contain it:
python unsafegitcloneoptions = [ "--upload-pack", "-u", "--config", "-c", "--template", "--bundle-uri", ]
So any application that forwards a separategitdir (or separate-git-dir) kwarg into Repo.clonefrom() / 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 allowunsafeoptions=False.
Root cause Parity gap between two sibling denylists that guard the same underlying primitive (arbitrary redirection of git metadata storage): unsafegitinitoptions correctly lists --separate-git-dir; unsafegitcloneoptions, 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 separategitdir=... (or equivalently "separate-git-dir") keyword argument passed into Repo.clonefrom() / Repo.clone() by the host application, with allowunsafeoptions left at its default False. 2. Git.optioncandidates() renders this as --separate-git-dir and Git.checkunsafeoptions() checks it against Repo.unsafegitcloneoptions — no match, no UnsafeOptionError raised. 3. Git.transformkwargs() 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 separategitdir kwarg of Repo.clonefrom()/Repo.clone() (or into the multioptions list as a raw --separate-git-dir=... token) without itself validating/rejecting it, and does not pass allowunsafeoptions=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 — unsafegitinitoptions includes "--separate-git-dir" with the comment "Redirects the repository metadata to a caller-controlled path". - git/repo/base.py:153-165 — unsafegitcloneoptions (the list actually enforced on clone) does not include "--separate-git-dir". - git/repo/base.py:1450-1452 — docstring of clonefrom/clone explicitly documents --separate-git-dir as one of the options allowunsafeoptions is supposed to gate. - git/repo/base.py:1495-1518 — clone() special-cases separategitdir only to Git.polishurl() it (path normalization for URL-like values), then runs it through Git.checkunsafeoptions(options=..., unsafeoptions=cls.unsafegitcloneoptions) — 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 allowunsafeoptions at its default False.
False-positive check (adversarial re-read) - Is there a value-level check that would still stop this? No — checkunsafeoptions only inspects option names (via canonicalizeoptionname) against the denylist; it performs no filesystem/path validation on separategitdir's value, and no other guard in clone() touches this kwarg besides the Git.polishurl() 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 unsafegitcloneoptions); GHSA-hmq2-w58f-27jc covers arbitrary repo creation via unvalidated .gitmodules submodule names (a different code path — Submodule, not Repo.clonefrom() 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 clonefrom/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.unsafegitcloneoptions in git/repo/base.py, matching unsafegitinitoptions. Since Repo.clone() already special-cases separategitdir for Git.polishurl() 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.clonefrom(separategitdir=...) is not in unsafegitcloneoptions, 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 allowunsafeoptions 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") sentineldir = os.path.join(workdir, "OUTSIDESENTINEL") targetgitdir = os.path.join(sentineldir, "redirected.git")
for p in (src, dest, sentineldir): os.makedirs(p, existok=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", "test@example.com"], 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("unsafegitcloneoptions =", git.Repo.unsafegitcloneoptions) assert "--separate-git-dir" not in git.Repo.unsafegitcloneoptions, ( "guard now includes --separate-git-dir; PoC no longer applicable, target patched" )
try: repo = git.Repo.clonefrom(src, dest, separategitdir=targetgitdir) except git.exc.UnsafeOptionError as e: print("NOT VULNERABLE: blocked by UnsafeOptionError:", e) sys.exit(1)
wroteoutside = os.path.isdir(os.path.join(targetgitdir, "hooks")) and os.path.isfile( os.path.join(targetgitdir, "config") ) gitlinkpointsoutside = False with open(os.path.join(dest, ".git")) as f: gitlink = f.read().strip() gitlinkpointsoutside = targetgitdir in gitlink
print("repo.gitdir =", repo.gitdir) print("wrote git directory outside dest (sentinel) =", wroteoutside) print("dest/.git gitlink points outside dest =", gitlinkpointsoutside)
if wroteoutside and gitlinkpointsoutside: print("VULNERABLE: git directory created at attacker-controlled path " f"outside the clone destination: {targetgitdir}") sys.exit(0) else: print("NOT VULNERABLE: sentinel not observed") sys.exit(1)
if name == "main": main()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/GitPythonto a version that resolves this vulnerability.Fixed in 3.1.59 - Upgrade
Upgrade to a fixed release to a version that resolves this vulnerability.
Fixed in 3.1.58Patch 9729ed3b948f2bde09f1f188c5311e172212b67e - Configuration
In git/repo/base.py, add "--separate-git-dir" to the denylist Repo.unsafe_git_clone_options (currently it omits it, while git/repo/base.py’s unsafe_git_init_options includes it and the docstring says allow_unsafe_options=False is supposed to block it for clone_from/clone).
GitPython Repo.clone_from/Repo.clone (git/repo/base.py) Repo.unsafe_git_clone_options = Include "--separate-git-dir" - Compensating control
Do not pass caller-influenced values for the "separate_git_dir" kwarg (and do not include raw "--separate-git-dir=..." tokens in the multi_options list) to Repo.clone_from()/Repo.clone() unless allow_unsafe_options=True is intentionally used.
Event History
Frequently Asked Questions
Which application code paths can reach the affected behavior?
The affected behavior is reached through the public Repo.clone_from(url, to_path, **kwargs) and Repo.clone() APIs. Both paths forward keyword arguments toward the underlying git clone invocation.
What would an attacker need to control?
An attacker would need a way to influence keyword arguments passed to Repo.clone_from() or Repo.clone(). The clone-option denylist is skipped entirely if the caller supplies allow_unsafe_options=True.
What can be done if updating is not immediately possible?
Do not pass attacker-controlled keyword arguments to the clone APIs, and avoid setting allow_unsafe_options=True. Ensure callers cannot select or inject git clone options through request parameters, configuration, or other untrusted input.
How can I identify the specifically reported affected source state?
The advisory identifies GitPython HEAD commit 9729ed3b948f2bde09f1f188c5311e172212b67e, dated 2026-08-05, with VERSION 3.1.58. The provided data does not specify a broader affected-version range or a fixed version.