GHSA-59cr-6r3x-644w: Path Traversal

Published Sep 30, 2026
·
Updated

Affected: GitPython 3.1.61 (latest release) and main — git/objects/submodule/base.py. git diff 3.1.61 origin/main -- git/objects/submodule/ is empty, so both are identical here.

---

The gap

The fix for GHSA-hmq2-w58f-27jc added Submodule.validatedname() and wired it into update() and five siblings, closing the .gitmodules name → .git/modules/<name> traversal. The other attacker-controlled .gitmodules field, path, is read raw:

python git/objects/submodule/base.py:172-177 def setcache(self, attr): if attr in ("path", "url", "branchpath"): reader = self.configreader() self.path = reader.get("path") # raw .gitmodules value

and GitPython's own containment guard is applied in only two of the places that consume it:

400: def torelativepath(cls, parentrepo, path) # the guard (abspath + commonpath containment) 542: path = cls.torelativepath(repo, path) # add() — guarded 1041: modulecheckoutpath = self.torelativepath(self.repo, modulepath) # move() — guarded

update() validates only the name and then uses the path-derived absolute location directly:

788: self.validatedname(self.name) # NAME only 801: checkoutmoduleabspath = self.abspath # derived from self.path — unguarded 821: os.makedirs(checkoutmoduleabspath, existok=True)

So path = ../../../tmp/escaped in an attacker-authored .gitmodules selects the directory that gets created and, on the clone path, populated from the submodule URL. The same absolute location is what forceremove hands to shutil.rmtree.

The asymmetry is the argument: this is not a missing concept — the project wrote torelativepath() precisely for this, and add()/move() use it. update() does not.

Honest limits (please read before rating)

- The most common flow is not affected. Repo.clonefrom(...) → repo.submodules → sm.update(init=True) re-derives path from a canonical tree lookup, and real git refuses to check out a tree containing a .. component, so an evil .gitmodules never lands in the working tree in the first place. A reachable trigger therefore requires the victim's code to name a non-HEAD commit (a historical-commit API such as submoduleupdate(previouscommit=...)). - The researcher did not build that end-to-end trigger. The researcher only verified first-hand the code above: the guard's two call sites, the name-only validation in update(), and the unguarded abspath → os.makedirs() flow at 3.1.61 == main.

Suggested fix

Apply the guard the project already has, wherever the path is consumed:

python in update(), before deriving abspath (and in any other consumer of self.path): checkoutrel = self.torelativepath(self.repo, self.path) # raises if it escapes the working tree

Better still, validate at the boundary: reject a .gitmodules entry whose path is absolute or contains a .. component when the section is first read in setcache()/iteritems(), so no consumer can be added later without the check. A regression test with path = ../escaped alongside the existing name test would pin both fields.

Prior art checked

GHSA-hmq2-w58f-27jc (this is a residual of its fix, in the sibling field, not a re-report) plus the repository's 30 published advisories — none mentions the path field or torelativepath. Searched issues and PRs for torelativepath, gitmodules path and submodule traversal: no report of this.

Credit

kta1kri.

---

Appendix — EVIDENCEgitpythonpathunguarded20260901.txt (inlined; advisories accept no attachments)

text === EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded === Mon Aug 31 18:45:22 UTC 2026

--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---

--- the containment guard GitPython owns, and its only two call sites --- 33: torelativepath, 400: def torelativepath(cls, parentrepo: "Repo", path: PathLike) -> PathLike: 407: path = torelativepath(parentrepo.workingtreedir, path) 542: path = cls.torelativepath(repo, path) 1041: modulecheckoutpath = self.torelativepath(self.repo, modulepath)

--- the parent fix (validatedname) call sites: it validates the NAME --- 309: def validatedname(cls, name: str) -> str: 321: name = cls.validatedname(name) 541: cls.validatedname(name) 788: self.validatedname(self.name) 1040: self.validatedname(self.name) 1181: self.validatedname(self.name) 1439: self.validatedname(self.name) 1440: self.validatedname(newname) 1489: self.validatedname(self.name)

--- update(): name validated, path not; abspath -> os.makedirs ---

try: self.validatedname(self.name)

# ENSURE REPO IS PRESENT AND UP-TO-DATE # END early abort if init is not allowed

checkoutmoduleabspath = self.abspath moduleabspath = self.moduleabspath(self.repo, self.path, self.name)

# git submodule deinit leaves the repository in # .git/modules and empties the checkout. Reconnect that retained # repository instead of trying to clone over it. if not dryrun and osp.isdir(moduleabspath): try: git.Repo(moduleabspath) except InvalidGitRepositoryError: pass else: if osp.lexists(checkoutmoduleabspath) and ( osp.islink(checkoutmoduleabspath) or not osp.isdir(checkoutmoduleabspath) or os.listdir(checkoutmoduleabspath) ): raise OSError( "Module directory at %r does already exist and is non-empty" % checkoutmoduleabspath ) os.makedirs(checkoutmoduleabspath, existok=True) self.writegitfileandmoduleconfig(checkoutmoduleabspath, moduleabspath) mrepo = git.Repo(checkoutmoduleabspath)

--- where self.path comes from (raw .gitmodules value) --- def setcache(self, attr: str) -> None: if attr in ("path", "url", "branchpath"): reader: SectionConstraint = self.configreader() # Default submodule values. try: self.path = reader.get("path") except cp.NoSectionError as e:

Affected Software

1 affected componentFixes available
pip/GitPython<=3.1.61
3.1.62

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/GitPython to a version that resolves this vulnerability.

    Fixed in 3.1.62
  2. Compensating control

    Validate the .gitmodules path field at the boundary when entries are first read in _set_cache_()/iter_items(), rejecting absolute paths and paths containing a '..' component; alternatively, apply the existing _to_relative_path() containment guard wherever the path is consumed.

Event History

Sep 30, 2026
Advisory Published
via GitHub·11:47 PM
Data Sourced
via GitHub·11:47 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Which releases should be treated as affected?

GitPython 3.1.61, identified as the latest release, is affected. The main branch is also affected because its relevant submodule code is identical to 3.1.61.

2

What attacker-controlled input is involved?

The submodule path value in .gitmodules is read without validation. This is separate from the submodule name traversal issue addressed by the earlier fix.

3

Are all submodule path operations protected by a containment check?

No. GitPython applies its relative-path containment guard in add() and move(), but update() validates only the submodule name before using a path-derived location. The supplied data does not establish equivalent protection for the other path-consuming operations.

4

How can I identify potentially risky repository input while triaging?

Inspect .gitmodules files for submodule path values that resolve outside the intended parent repository directory. Treat such files as untrusted, particularly if your application invokes submodule update functionality.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203