{"id":"CVE-2026-78677","aliases":["GHSA-8mcc-hrx5-hvxc","PYSEC-2026-3787"],"title":"GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside …","summary":"GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination","severity":"high","cvss":7.5,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","vendor":"gitpython","product":"gitpython","ecosystem":"pip","affected":["gitpython < 3.1.59"],"patched":["gitpython 3.1.59"],"published":"2026-09-08","updated":"2026-09-08","sourceUpdated":"2026-09-08T19:00:06.914140693Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-8mcc-hrx5-hvxc","references":[{"url":"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-8mcc-hrx5-hvxc"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-78677"},{"url":"https://github.com/gitpython-developers/GitPython/pull/2210"},{"url":"https://github.com/gitpython-developers/GitPython/commit/b68afff45af0f49e79a3e2d2162018986b37ad5d"},{"url":"https://github.com/gitpython-developers/GitPython"},{"url":"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.59"},{"url":"https://github.com/pypa/advisory-database/tree/main/vulns/gitpython/PYSEC-2026-3787.yaml"},{"url":"https://www.vulncheck.com/advisories/gitpython-before-path-traversal-via-separate-git-dir"},{"url":"https://github.com/advisories/GHSA-8mcc-hrx5-hvxc"}],"tags":["osv","pip","ghsa"],"epss":0.0043,"epssPercentile":0.36666,"cwe":["CWE-22","CWE-73"],"ingestedAt":"2026-09-03T19:32:11.796Z","slug":"CVE-2026-78677","body":"## Overview\n\n- **CWE:** CWE-73 (External Control of File Name or Path) / CWE-22 (Path Traversal, in the \"escapes intended base directory\" sense)\n- **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.\n- **Affected version:** GitPython at HEAD (`9729ed3b948f2bde09f1f188c5311e172212b67e`, 2026-08-05, VERSION `3.1.58`)\n\n## Reachability\n`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.).\n\n`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:\n\n```\n:param allow_unsafe_options:\n    Allow unsafe options to be used, such as ``--template`` and\n    ``--separate-git-dir``.\n```\n\ni.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**:\n\n```python\nunsafe_git_clone_options = [\n    \"--upload-pack\",\n    \"-u\",\n    \"--config\",\n    \"-c\",\n    \"--template\",\n    \"--bundle-uri\",\n]\n```\n\nSo 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`.\n\n## Root cause\nParity 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).\n\n## Exploit path\n1. 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`.\n2. `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.\n3. `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).\n4. `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.\n\n## Impact\nArbitrary 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:\n- 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.\n- 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.\n- 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`.\n\n## Preconditions\n- 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.\n- No authentication/role requirement inside GitPython itself; the vulnerable code runs the moment the host application calls the API with the option present.\n\n## Evidence\n- `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\".\n- `git/repo/base.py:153-165` — `unsafe_git_clone_options` (the list actually enforced on `_clone`) does **not** include `\"--separate-git-dir\"`.\n- `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.\n- `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.\n- 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`.\n\n## False-positive check (adversarial re-read)\n- **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).\n- **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.\n- **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.\n- **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.\n- Verdict: no concrete blocker found. **CONFIRMED.**\n\n## Remediation\nAdd `\"--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.\n\n## Confidence\nHigh. 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.\n\n\n## Proof-of-Concept source (`gitpython-001-poc.py`)\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nGITPYTHON-001 PoC: Repo.clone_from(separate_git_dir=...) is not in\nunsafe_git_clone_options, so it reaches `git clone` unguarded and writes a\nfull git directory (config, hooks/, objects/, refs/, ...) to an\nattacker-controlled path OUTSIDE the intended destination directory, with\nallow_unsafe_options left at its default of False.\n\nRun against the GitPython source tree under test, e.g.:\n  PYTHONPATH=\"<repo>:<repo>/gitdb:<repo>/smmap\" python3 gitpython-001-poc.py <workdir>\n\nBenign: only writes/reads inside the given workdir. No destructive/exfiltrating\npayload. Exits non-zero and prints \"NOT VULNERABLE\" if the guard blocks the option\nor the write does not escape the destination directory.\n\"\"\"\nimport os\nimport sys\nimport subprocess\n\n\ndef main():\n    workdir = sys.argv[1] if len(sys.argv) > 1 else \"/tmp/gitpython-001-poc\"\n    src = os.path.join(workdir, \"src\")\n    dest = os.path.join(workdir, \"dest\")\n    sentinel_dir = os.path.join(workdir, \"OUTSIDE_SENTINEL\")\n    target_gitdir = os.path.join(sentinel_dir, \"redirected.git\")\n\n    for p in (src, dest, sentinel_dir):\n        os.makedirs(p, exist_ok=True)\n\n    # Minimal benign source repo to clone from.\n    subprocess.run([\"git\", \"init\", \"-q\", \"-b\", \"main\", src], check=True)\n    subprocess.run([\"git\", \"-C\", src, \"config\", \"user.email\", \"test@example.com\"], check=True)\n    subprocess.run([\"git\", \"-C\", src, \"config\", \"user.name\", \"Test\"], check=True)\n    with open(os.path.join(src, \"file.txt\"), \"w\") as f:\n        f.write(\"hello\\n\")\n    subprocess.run([\"git\", \"-C\", src, \"add\", \"file.txt\"], check=True)\n    subprocess.run([\"git\", \"-C\", src, \"commit\", \"-q\", \"-m\", \"init\"], check=True)\n\n    import git  # gitpython under test\n\n    print(\"unsafe_git_clone_options =\", git.Repo.unsafe_git_clone_options)\n    assert \"--separate-git-dir\" not in git.Repo.unsafe_git_clone_options, (\n        \"guard now includes --separate-git-dir; PoC no longer applicable, target patched\"\n    )\n\n    try:\n        repo = git.Repo.clone_from(src, dest, separate_git_dir=target_gitdir)\n    except git.exc.UnsafeOptionError as e:\n        print(\"NOT VULNERABLE: blocked by UnsafeOptionError:\", e)\n        sys.exit(1)\n\n    wrote_outside = os.path.isdir(os.path.join(target_gitdir, \"hooks\")) and os.path.isfile(\n        os.path.join(target_gitdir, \"config\")\n    )\n    gitlink_points_outside = False\n    with open(os.path.join(dest, \".git\")) as f:\n        gitlink = f.read().strip()\n        gitlink_points_outside = target_gitdir in gitlink\n\n    print(\"repo.git_dir =\", repo.git_dir)\n    print(\"wrote git directory outside dest (sentinel) =\", wrote_outside)\n    print(\"dest/.git gitlink points outside dest =\", gitlink_points_outside)\n\n    if wrote_outside and gitlink_points_outside:\n        print(\"VULNERABLE: git directory created at attacker-controlled path \"\n              f\"outside the clone destination: {target_gitdir}\")\n        sys.exit(0)\n    else:\n        print(\"NOT VULNERABLE: sentinel not observed\")\n        sys.exit(1)\n\n\nif __name__ == \"__main__\":\n    main()\n\n```\n\n## Affected packages\n\n- `gitpython < 3.1.59`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `gitpython 3.1.59`","depth":"twilight","depthScore":41,"depthScoreParts":{"impact":41.3,"likelihood":0.1,"exploitation":0,"ransomware":0},"changes":[{"seq":8215,"id":"CVE-2026-78677","ts":1788919993249,"field":"cvss","old":null,"new":"7.5"},{"seq":8214,"id":"CVE-2026-78677","ts":1788919993249,"field":"severity","old":"none","new":"high"},{"seq":8024,"id":"CVE-2026-78677","ts":1788919280603,"field":"cvss","old":"7.5","new":null},{"seq":8023,"id":"CVE-2026-78677","ts":1788919280603,"field":"severity","old":"high","new":"none"},{"seq":7833,"id":"CVE-2026-78677","ts":1788916353752,"field":"cvss","old":null,"new":"7.5"},{"seq":7832,"id":"CVE-2026-78677","ts":1788916353752,"field":"severity","old":"none","new":"high"},{"seq":7642,"id":"CVE-2026-78677","ts":1788915297307,"field":"cvss","old":"7.5","new":null},{"seq":7641,"id":"CVE-2026-78677","ts":1788915297307,"field":"severity","old":"high","new":"none"},{"seq":7451,"id":"CVE-2026-78677","ts":1788912713337,"field":"cvss","old":null,"new":"7.5"},{"seq":7450,"id":"CVE-2026-78677","ts":1788912713337,"field":"severity","old":"none","new":"high"},{"seq":7260,"id":"CVE-2026-78677","ts":1788911331239,"field":"cvss","old":"7.5","new":null},{"seq":7259,"id":"CVE-2026-78677","ts":1788911331239,"field":"severity","old":"high","new":"none"},{"seq":7064,"id":"CVE-2026-78677","ts":1788909076579,"field":"cvss","old":null,"new":"7.5"},{"seq":7063,"id":"CVE-2026-78677","ts":1788909076579,"field":"severity","old":"none","new":"high"},{"seq":6876,"id":"CVE-2026-78677","ts":1788907391350,"field":"cvss","old":"7.5","new":null},{"seq":6875,"id":"CVE-2026-78677","ts":1788907391350,"field":"severity","old":"high","new":"none"},{"seq":6678,"id":"CVE-2026-78677","ts":1788905442908,"field":"cvss","old":null,"new":"7.5"},{"seq":6677,"id":"CVE-2026-78677","ts":1788905442908,"field":"severity","old":"none","new":"high"},{"seq":6496,"id":"CVE-2026-78677","ts":1788903459561,"field":"cvss","old":"7.5","new":null},{"seq":6495,"id":"CVE-2026-78677","ts":1788903459561,"field":"severity","old":"high","new":"none"},{"seq":6286,"id":"CVE-2026-78677","ts":1788901809881,"field":"cvss","old":null,"new":"7.5"},{"seq":6285,"id":"CVE-2026-78677","ts":1788901809881,"field":"severity","old":"none","new":"high"},{"seq":6116,"id":"CVE-2026-78677","ts":1788899561561,"field":"cvss","old":"7.5","new":null},{"seq":6115,"id":"CVE-2026-78677","ts":1788899561561,"field":"severity","old":"high","new":"none"},{"seq":5919,"id":"CVE-2026-78677","ts":1788898179157,"field":"cvss","old":null,"new":"7.5"},{"seq":5918,"id":"CVE-2026-78677","ts":1788898179157,"field":"severity","old":"none","new":"high"},{"seq":5808,"id":"CVE-2026-78677","ts":1788895711042,"field":"cvss","old":"7.5","new":null},{"seq":5807,"id":"CVE-2026-78677","ts":1788895711042,"field":"severity","old":"high","new":"none"},{"seq":5718,"id":"CVE-2026-78677","ts":1788894572462,"field":"cvss","old":null,"new":"7.5"},{"seq":5717,"id":"CVE-2026-78677","ts":1788894572462,"field":"severity","old":"none","new":"high"}]}