GHSA-59cr-6r3x-644w
GitPython submodule update path traversal can write outside the repository
- Risk score
- 42/100
- CVSS
- 6.1 v4.0
- Published
- 30 Sep 2026
Open in the live forecast Today's forecast
What to know
- What it is
- 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 fix for GHSA-hmq2-w58f-27jc added Submodule._validated_name() and wired it into update() and five siblings, closing the .gitmodules name …
- Affected
- GitPython (pip) <= 3.1.61
- Fix
- Upgrade GitPython to 3.1.62 or later.
- Exploitation
- No known exploitation
Why it is Yellow
- CVSS 6.1 medium
- On your watchlist: PyPI
Affected packages
| Package | Ecosystem | Vulnerable | Fixed in |
|---|---|---|---|
| GitPython | pip | <= 3.1.61 | 3.1.62 |
References
- https://github.com/advisories/GHSA-59cr-6r3x-644w
- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w
- https://github.com/gitpython-developers/GitPython/pull/2225
- https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093
- https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62
- https://github.com/advisories/GHSA-59cr-6r3x-644w
Original 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: ```python # 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: ```python # 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) ```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: _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) ):
Last activity 30 Sep 2026. Patch Forecast updates every hour.