diff mbox series

[scarthgap,24/27] python3-git: fix CVE-2026-87817

Message ID 20261016061922.843414-25-yurade@cisco.com
State New
Delegated to: Yoann Congal
Headers show
Series python3-git: Security fixes | expand

Commit Message

Yogita Urade Oct. 16, 2026, 6:19 a.m. UTC
This patch applies the upstream fix as referenced in [2],
using the commit shown in [1].

[1] https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b
[2] https://www.cve.org/CVERecord?id=CVE-2026-87817

Signed-off-by: Yogita Urade <yurade@cisco.com>
---
 .../python/python3-git/CVE-2026-87817.patch   | 430 ++++++++++++++++++
 .../python/python3-git_3.1.42.bb              |   1 +
 2 files changed, 431 insertions(+)
 create mode 100644 meta/recipes-devtools/python/python3-git/CVE-2026-87817.patch
diff mbox series

Patch

diff --git a/meta/recipes-devtools/python/python3-git/CVE-2026-87817.patch b/meta/recipes-devtools/python/python3-git/CVE-2026-87817.patch
new file mode 100644
index 0000000000..c69c906144
--- /dev/null
+++ b/meta/recipes-devtools/python/python3-git/CVE-2026-87817.patch
@@ -0,0 +1,430 @@ 
+From e1997b93556ee52ddb4ddfa2da174e8fdb4609cb Mon Sep 17 00:00:00 2001
+From: Byron <sebastian.thiel@icloud.com>
+Date: Tue, 25 Aug 2026 04:57:29 +0200
+Subject: [PATCH 35/38] fix: prefer `.git` during repository discovery
+
+<!-- agent -->
+GitPython considered worktree administration and bare-repository signatures
+before a worktree's real .git entry. Align discovery with Git so .git files
+and directories win, malformed .git files stop discovery, and candidate git
+directories validate HEAD plus commondir-backed object and ref storage. This
+addresses GHSA-239g-whfq-7xj9.
+
+Regression coverage compares ambiguous layouts with git rev-parse and rejects
+invalid HEAD/.git metadata.
+
+Git baseline: 15c6308cf7ad276b306aa5b3ababfbdebfb1a917; setup.c
+setup_git_directory_gently_1(), is_git_directory(), and validate_headref().
+
+Repository validation can use GIT_COMMON_DIR for refs and objects. Preserve the
+same value on Repo so later config, ref, and object access uses the directory
+that made discovery succeed.
+
+Capture GIT_COMMON_DIR before the first repository config read so bare-state
+detection uses the same metadata location as discovery. Resolve relative
+environment values immediately so later working-directory changes cannot
+retarget the Repo.
+
+Review feedback: relative GIT_COMMON_DIR left Git subprocesses resolving GIT_DIR
+and GIT_COMMON_DIR from a different working directory; malformed commondir data,
+empty GIT_OBJECT_DIRECTORY, and the linked-worktree signature were also handled
+inconsistently.
+
+Pin the repository environment to resolved paths, reject invalid
+metadata without consulting the process working directory, and
+restore HEAD-based linked-worktree detection. Keep the loose HEAD
+and dangling .git behavior because both match Git setup.c at baseline
+15c6308cf7ad276b306aa5b3ababfbdebfb1a917.
+
+Review feedback identified that chained or self-referential .git pointers
+recurse, filesystem-encoded metadata can fail text decoding, and a relative
+GIT_DIR is not retained for later Git commands.
+
+Parse one regular, size-bounded Gitfile exactly once, decode Gitfile and
+commondir paths with the filesystem codec, and retain the resolved GIT_DIR
+for subprocesses. This rejects cycles like Git instead of recursing and keeps
+commands stable after working-directory changes.
+
+Review feedback noted that a dangling commondir symlink was treated as absent,
+allowing local objects and refs to validate the repository.
+
+Distinguish a truly missing commondir from a dangling symlink. This follows
+Git's get_common_dir_noenv(), whose file_exists check uses lstat before
+attempting to read the entry.
+
+Python 3.9 on Windows raised UnicodeDecodeError while the repository-discovery
+regression test parsed invalid commondir bytes, causing the Python package test
+(windows, 3.9) check to fail.
+
+Treat UnicodeError like an unreadable metadata file in both commondir and
+gitfile parsing. Invalid bytes now make discovery reject the candidate
+repository, matching Git's behavior.
+
+Review feedback noted that GIT_OBJECT_DIRECTORY made discovery succeed without
+becoming the Repo ODB root, while a relative value could later be resolved from
+the Git wrapper's different working directory.
+
+Resolve the environment value against the construction directory, use it as the
+ODB root, and preserve the absolute value for later Git commands. The regression
+moves the only object store outside the git directory and verifies access
+through both GitDB and git cat-file after the original environment and current
+directory are restored.
+
+Review feedback identified four setup mismatches: non-missing .git stat failures
+could fall through to another repository, explicit GIT_DIR could be redirected
+through a nested .git entry, Gitfile reads were not bounded to the stat-reported
+size, and alternates ignored GIT_OBJECT_DIRECTORY.
+
+Match Git setup.c by bypassing discovery for an environment-selected GIT_DIR,
+stopping discovery when Gitfile stat fails for reasons other than a missing
+path, and reading exactly the previously observed Gitfile size. Resolve
+alternates below the active ODB root so custom object stores remain internally
+consistent.
+
+The commit review noted that treating every present GIT_DIR as explicit broke
+two documented cases: an empty GIT_DIR must fall back to current-directory
+discovery, while an empty Repo path must still use a nonempty GIT_DIR.
+
+CVE: CVE-2026-87817
+Upstream-Status: Backport [https://github.com/gitpython-developers/GitPython/commit/c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b]
+
+Backport Changes:
+- Omit upstream regression test changes because the current PyPI 
+  source archive does not include its test suite.
+- Transport the discovery checks into the 3.1.42 Repo constructor/helper 
+  layout. Omit upstream release-note changes.
+
+Assisted-by: GPT 5.6
+Co-authored-by: GPT 5.6 <codex@openai.com>
+(cherry picked from commit c7cf4d13b1ed0a2e70f2a1f3c6b4fc6c2652cf0b)
+Signed-off-by: Yogita Urade <yurade@cisco.com>
+---
+ git/repo/base.py |  101 +++++++++++++++++++++++++++++++---------------
+ git/repo/fun.py  |  119 ++++++++++++++++++++++++++++++++++--------------------
+ 2 files changed, 144 insertions(+), 76 deletions(-)
+
+diff --git a/git/repo/base.py b/git/repo/base.py
+index ebad63b8..c8e8f6ed 100644
+--- a/git/repo/base.py
++++ b/git/repo/base.py
+@@ -218,6 +218,10 @@ class Repo:
+     ) -> None:
+         """Create a new Repo instance.
+ 
++        .. note::
++            Repositories using reftable may be opened, but GitPython's direct reference
++            access does not support reftable.
++
+         :param path:
+             The path to either the root git directory or the bare git repo::
+ 
+@@ -248,7 +252,11 @@ class Repo:
+         :return: git.Repo
+         """
+ 
+-        epath = path or os.getenv("GIT_DIR")
++        git_dir_env = os.getenv("GIT_DIR")
++        object_dir_env = os.getenv("GIT_OBJECT_DIRECTORY")
++        if object_dir_env is not None:
++            object_dir_env = osp.abspath(object_dir_env)
++        epath = path or git_dir_env
+         if not epath:
+             epath = os.getcwd()
+         if Git.is_cygwin():
+@@ -271,17 +279,49 @@ class Repo:
+             if not os.path.exists(epath):
+                 raise NoSuchPathError(epath)
+ 
+-        ## Walk up the path to find the `.git` dir.
+-        #
+-        curpath = epath
+-        git_dir = None
++        # Walk up the path to find the `.git` dir.
++        curpath = os.fspath(epath) if epath is not None else ""
++        git_dir: Optional[str] = None
++        explicit_git_dir = not path and bool(git_dir_env)
+         while curpath:
+             # ABOUT osp.NORMPATH
+-            # It's important to normalize the paths, as submodules will otherwise initialize their
+-            # repo instances with paths that depend on path-portions that will not exist after being
+-            # removed. It's just cleaner.
++            # It's important to normalize the paths, as submodules will otherwise
++            # initialize their repo instances with paths that depend on path-portions
++            # that will not exist after being removed. It's just cleaner.
++            if not explicit_git_dir:
++                dotgit = osp.join(curpath, ".git")
++                try:
++                    sm_gitpath = find_submodule_git_dir(dotgit)
++                except OSError:
++                    break
++                if sm_gitpath is not None:
++                    # Worktrees can use relative paths as of Git 2.48, so join to curpath.
++                    git_dir = osp.normpath(osp.join(curpath, os.fspath(sm_gitpath)))
++                    self._working_tree_dir = curpath
++                    break
++
++                # Like Git, do not fall back to a bare repository or parent directory when
++                # a non-directory .git entry exists but is not a valid gitfile.
++                if osp.exists(dotgit) and not osp.isdir(dotgit):
++                    break
++
+             if is_git_dir(curpath):
+                 git_dir = curpath
++                if osp.isfile(osp.join(curpath, "gitdir")) and osp.isfile(osp.join(curpath, "commondir")):
++                    if "GIT_WORK_TREE" in os.environ:
++                        self._working_tree_dir = os.getenv("GIT_WORK_TREE")
++                    else:
++                        # Linked worktree administrative directories store the path to
++                        # the worktree's .git file in gitdir (without a "gitdir: " prefix).
++                        with open(osp.join(git_dir, "gitdir")) as fp:
++                            worktree_gitfile = fp.read().strip()
++
++                        if not osp.isabs(worktree_gitfile):
++                            worktree_gitfile = osp.normpath(osp.join(git_dir, worktree_gitfile))
++
++                        self._working_tree_dir = osp.dirname(worktree_gitfile)
++                    break
++
+                 # from man git-config : core.worktree
+                 # Set the path to the root of the working tree. If GIT_COMMON_DIR environment
+                 # variable is set, core.worktree is ignored and not used for determining the
+@@ -299,21 +339,7 @@ class Repo:
+                     self._working_tree_dir = os.getenv("GIT_WORK_TREE")
+                 break
+ 
+-            dotgit = osp.join(curpath, ".git")
+-            sm_gitpath = find_submodule_git_dir(dotgit)
+-            if sm_gitpath is not None:
+-                git_dir = osp.normpath(sm_gitpath)
+-
+-            sm_gitpath = find_submodule_git_dir(dotgit)
+-            if sm_gitpath is None:
+-                sm_gitpath = find_worktree_git_dir(dotgit)
+-
+-            if sm_gitpath is not None:
+-                git_dir = expand_path(sm_gitpath, expand_vars)
+-                self._working_tree_dir = curpath
+-                break
+-
+-            if not search_parent_directories:
++            if explicit_git_dir or not search_parent_directories:
+                 break
+             curpath, tail = osp.split(curpath)
+             if not tail:
+@@ -324,6 +350,16 @@ class Repo:
+             raise InvalidGitRepositoryError(epath)
+         self.git_dir = git_dir
+ 
++        common_dir_env = os.getenv("GIT_COMMON_DIR")
++        if common_dir_env is not None:
++            self._common_dir = osp.abspath(common_dir_env)
++        else:
++            try:
++                common_dir = os.fsdecode((Path(self.git_dir) / "commondir").read_bytes()).rstrip("\r\n")
++                self._common_dir = osp.join(self.git_dir, common_dir)
++            except OSError:
++                self._common_dir = ""
++
+         self._bare = False
+         try:
+             self._bare = self.config_reader("repository").getboolean("core", "bare")
+@@ -331,12 +367,6 @@ class Repo:
+             # Let's not assume the option exists, although it should.
+             pass
+ 
+-        try:
+-            common_dir = (Path(self.git_dir) / "commondir").read_text().splitlines()[0].strip()
+-            self._common_dir = osp.join(self.git_dir, common_dir)
+-        except OSError:
+-            self._common_dir = ""
+-
+         # Adjust the working directory in case we are actually bare - we didn't know
+         # that in the first place.
+         if self._bare:
+@@ -345,9 +375,15 @@ class Repo:
+ 
+         self.working_dir: PathLike = self._working_tree_dir or self.common_dir
+         self.git = self.GitCommandWrapperType(self.working_dir)
++        if common_dir_env is not None:
++            self.git.update_environment(GIT_DIR=os.fspath(self.git_dir), GIT_COMMON_DIR=os.fspath(self.common_dir))
++        elif git_dir_env is not None:
++            self.git.update_environment(GIT_DIR=os.fspath(self.git_dir))
++        if object_dir_env is not None:
++            self.git.update_environment(GIT_OBJECT_DIRECTORY=object_dir_env)
+ 
+         # Special handling, in special times.
+-        rootpath = osp.join(self.common_dir, "objects")
++        rootpath = object_dir_env if object_dir_env is not None else osp.join(self.common_dir, "objects")
+         if issubclass(odbt, GitCmdObjectDB):
+             self.odb = odbt(rootpath, self.git)
+         else:
+@@ -876,8 +912,7 @@ class Repo:
+ 
+         :return: List of strings being pathnames of alternates
+         """
+-        if self.git_dir:
+-            alternates_path = osp.join(self.git_dir, "objects", "info", "alternates")
++        alternates_path = osp.join(self.odb.root_path(), "info", "alternates")
+ 
+         if osp.exists(alternates_path):
+             with open(alternates_path, "rb") as f:
+@@ -898,7 +933,7 @@ class Repo:
+             The method does not check for the existence of the paths in alts
+             as the caller is responsible.
+         """
+-        alternates_path = osp.join(self.common_dir, "objects", "info", "alternates")
++        alternates_path = osp.join(self.odb.root_path(), "info", "alternates")
+         if not alts:
+             if osp.isfile(alternates_path):
+                 os.remove(alternates_path)
+diff --git a/git/repo/fun.py b/git/repo/fun.py
+index 63bcfdfc..663b6ee1 100644
+--- a/git/repo/fun.py
++++ b/git/repo/fun.py
+@@ -60,21 +60,61 @@ def is_git_dir(d: "PathLike") -> bool:
+     """This is taken from the git setup.c:is_git_directory
+     function.
+ 
+-    @throws WorkTreeRepositoryUnsupported if it sees a worktree directory. It's quite hacky to do that here,
+-            but at least clearly indicates that we don't support it.
+-            There is the unlikely danger to throw if we see directories which just look like a worktree dir,
+-            but are none."""
++    .. note::
++        This function recognizes repositories using reftable through their
++        compatibility files, but GitPython's direct reference access does not support
++        reftable.
++
++    :raise git.exc.WorkTreeRepositoryUnsupported:
++        If it sees a worktree directory. It's quite hacky to do that here, but at least
++        clearly indicates that we don't support it. There is the unlikely danger to
++        throw if we see directories which just look like a worktree dir, but are none.
++    """
+     if osp.isdir(d):
+-        if (osp.isdir(osp.join(d, "objects")) or "GIT_OBJECT_DIRECTORY" in os.environ) and osp.isdir(
+-            osp.join(d, "refs")
+-        ):
+-            headref = osp.join(d, "HEAD")
+-            return osp.isfile(headref) or (osp.islink(headref) and os.readlink(headref).startswith("refs"))
+-        elif (
+-            osp.isfile(osp.join(d, "gitdir"))
+-            and osp.isfile(osp.join(d, "commondir"))
+-            and osp.isfile(osp.join(d, "gitfile"))
+-        ):
++        headref = osp.join(d, "HEAD")
++        if osp.islink(headref):
++            try:
++                valid_head = os.readlink(headref).startswith("refs/")
++            except OSError:
++                valid_head = False
++        else:
++            try:
++                with open(headref, "rb") as fp:
++                    head = fp.read(256)
++            except OSError:
++                valid_head = False
++            else:
++                valid_head = (head.startswith(b"ref:") and head[4:].lstrip().startswith(b"refs/")) or bool(
++                    re.match(rb"(?:[0-9A-Fa-f]{64}|[0-9A-Fa-f]{40})", head)
++                )
++
++        common_dir = os.getenv("GIT_COMMON_DIR")
++        if common_dir == "":
++            return False
++        if common_dir is None:
++            common_dir_file = Path(d) / "commondir"
++            try:
++                common_dir = os.fsdecode(common_dir_file.read_bytes()).rstrip("\r\n")
++            except FileNotFoundError:
++                if osp.lexists(common_dir_file):
++                    return False
++                common_dir = os.fspath(d)
++            except (OSError, UnicodeError):
++                return False
++            else:
++                if not common_dir:
++                    return False
++                try:
++                    common_dir = osp.realpath(osp.join(d, common_dir))
++                except (OSError, ValueError):
++                    return False
++
++        object_dir = os.getenv("GIT_OBJECT_DIRECTORY")
++        if object_dir is None:
++            object_dir = osp.join(common_dir, "objects")
++        if valid_head and osp.isdir(object_dir) and osp.isdir(osp.join(common_dir, "refs")):
++            return True
++        if osp.isfile(osp.join(d, "gitdir")) and osp.isfile(osp.join(d, "commondir")) and osp.isfile(headref):
+             raise WorkTreeRepositoryUnsupported(d)
+     return False
+ 
+@@ -83,19 +123,20 @@ def find_worktree_git_dir(dotgit: "PathLike") -> Optional[str]:
+     """Search for a gitdir for this worktree."""
+     try:
+         statbuf = os.stat(dotgit)
+-    except OSError:
++    except (FileNotFoundError, NotADirectoryError):
+         return None
+-    if not stat.S_ISREG(statbuf.st_mode):
++    if not stat.S_ISREG(statbuf.st_mode) or statbuf.st_size > (1 << 20):
+         return None
+ 
+     try:
+-        lines = Path(dotgit).read_text().splitlines()
+-        for key, value in [line.strip().split(": ") for line in lines]:
+-            if key == "gitdir":
+-                return value
+-    except ValueError:
+-        pass
+-    return None
++        with open(dotgit, "rb") as fp:
++            content_bytes = fp.read(statbuf.st_size)
++        if len(content_bytes) != statbuf.st_size:
++            return None
++        content = os.fsdecode(content_bytes).rstrip("\r\n")
++    except (OSError, UnicodeError):
++        return None
++    return content[8:] if len(content) >= 9 and content.startswith("gitdir: ") else None
+ 
+ 
+ def find_submodule_git_dir(d: "PathLike") -> Optional["PathLike"]:
+@@ -103,26 +144,18 @@ def find_submodule_git_dir(d: "PathLike") -> Optional["PathLike"]:
+     if is_git_dir(d):
+         return d
+ 
+-    try:
+-        with open(d) as fp:
+-            content = fp.read().rstrip()
+-    except IOError:
+-        # it's probably not a file
+-        pass
+-    else:
+-        if content.startswith("gitdir: "):
+-            path = content[8:]
+-
+-            if Git.is_cygwin():
+-                ## Cygwin creates submodules prefixed with `/cygdrive/...` suffixes.
+-                # Cygwin git understands Cygwin paths much better than Windows ones
+-                # Also the Cygwin tests are assuming Cygwin paths.
+-                path = cygpath(path)
+-            if not osp.isabs(path):
+-                path = osp.normpath(osp.join(osp.dirname(d), path))
+-            return find_submodule_git_dir(path)
+-    # END handle exception
+-    return None
++    path = find_worktree_git_dir(d)
++    if path is None:
++        return None
++
++    if Git.is_cygwin():
++        # Cygwin creates submodules prefixed with `/cygdrive/...`.
++        # Cygwin git understands Cygwin paths much better than Windows ones.
++        # Also the Cygwin tests are assuming Cygwin paths.
++        path = cygpath(path)
++    if not osp.isabs(path):
++        path = osp.normpath(osp.join(osp.dirname(d), path))
++    return path if is_git_dir(path) else None
+ 
+ 
+ def short_to_long(odb: "GitCmdObjectDB", hexsha: str) -> Optional[bytes]:
+-- 
+2.35.6
diff --git a/meta/recipes-devtools/python/python3-git_3.1.42.bb b/meta/recipes-devtools/python/python3-git_3.1.42.bb
index 54bc423fa6..decd1ccc00 100644
--- a/meta/recipes-devtools/python/python3-git_3.1.42.bb
+++ b/meta/recipes-devtools/python/python3-git_3.1.42.bb
@@ -47,6 +47,7 @@  SRC_URI += "            file://CVE-2026-42284.patch \
             file://CVE-2026-78677.patch \
             file://CVE-2026-78676.patch \
             file://CVE-2026-78675.patch \
+            file://CVE-2026-87817.patch \
            "
 SRC_URI[sha256sum] = "2d99869e0fef71a73cbd242528105af1d6c1b108c60dfabd994bf292f76c3ceb"