BREW-OTTERDOG-CVE-2026-100689 (GHSA-59CR-6R3X-644W)

Vulnerability from osv_homebrew – Published: 2026-10-01 09:31 – Updated: 2026-10-01 09:31 – Source website
VLAI
Summary
GitPython submodule update path traversal can write outside the repository
Details

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:

{
  "affected": [
    {
      "ecosystem_specific": {
        "fix": "bump",
        "range_state": "fixed",
        "resource": "gitpython",
        "resource_purl": "pkg:pypi/gitpython@3.1.62",
        "upstream_fixed_in": "3.1.62"
      },
      "package": {
        "ecosystem": "Homebrew",
        "name": "otterdog",
        "purl": "pkg:brew/otterdog"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "1.0.1"
            },
            {
              "fixed": "1.5.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "database_specific": {
    "confidence": "high",
    "source": "matched",
    "strategy": "registry",
    "upstream_evidence": [
      {
        "ecosystem": "PyPI",
        "key": "pkg:pypi/gitpython@3.1.62",
        "name": "gitpython",
        "resource": "gitpython",
        "strategy": "registry",
        "subject_version": "3.1.62"
      }
    ]
  },
  "details": "**Affected:** `GitPython` **3.1.61** (latest release) and `main` \u2014 `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** \u2192 `.git/modules/\u003cname\u003e` 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\u0027s 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()   \u2014 guarded\n1041:    module_checkout_path = self._to_relative_path(self.repo, module_path)   # move() \u2014 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 \u2014 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 \u2014 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(...)` \u2192 `repo.submodules` \u2192 `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\u0027s 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\u0027s two call sites, the name-only validation in `update()`, and the unguarded `abspath` \u2192 `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\u0027s 30 published advisories \u2014 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 \u2014 `EVIDENCE_gitpython_path_unguarded_20260901.txt` (inlined; advisories accept no attachments)\n\n```text\n=== EVIDENCE: GitPython \u2014 the .gitmodules \u0027path\u0027 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) -\u003e 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) -\u003e 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 -\u003e 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) -\u003e 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```",
  "id": "BREW-otterdog-CVE-2026-100689",
  "modified": "2026-10-01T09:31:27Z",
  "published": "2026-10-01T09:31:27Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-59cr-6r3x-644w"
    },
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/pull/2225"
    },
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/commit/1ed0ebc2f2e74d979cdc367a4864a7731fdcc093"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/gitpython-developers/GitPython"
    },
    {
      "type": "WEB",
      "url": "https://github.com/gitpython-developers/GitPython/releases/tag/3.1.62"
    }
  ],
  "schema_version": "1.7.3",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:P/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "GitPython submodule update path traversal can write outside the repository",
  "upstream": [
    "GHSA-59cr-6r3x-644w",
    "CVE-2026-100689"
  ]
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…