All vulnerabilities
GHSA-59cr-6r3x-644wCVE-2026-100689

GitPython submodule update path traversal can write outside the repository

GitPythonpipLibraries & packages
CVSS
6.1Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in3.1.62Fixed versionGitHub
Published30 Sep 2026Updated 1 Oct 2026GitHub

What fixes it

  • 3.1.62GitPython, pipGitHub
  • 3.1.62gitpython, PyPIOSVpip install "gitpython>=3.1.62"

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

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._validated_name() 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:

# git/objects/submodule/base.py:172-177
def _set_cache_(self, attr):
    if attr in ("path", "_url", "_branch_path"):
        reader = self.config_reader()
        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 _to_relative_path(cls, parent_repo, path)      # the guard (abspath + commonpath containment)
542:     path = cls._to_relative_path(repo, path)        # add()   — guarded
1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() — guarded

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

788:  self._validated_name(self.name)                    # NAME only
801:  checkout_module_abspath = self.abspath             # derived from self.path — unguarded
821:  os.makedirs(checkout_module_abspath, exist_ok=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 force_remove hands to shutil.rmtree.

The asymmetry is the argument: this is not a missing concept — the project wrote _to_relative_path() 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.clone_from(...) → 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 submodule_update(previous_commit=...)).
  • 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:

# in update(), before deriving abspath (and in any other consumer of self.path):
checkout_rel = self._to_relative_path(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 _set_cache_()/iter_items(), 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 _to_relative_path. Searched issues and PRs for _to_relative_path, gitmodules path and submodule traversal: no report of this.

Credit

kta1kri.

---

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

=== 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:    _to_relative_path,
400:    def _to_relative_path(cls, parent_repo: "Repo", path: PathLike) -> PathLike:
407:            path = _to_relative_path(parent_repo.working_tree_dir, path)
542:        path = cls._to_relative_path(repo, path)
1041:        module_checkout_path = self._to_relative_path(self.repo, module_path)

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

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

        try:
            self._validated_name(self.name)

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

                checkout_module_abspath = self.abspath
                module_abspath = self._module_abspath(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 dry_run and osp.isdir(module_abspath):
                    try:
                        git.Repo(module_abspath)
                    except InvalidGitRepositoryError:
                        pass
                    else:
                        if osp.lexists(checkout_module_abspath) and (
                            osp.islink(checkout_module_abspath)
                            or not osp.isdir(checkout_module_abspath)
                            or os.listdir(checkout_module_abspath)
                        ):
                            raise OSError(
                                "Module directory at %r does already exist and is non-empty" % checkout_module_abspath
                            )
                        os.makedirs(checkout_module_abspath, exist_ok=True)
                        self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)
                        mrepo = git.Repo(checkout_module_abspath)

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

Text from GitHub Advisory Database

Affected versions 52

  • GitPython, pip<= 3.1.61GitHub
  • gitpython, PyPIfrom first release, fixed in 3.1.62OSV
  • gitpython, PyPI0.1.7OSV
  • gitpython, PyPI0.2.0-beta1OSV
  • gitpython, PyPI0.3.0-beta1OSV
  • gitpython, PyPI0.3.0-beta2OSV
  • gitpython, PyPI0.3.1-beta2OSV
  • gitpython, PyPI0.3.2OSV
  • gitpython, PyPI0.3.2.1OSV
  • gitpython, PyPI0.3.2.RC1OSV
  • gitpython, PyPI0.3.3OSV
  • gitpython, PyPI0.3.4OSV
  • gitpython, PyPI0.3.5OSV
  • gitpython, PyPI0.3.6OSV
  • gitpython, PyPI0.3.7OSV
  • gitpython, PyPI1.0.0OSV
  • gitpython, PyPI1.0.1OSV
  • gitpython, PyPI1.0.2OSV
  • gitpython, PyPI2.0.0OSV
  • gitpython, PyPI2.0.1OSV
  • gitpython, PyPI2.0.2OSV
  • gitpython, PyPI2.0.3OSV
  • gitpython, PyPI2.0.4OSV
  • gitpython, PyPI2.0.5OSV
  • gitpython, PyPI2.0.6OSV
  • gitpython, PyPI2.0.7OSV
  • gitpython, PyPI2.0.8OSV
  • gitpython, PyPI2.0.9OSV
  • gitpython, PyPI2.0.9.dev0OSV
  • gitpython, PyPI2.0.9.dev1OSV
  • gitpython, PyPI2.1.0OSV
  • gitpython, PyPI2.1.1OSV
  • gitpython, PyPI2.1.10OSV
  • gitpython, PyPI2.1.11OSV
  • gitpython, PyPI2.1.12OSV
  • gitpython, PyPI2.1.13OSV
  • gitpython, PyPI2.1.14OSV
  • gitpython, PyPI2.1.15OSV
  • gitpython, PyPI2.1.2OSV
  • gitpython, PyPI2.1.3OSV
  • gitpython, PyPI2.1.4OSV
  • gitpython, PyPI2.1.5OSV
  • gitpython, PyPI2.1.6OSV
  • gitpython, PyPI2.1.7OSV
  • gitpython, PyPI2.1.8OSV
  • gitpython, PyPI2.1.9OSV
  • gitpython, PyPI3.0.0OSV
  • gitpython, PyPI3.0.1OSV
  • gitpython, PyPI3.0.2OSV
  • gitpython, PyPI3.0.3OSV
  • gitpython, PyPI3.0.4OSV
  • gitpython, PyPI3.0.5OSV

References 5