[HIGH] Repo.clone_from()/Repo.clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the clone destination
- 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) — i.e. the version that already incorporates the fixes for all 26 currently-published GitPython GHSAs, including the most recent (GHSA-hh9p-6wh2-4mfc, GHSA-9rj7-rf2p-w77r, both 2026-08-04/05).
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
- 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.
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.
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).
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)
#!/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", "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("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()
[HIGH]
Repo.clone_from()/Repo.clone()omit--separate-git-dirfromunsafe_git_clone_options, enabling arbitrary git-directory creation outside the clone destinationgit/repo/base.py,Repo.unsafe_git_clone_options(class attribute, lines 153-165) andRepo._clone()(lines 1477-1520), reached via the publicRepo.clone_from()(line 1626) andRepo.clone()(line 1567) APIs.9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58) — i.e. the version that already incorporates the fixes for all 26 currently-published GitPython GHSAs, including the most recent (GHSA-hh9p-6wh2-4mfc,GHSA-9rj7-rf2p-w77r, both 2026-08-04/05).Reachability
Repo.clone_from(url, to_path, **kwargs)(andRepo.clone()) forward arbitrary keyword arguments to the underlyinggit cloneinvocation. 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, viaGit.check_unsafe_options()— unless the caller passesallow_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 clonealso accepts--separate-git-dir=<path>, which redirects the repository's entire.gitmetadata 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-dirforRepo.init(), with the comment "Redirects the repository metadata to a caller-controlled path". TheRepo._clone()/clone()/clone_from()docstring (line 1450-1452) is even more explicit:i.e. the maintainers' own documentation states that
allow_unsafe_options=False(the default) is supposed to block--separate-git-dirfor clone. ButRepo.unsafe_git_clone_optionsdoes not contain it:So any application that forwards a
separate_git_dir(orseparate-git-dir) kwarg intoRepo.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/--configentries in this same list — gets no protection at all for--separate-git-dir, even with the defaultallow_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_optionscorrectly 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 forGHSA-539m-9xh6-q6rr(archivedenylist missing--add-file/--add-virtual-file) andGHSA-6p8h-3wgx-97gf(clonedenylist missing--template, since fixed).Exploit path
separate_git_dir=...(or equivalently"separate-git-dir") keyword argument passed intoRepo.clone_from()/Repo.clone()by the host application, withallow_unsafe_optionsleft at its defaultFalse.Git._option_candidates()renders this as--separate-git-dirandGit.check_unsafe_options()checks it againstRepo.unsafe_git_clone_options— no match, noUnsafeOptionErrorraised.Git.transform_kwargs()renders the same kwarg into the real command line as--separate-git-dir=<attacker path>and GitPython executesgit clone -v --separate-git-dir=<attacker path> -- <url> <dest>viasubprocess(no shell).gititself 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:hooks/directory) at an attacker-chosen location outside the sandboxed clone destination the calling application intended to confine the operation to..git, a shared cache path, a predictable temp location), the clone silently populates/overwritesconfig,HEAD,hooks/*,refs/*,packed-refs, andindexthere — an integrity violation of a resource outside the intended destination.gitagainst 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--templateinGHSA-9rj7-rf2p-w77r.Preconditions
separate_git_dirkwarg ofRepo.clone_from()/Repo.clone()(or into themulti_optionslist as a raw--separate-git-dir=...token) without itself validating/rejecting it, and does not passallow_unsafe_options=Trueintentionally. This is the identical trust model GitPython's own denylist already defends for--template/--upload-pack/--config/--bundle-urion the very same code path — i.e. this option was clearly meant to be covered by the same guard and was simply omitted.Evidence
git/repo/base.py:145-151—unsafe_git_init_optionsincludes"--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 ofclone_from/cloneexplicitly documents--separate-git-diras one of the optionsallow_unsafe_optionsis supposed to gate.git/repo/base.py:1495-1518—_clone()special-casesseparate_git_dironly toGit.polish_url()it (path normalization for URL-like values), then runs it throughGit.check_unsafe_options(options=..., unsafe_options=cls.unsafe_git_clone_options)— which, per the list above, does not flag it.gitpython-001-poc.py, embedded below) run against this exact checkout confirms the option reaches the realgit clonesubprocess unguarded and creates a full git directory outside the destination path, withallow_unsafe_optionsat its defaultFalse.False-positive check (adversarial re-read)
check_unsafe_optionsonly inspects option names (via_canonicalize_option_name) against the denylist; it performs no filesystem/path validation onseparate_git_dir's value, and no other guard in_clone()touches this kwarg besides theGit.polish_url()normalization (which does not reject arbitrary paths).--separate-git-dirperhaps a no-op or safely sandboxed forclonespecifically (unlikeinit)? No — confirmed empirically: the option reaches the realgitbinary unmodified and git honors it exactly as documented, writing the full metadata tree to the given path._known-advisories.json(Filter 0):GHSA-9rj7-rf2p-w77rcovers--templateinRepo.init;GHSA-6p8h-3wgx-97gfcovers--templatein clone (already fixed, present inunsafe_git_clone_options);GHSA-hmq2-w58f-27jccovers arbitrary repo creation via unvalidated.gitmodulessubmodule names (a different code path —Submodule, notRepo.clone_from()kwargs). None reference--separate-git-diron the clone path. This is a distinct, currently-unpatched gap.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.Remediation
Add
"--separate-git-dir"(and its-alias if git ever adds one — currently there is none) toRepo.unsafe_git_clone_optionsingit/repo/base.py, matchingunsafe_git_init_options. SinceRepo._clone()already special-casesseparate_git_dirforGit.polish_url()normalization, the fix is a one-line addition to the existing list, consistent with howGHSA-6p8h-3wgx-97gfadded--templateto 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)