GHSA-59cr-6r3x-644w: Path Traversal
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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/GitPythonto a version that resolves this vulnerability.Fixed in 3.1.62 - 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
Frequently Asked Questions
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.
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.
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.
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.