{"id":"GHSA-3f7w-8rr8-f37f","summary":"GitPython: Unguarded git option forwarding in IndexFile.checkout() and TagReference.create() enables arbitrary file overwrite and arbitrary file read","details":"**Target:** gitpython-developers/GitPython\n**Tested:** HEAD `07e80555` (2026-07-25), latest release 3.1.55, `git version 2.50.1`\n**Reported instances:** 2 exploitable, from a sweep of 14 unguarded call sites\n\n## Summary\n\nGitPython blocks dangerous git options through `Git.check_unsafe_options()`, gated per method by an `allow_unsafe_options` parameter. That guard is applied **per call site**, so any API that forwards `**kwargs` into a git command without calling it passes caller-controlled options straight to git.\n\nA mechanical sweep of every method that forwards `**kwargs` into a `.git.\u003ccommand\u003e(...)` call found **14 sites with no guard**. Two reach a git option that takes a filesystem path:\n\n| # | Call site | git option | Impact |\n|---|---|---|---|\n| 1 | `IndexFile.checkout()` → `git checkout-index` | `--prefix=\u003cpath\u003e` | arbitrary file **overwrite** with repository-controlled content |\n| 2 | `TagReference.create()` → `git tag` | `-F \u003cfile\u003e` / `--file=\u003cfile\u003e` | arbitrary file **read**, returned in-band |\n\nThis is the same defect class already fixed in `Commit.count()` (GHSA-p538-c434-8v24), `Repo.archive()` and `Git.ls_remote()` (GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD.\n\n---\n\n## Instance 1 — `IndexFile.checkout()`: arbitrary file overwrite\n\n`git/index/base.py:1210` accepts `**kwargs` and forwards them with no guard:\n\n```python\ndef checkout(self, paths=None, force=False, fprogress=lambda *args: None, **kwargs):\n    ...\n    proc = self.repo.git.checkout_index(*args, **kwargs)   # line 1331\n    ...\n    proc = self.repo.git.checkout_index(args, **kwargs)    # line 1349\n```\n\nThere is no `allow_unsafe_options` parameter and no `check_unsafe_options()` call in the method.\n\n`git checkout-index` accepts `--prefix=\u003cstring\u003e`, prepended to every output path. It is not confined to the working tree, so an absolute prefix writes tracked file contents anywhere the process can write, and `-f` overwrites what is already there.\n\n### Reproduction\n\n```python\nfrom git import Repo\nRepo(\"/path/to/repo\").index.checkout(prefix=\"/tmp/target_dir/\", a=True, f=True)\n```\n\nObserved (`poc/poc_checkout_index.py`) — no exception raised, files land outside the repository:\n\n```\n[ALLOWED] no UnsafeOptionError raised\nfiles written outside the repo: ['f.txt']\n  f.txt: 'hi\\n'\n```\n\nOverwrite of a pre-existing file (`poc/poc_ci_overwrite.py`) — the victim file held `ORIGINAL-DO-NOT-CLOBBER\\n` before the call:\n\n```\n[ALLOWED] no exception\nvictim content now: 'hi\\n'\nOVERWRITTEN: True\n```\n\n### Why this rates High\n\nBoth halves of the write are attacker-influenced:\n\n- **Destination** — the `prefix` kwarg.\n- **Content** — the bytes written are repository blobs, so anyone who can land a file in the repository (a pull-request branch, a mirrored or untrusted repository, an agent-cloned repository) controls exactly what is written.\n\nCommit a file named `authorized_keys`, `.bashrc`, `config` or `post-checkout`, choose the matching prefix (`~/.ssh/`, `~/`, `.git/hooks/`), and the write becomes code execution as the service account.\n\nFor comparison within this project: GHSA-fjr4-x663-mwxc (arbitrary file overwrite via `git diff --output`) is rated High, and GHSA-p538-c434-8v24 (arbitrary file *truncation* via `git rev-list --output`) is rated Medium. `--prefix` supplies full content control, so it sits at or above the former.\n\n---\n\n## Instance 2 — `TagReference.create()`: arbitrary file read\n\n`git/refs/tag.py:88` forwards `**kwargs` into `git tag` with no guard, and the signature advertises the passthrough:\n\n```python\ndef create(cls, repo, path, reference=\"HEAD\", logmsg=None, force=False, **kwargs):\n    \"\"\"...\n    :param kwargs:\n        Additional keyword arguments to be passed to :manpage:`git-tag(1)`.\n    \"\"\"\n```\n\n`git tag` accepts `-F \u003cfile\u003e` / `--file=\u003cfile\u003e`, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller via `TagReference.tag.message`, so the file contents come back in-band.\n\n### Reproduction\n\n```python\nfrom git import Repo\nfrom git.refs.tag import TagReference\n\nt = TagReference.create(Repo(\"/path/to/repo\"), \"x\", force=True, a=True, F=\"/etc/passwd\")\nprint(t.tag.message)\n```\n\nObserved (`poc/poc_tag_F.py`), reading a canary file outside the repository:\n\n```\n[ALLOWED] no UnsafeOptionError raised\n\u003e\u003e\u003e tag message recovered from arbitrary path: 'TAG-READ-CANARY-98765\\nsecond-line-secret'\n```\n\nImpact is a read at the privileges of the process. I am not claiming code execution for this instance. The signing options (`-s`, `-u`/`--local-user`) do invoke gpg from the same unguarded kwargs, but I did not develop that into command execution and make no claim about it.\n\n---\n\n## Sweep results — the other 12 sites\n\nReported so the fix can be scoped once rather than per report. `poc/sweep.py` reproduces this list.\n\n| Call site | git command | Assessment |\n|---|---|---|\n| `IndexFile.from_tree()` | `read-tree` | `--index-output=\u003cpath\u003e` looked reachable but is **neutralised**: GitPython appends its own `--index-output` after the caller's kwargs and git honours the last occurrence. Verified — victim file unchanged (`poc/poc_readtree.py`) |\n| `IndexFile.remove()` | `rm` | `--pathspec-from-file` only reads a pathspec; no write or disclosure primitive found |\n| `IndexFile.move()` | `mv` | same |\n| `HEAD.reset()` | `reset` | same |\n| `HEAD.checkout()` | `checkout` | same |\n| `Head.delete()`, `RemoteReference.delete()` | `branch` | no path-taking option found |\n| `Repo.merge_base()` | `merge-base` | no path-taking option found |\n| `Repo._get_untracked_files()` | `status` | no path-taking option found |\n| `Remote.set_url()`, `Remote.create()`, `Remote.update()` | `remote` | URL handling already addressed by GHSA-94p4-4cq8-9g67 |\n\n## Suggested remediation\n\n**Immediate:** add `allow_unsafe_options: bool = False` to both methods and gate `Git._option_candidates(args, kwargs)` against new lists — `unsafe_git_checkout_index_options = [\"--prefix\"]` (consider `--temp`) and `unsafe_git_tag_options = [\"--file\", \"-F\"]` (consider `-s`, `-u`/`--local-user`, `--cleanup`) — matching the pattern used in `Repo.archive()` and `Commit.count()`.\n\n**Structural:** this defect has now been fixed four times in four places (`Repo.archive()`, `Git.ls_remote()`, `Commit.count()`, and the two here), because the guard is opt-in per method: every new `**kwargs`-forwarding API starts unguarded and stays that way until someone reports it. Enforcing the check centrally in `Git._call_process()` — each git invocation consults a per-command unsafe-option table unless the caller opts out — would make new call sites safe by default rather than by review, and would close the remaining sites in the table above at the same time.\n\n## Disclosure\n\nReported privately via GitHub private vulnerability reporting.","aliases":["CVE-2026-73620","PYSEC-2026-3949"],"modified":"2026-09-10T13:10:50.685700830Z","published":"2026-08-03T20:09:56Z","database_specific":{"github_reviewed_at":"2026-08-03T20:09:56Z","nvd_published_at":null,"cwe_ids":["CWE-200","CWE-22","CWE-73"],"severity":"HIGH","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-3f7w-8rr8-f37f"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/pull/2193"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/commit/3af0c2516c5e18c829da30338614688f6b69b49c"},{"type":"PACKAGE","url":"https://github.com/gitpython-developers/GitPython"},{"type":"WEB","url":"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.57"}],"affected":[{"package":{"name":"gitpython","ecosystem":"PyPI","purl":"pkg:pypi/gitpython"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.1.57"}]}],"versions":["0.1.7","0.2.0-beta1","0.3.0-beta1","0.3.0-beta2","0.3.1-beta2","0.3.2","0.3.2.1","0.3.2.RC1","0.3.3","0.3.4","0.3.5","0.3.6","0.3.7","1.0.0","1.0.1","1.0.2","2.0.0","2.0.1","2.0.2","2.0.3","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9","2.0.9.dev0","2.0.9.dev1","2.1.0","2.1.1","2.1.10","2.1.11","2.1.12","2.1.13","2.1.14","2.1.15","2.1.2","2.1.3","2.1.4","2.1.5","2.1.6","2.1.7","2.1.8","2.1.9","3.0.0","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.0.6","3.0.7","3.0.8","3.0.9","3.1.0","3.1.1","3.1.10","3.1.11","3.1.12","3.1.13","3.1.14","3.1.15","3.1.16","3.1.17","3.1.18","3.1.19","3.1.2","3.1.20","3.1.22","3.1.23","3.1.24","3.1.25","3.1.26","3.1.27","3.1.28","3.1.29","3.1.3","3.1.30","3.1.31","3.1.32","3.1.33","3.1.34","3.1.35","3.1.36","3.1.37","3.1.38","3.1.4","3.1.40","3.1.41","3.1.42","3.1.43","3.1.44","3.1.45","3.1.46","3.1.47","3.1.48","3.1.49","3.1.5","3.1.50","3.1.51","3.1.52","3.1.53","3.1.54","3.1.55","3.1.56","3.1.6","3.1.7","3.1.8","3.1.9"],"database_specific":{"last_known_affected_version_range":"\u003c= 3.1.56","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/08/GHSA-3f7w-8rr8-f37f/GHSA-3f7w-8rr8-f37f.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H"}]}