{"id":"GHSA-59cr-6r3x-644w","title":"GitPython submodule update path traversal can write outside the repository","summary":"GitPython submodule update path traversal can write outside the repository","severity":"medium","cwe":["CWE-22","CWE-73"],"vendor":"GitPython","product":"GitPython","ecosystem":"pip","affected":["GitPython <= 3.1.61"],"patched":["GitPython 3.1.62"],"published":"2026-09-30","updated":"2026-09-30","sourceUpdated":"2026-09-30T23:47:14Z","source":"GHSA","sourceUrl":"https://github.com/advisories/GHSA-59cr-6r3x-644w","references":[{"url":"https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w"},{"url":"https://github.com/gitpython-developers/GitPython/pull/2225"},{"url":"https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093"},{"url":"https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62"},{"url":"https://github.com/advisories/GHSA-59cr-6r3x-644w"}],"tags":["ghsa","pip"],"ingestedAt":"2026-10-01T00:33:27.742Z","slug":"GHSA-59cr-6r3x-644w","body":"## Overview\n\n**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.\n\n---\n\n## The gap\n\nThe 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:\n\n```python\n# git/objects/submodule/base.py:172-177\ndef _set_cache_(self, attr):\n    if attr in (\"path\", \"_url\", \"_branch_path\"):\n        reader = self.config_reader()\n        self.path = reader.get(\"path\")          # raw .gitmodules value\n```\n\nand GitPython's own containment guard is applied in only two of the places that consume it:\n\n```\n400: def _to_relative_path(cls, parent_repo, path)      # the guard (abspath + commonpath containment)\n542:     path = cls._to_relative_path(repo, path)        # add()   — guarded\n1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() — guarded\n```\n\n`update()` validates only the name and then uses the path-derived absolute location directly:\n\n```\n788:  self._validated_name(self.name)                    # NAME only\n801:  checkout_module_abspath = self.abspath             # derived from self.path — unguarded\n821:  os.makedirs(checkout_module_abspath, exist_ok=True)\n```\n\nSo `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`.\n\nThe 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.\n\n## Honest limits (please read before rating)\n\n- **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=...)`).\n- **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`.\n\n## Suggested fix\n\nApply the guard the project already has, wherever the path is consumed:\n\n```python\n# in update(), before deriving abspath (and in any other consumer of self.path):\ncheckout_rel = self._to_relative_path(self.repo, self.path)   # raises if it escapes the working tree\n```\n\nBetter 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.\n\n## Prior art checked\n\n`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.\n\n## Credit\n\n**kta1kri**.\n\n\n---\n\n## Appendix — `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments)\n\n```text\n=== EVIDENCE: GitPython — the .gitmodules 'path' field reaches os.makedirs()/clone unguarded ===\nMon Aug 31 18:45:22 UTC 2026\n\n--- artifact: tag 3.1.61 (latest release); git diff 3.1.61 origin/main -- git/objects/submodule/ is empty ---\n\n--- the containment guard GitPython owns, and its only two call sites ---\n33:    _to_relative_path,\n400:    def _to_relative_path(cls, parent_repo: \"Repo\", path: PathLike) -> PathLike:\n407:            path = _to_relative_path(parent_repo.working_tree_dir, path)\n542:        path = cls._to_relative_path(repo, path)\n1041:        module_checkout_path = self._to_relative_path(self.repo, module_path)\n\n--- the parent fix (_validated_name) call sites: it validates the NAME ---\n309:    def _validated_name(cls, name: str) -> str:\n321:        name = cls._validated_name(name)\n541:        cls._validated_name(name)\n788:            self._validated_name(self.name)\n1040:        self._validated_name(self.name)\n1181:        self._validated_name(self.name)\n1439:        self._validated_name(self.name)\n1440:        self._validated_name(new_name)\n1489:        self._validated_name(self.name)\n\n--- update(): name validated, path not; abspath -> os.makedirs ---\n\n        try:\n            self._validated_name(self.name)\n\n            # ENSURE REPO IS PRESENT AND UP-TO-DATE\n                # END early abort if init is not allowed\n\n                checkout_module_abspath = self.abspath\n                module_abspath = self._module_abspath(self.repo, self.path, self.name)\n\n                # ``git submodule deinit`` leaves the repository in\n                # ``.git/modules`` and empties the checkout. Reconnect that retained\n                # repository instead of trying to clone over it.\n                if not dry_run and osp.isdir(module_abspath):\n                    try:\n                        git.Repo(module_abspath)\n                    except InvalidGitRepositoryError:\n                        pass\n                    else:\n                        if osp.lexists(checkout_module_abspath) and (\n                            osp.islink(checkout_module_abspath)\n                            or not osp.isdir(checkout_module_abspath)\n                            or os.listdir(checkout_module_abspath)\n                        ):\n                            raise OSError(\n                                \"Module directory at %r does already exist and is non-empty\" % checkout_module_abspath\n                            )\n                        os.makedirs(checkout_module_abspath, exist_ok=True)\n                        self._write_git_file_and_module_config(checkout_module_abspath, module_abspath)\n                        mrepo = git.Repo(checkout_module_abspath)\n\n--- where self.path comes from (raw .gitmodules value) ---\n    def _set_cache_(self, attr: str) -> None:\n        if attr in (\"path\", \"_url\", \"_branch_path\"):\n            reader: SectionConstraint = self.config_reader()\n            # Default submodule values.\n            try:\n                self.path = reader.get(\"path\")\n            except cp.NoSectionError as e:\n```\n\n## Affected packages\n\n- `GitPython <= 3.1.61`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `GitPython 3.1.62`","depth":"sunlit","depthScore":28,"depthScoreParts":{"impact":27.5,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}