GHSA-fx3j-rwgx-fr94

Suggest an improvement
Source
https://github.com/advisories/GHSA-fx3j-rwgx-fr94
Import Source
https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-fx3j-rwgx-fr94/GHSA-fx3j-rwgx-fr94.json
JSON Data
https://api.osv.dev/v1/vulns/GHSA-fx3j-rwgx-fr94
Aliases
Published
2026-10-27T15:26:32Z
Modified
2026-10-27T15:47:24Z
Severity
  • 6.5 (Medium) CVSS_V3 - CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N CVSS Calculator
Summary
GitPython: separate_git_dir leaks env vars via crafted .gitmodules submodule name
Details

Summary

Repo._clone()'s handling of separate_git_dir never disables Git.polish_url()'s environment-variable expansion, unlike every other caller of that helper that handles attacker-influenced data. Since separate_git_dir can be derived from an attacker-controlled .gitmodules submodule name, a completely ordinary clone + 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 a Popen(...) log line that is not redacted the way credential-bearing arguments are.

Root cause / Details

git/repo/base.py's _clone() has:

sep_dir = kwargs.get("separate_git_dir")
if sep_dir:
    kwargs["separate_git_dir"] = Git.polish_url(sep_dir)

This is the only remaining Git.polish_url() call site handling attacker-influenced data that does not pass expand_vars=False (there is one other unguarded call site, clone_path at line 1537, but its input is the caller's own local destination path, not attacker-controlled). The sibling fixes for GHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573 added expand_vars=False to clone_url (5 lines below this one, in the same function), git/repo/base.py:1599, remote.py:814, and submodule/base.py:686 — this one did not receive it. (submodule/root.py also currently sets expand_vars=False at 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 plain os.path.expandvars() on the entire string, including embedded variables anywhere in an absolute path.

separate_git_dir reaches _clone() from Submodule._clone_repo() (git/objects/submodule/base.py) as module_abspath = osp.join(parent_repo.git_dir, "modules", name), where name is the raw .gitmodules submodule section name — fully attacker-controlled content of a cloned repository. _validated_name() (added by the fix for GHSA-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 as AWS_SECRET_ACCESS_KEY): GPY_SECRET_MARKER=AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345. Then ran the exact ordinary workflow the GHSA-hmq2-w58f-27jc advisory's own PoC also uses:

repo = Repo.clone_from(evil_parent_url, victim_clone_dir)
repo.submodule_update(init=True, recursive=True)

Observed result (ls .git/modules/):

.git/modules/AKIA_TOTALLY_FAKE_BUT_SECRET_LOOKING_VALUE_12345/   (a full git repo: HEAD, config, objects/, refs/, hooks/)

The literal $GPY_SECRET_MARKER-named directory was never created — only the expanded one exists, proving expansion happened before the value reached the git 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 the git.cmd/git.repo.base loggers at DEBUG level (a common CI/verbose-logging setting, not requiring any special flag), GitPython's own Popen(...) 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 an OSError/FileNotFoundError referencing 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/GitPython security advisories in full text. GHSA-hmq2-w58f-27jc covers the same name → .git/modules/<name> path but for path-traversal (../../..), not variable expansion. GHSA-59cr-6r3x-644w covers the sibling path field reaching unguarded os.makedirs(). GHSA-94p4-4cq8-9g67/GHSA-rwj8-pgh3-r573 cover expand_vars, but only on URL arguments. git log -p -S "separate_git_dir" -- git/repo/base.py shows neither fix commit touches this line; it is unchanged since its 2016 introduction (commit 57d05379).

The closest topical match is GHSA-8mcc-hrx5-hvxc ("clone_from()/clone() omit --separate-git-dir from unsafe_git_clone_options") — already fixed, and its own text discusses this same _clone() handling of separate_git_dir. That fix only added --separate-git-dir to the unsafe_git_clone_options denylist, which gates the direct-kwarg vector; Submodule._clone_repo() explicitly sets allow_unsafe_options=True for the gitfile-submodules path, unconditionally bypassing that exact denylist (confirmed: the reproduction above succeeds against current main, which already has that fix). The bug this report describes is independent of that denylist — the value itself is expanded via os.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_dir path.

Suggested fix

Add expand_vars=False to the Git.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 ordinary git 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).

Database specific
{
    "cwe_ids":  [
        "CWE-200"
    ],
    "github_reviewed":  true,
    "github_reviewed_at":  "2026-10-27T15:26:32Z",
    "nvd_published_at":  null,
    "severity":  "MODERATE"
}
References

Affected packages

PyPI / gitpython

Package

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
3.2.0

Affected versions

0.*
0.1.7
0.2.0-beta1
0.3.0-beta1
0.3.0-beta2
0.3.1-beta2
0.3.2.RC1
0.3.2
0.3.2.1
0.3.3
0.3.4
0.3.5
0.3.6
0.3.7
1.*
1.0.0
1.0.1
1.0.2
2.*
2.0.0
2.0.1
2.0.2
2.0.3
2.0.4
2.0.5
2.0.6
2.0.7
2.0.8
2.0.9.dev0
2.0.9.dev1
2.0.9
2.1.0
2.1.1
2.1.2
2.1.3
2.1.4
2.1.5
2.1.6
2.1.7
2.1.8
2.1.9
2.1.10
2.1.11
2.1.12
2.1.13
2.1.14
2.1.15
3.*
3.0.0
3.0.1
3.0.2
3.0.3
3.0.4
3.0.5
3.0.6
3.0.7
3.0.8
3.0.9
3.1.0
3.1.1
3.1.2
3.1.3
3.1.4
3.1.5
3.1.6
3.1.7
3.1.8
3.1.9
3.1.10
3.1.11
3.1.12
3.1.13
3.1.14
3.1.15
3.1.16
3.1.17
3.1.18
3.1.19
3.1.20
3.1.22
3.1.23
3.1.24
3.1.25
3.1.26
3.1.27
3.1.28
3.1.29
3.1.30
3.1.31
3.1.32
3.1.33
3.1.34
3.1.35
3.1.36
3.1.37
3.1.38
3.1.40
3.1.41
3.1.42
3.1.43
3.1.44
3.1.45
3.1.46
3.1.47
3.1.48
3.1.49
3.1.50
3.1.51
3.1.52
3.1.53
3.1.54
3.1.55
3.1.56
3.1.57
3.1.58
3.1.59
3.1.60
3.1.61
3.1.62

Database specific

last_known_affected_version_range
"<= 3.1.62"
source
"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-fx3j-rwgx-fr94/GHSA-fx3j-rwgx-fr94.json"