VYPR
High severity8.8GHSA Advisory· Published May 28, 2026

Dulwich has an arbitrary file write via NTFS-hostile tree entries on Windows

CVE-2026-42305

Description

Impact

Arbitrary file write leading to remote code execution when cloning or checking out a malicious Git repository on Windows.

Dulwich's path-element validator accepted tree entries whose filenames contained bytes that Windows interprets as structural path syntax:

- \ — the Windows path separator. A single tree entry named .git\hooks\pre-commit.exe was treated as one valid filename on POSIX but materialized as nested directories .git/hooks/pre-commit.exe on Windows, planting a file inside the victim's .git directory. Git for Windows then executes that hook on the next git commit, giving the attacker arbitrary code execution in the victim's user context. The same primitive can be used with ..\outside.txt to escape the work tree. - : — the NTFS alternate-data-stream marker. .git::$INDEX_ALLOCATION writes directly into the victim's .git entity, bypassing the .git-as-a-directory check. - git~ — NTFS 8.3 short-name aliases of .git. Only the literal git~1 was rejected; git~2, git~10, GIT~1, etc. were all accepted.

Contributing configuration bugs made matters worse. The core.protectNTFS and core.protectHFS settings were looked up under a wrong option name and so user-set values were silently ignored, and core.protectNTFS only defaulted to true on Windows (Git upstream has defaulted it to true everywhere since CVE-2019-1353). Both have been corrected.

Anyone who clones, fetches, or checks out an untrusted repository with Dulwich on Windows - either through the Dulwich CLI, porcelain.clone, or any downstream tool built on Dulwich - is impacted. POSIX clones are not directly exploitable (on POSIX \ is a literal filename byte), but a POSIX user can unknowingly propagate a malicious tree to Windows consumers via push or re-publication.

Patches

Fixed in Dulwich 1.2.5. Users should upgrade to 1.2.5 or later.

The fix lives in three commits:

  • Read core.protectNTFS / core.protectHFS under their documented option names so user-set values are honored.
  • Default core.protectNTFS to true on every platform, matching Git's PROTECT_NTFS_DEFAULT=1.
  • Reject \, :, and all git~ 8.3 short-name forms in validate_path_element_ntfs.

Workarounds

There is no effective pre-patch workaround. On affected versions the core.protectNTFS configuration key was silently ignored, so setting it to true does not mitigate the issue. Users who cannot upgrade should avoid cloning, fetching, or checking out untrusted repositories with Dulwich on Windows. After upgrading the NTFS validator is on by default on every platform, so no additional configuration is required.

Resources

  • Git upstream path validation: https://github.com/git/git/blob/master/path.c (is_ntfs_dotgit, verify_path)
  • CVE-2019-1353 — the Git upstream vulnerability that established core.protectNTFS = true as the cross-platform default
  • CVE-2019-1354 — backslash-in-tree-path class in Git, analogous to this issue

AI Insight

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

Dulwich on Windows fails to validate path separators, NTFS streams, and short names, enabling arbitrary file write and RCE via malicious Git repository clones.

Vulnerability

Dulwich's path-element validator (validate_path_element_ntfs) accepted tree entries containing characters that Windows treats as structural path syntax, specifically the backslash (\, Windows path separator), the colon (:, NTFS alternate-data-stream marker), and NTFS 8.3 short-name aliases of .git (e.g., git~2, GIT~1). This allowed a single tree entry like .git\hooks\pre-commit.exe to be materialized as nested directories on Windows, planting a file inside the victim's .git directory. Additionally, configuration settings core.protectNTFS and core.protectHFS were looked up under incorrect option names, causing user-set values to be silently ignored, and core.protectNTFS only defaulted to true on Windows. Affected are all Dulwich versions prior to 1.2.5 when used on Windows[1][3].

Exploitation

An attacker hosts a malicious Git repository containing tree entries with crafted filenames (e.g., .git\hooks\pre-commit.exe, ..\outside.txt, or .git::$INDEX_ALLOCATION). A victim on Windows who clones, fetches, or checks out this repository using Dulwich (via CLI, porcelain.clone, or any downstream tool) will have those entries written to the file system with the structural interpretation. The attacker requires no authentication; only victim interaction (clone/checkout) is needed. After checkout, the planted hook file resides in .git/hooks/, and Git for Windows executes it on the next git commit, achieving arbitrary code execution. The same primitive can escape the work tree using ..\outside.txt or write directly to .git via the ADS marker[1].

Impact

Successful exploitation enables arbitrary file write within the user's repository, leading to remote code execution in the victim's user context. The attacker can plant a Git hook (e.g., pre-commit.exe) that executes upon the next commit, gaining control over the user's session. Additionally, files can be written outside the work tree, potentially overwriting sensitive files. The attack does not require elevated privileges beyond the victim's own[1].

Mitigation

The vulnerability is fixed in Dulwich version 1.2.5, released on 2026-05-28[3]. Users should upgrade to 1.2.5 or later immediately. The fix corrects path validation to reject Windows-hostile characters, defaults core.protectNTFS to true on all platforms, and reads the configuration options under their correct names. No workaround is available; upgrading is the only mitigation. This CVE is not listed in CISA's Known Exploited Vulnerabilities (KEV) catalog as of publication[1][3].

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

9
49eb56e51aad

Add NEWS entry for CVE-2026-42305

https://github.com/jelmer/dulwichJelmer VernooijMay 28, 2026Fixed in dulwich-1.2.5via github-commit-search
1 file changed · +27 23
  • NEWS+27 23 modified
    @@ -1,3 +1,30 @@
    +1.2.5	UNRELEASED
    +
    + * SECURITY(CVE-2026-42305): Harden tree path validation against entry
    +   names that are harmless on POSIX but dangerous when checked out on
    +   Windows. A crafted tree could previously carry such names through to
    +   the work tree. ``validate_path_element_ntfs`` now also rejects:
    +
    +   - Windows path separators, so an entry named
    +     ``.git\hooks\pre-commit.exe`` can no longer materialize a file
    +     inside ``.git`` that Git for Windows would execute.
    +   - The alternate data stream marker ``:`` (e.g.
    +     ``.git::$INDEX_ALLOCATION``, which writes into ``.git`` directly).
    +   - NTFS 8.3 short-name aliases of ``.git`` (``git~<digits>``); only
    +     ``git~1`` was rejected before.
    +   - Reserved Windows device names (``CON``, ``PRN``, ``AUX``, ``NUL``,
    +     ``COM1``-``COM9``, ``LPT1``-``LPT9``), including with an extension or
    +     trailing dots/spaces such as ``NUL.txt`` or ``COM1 .bar``.
    +
    +   In addition, ``core.protectNTFS`` now defaults to true on every
    +   platform (matching git after CVE-2019-1353), so a POSIX clone no longer
    +   accepts paths that would be unsafe on a later Windows clone, and both
    +   ``core.protectNTFS`` and ``core.protectHFS`` are now read under their
    +   correct option names, having previously been silently ignored. POSIX
    +   users who need literal NTFS-unsafe filenames can opt out with
    +   ``core.protectNTFS=false``.
    +   (Jelmer Vernooij; reported by Christopher Toth)
    +
     1.2.4	2026-05-21
     
      * Tolerate ref names with empty path components (e.g. ``refs/tags//v1.0``)
    @@ -114,29 +141,6 @@
        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.
    -
    - * 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
    
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."""
    
6db10e4b67e7
https://github.com/jelmer/dulwichFixed in dulwich-1.2.5via ghsa-release-walk
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 ghsa-release-walk
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")))
     
     
    
1ca18147a1d0

submodule: Reject unsafe submodule paths in submodule_update

https://github.com/jelmer/dulwichJelmer VernooijMay 28, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
6 files changed · +171 30
  • dulwich/index.py+25 0 modified
    @@ -60,6 +60,7 @@
         "commit_tree",
         "detect_case_only_renames",
         "get_path_element_normalizer",
    +    "get_path_element_validator",
         "get_unstaged_changes",
         "index_entry_from_stat",
         "make_path_normalizer",
    @@ -2073,6 +2074,30 @@ def validate_path_element_hfs(element: bytes) -> bool:
         return True
     
     
    +def get_path_element_validator(config: "Config") -> Callable[[bytes], bool]:
    +    """Get the path-element validator to use when checking out a tree.
    +
    +    ``core.protectNTFS`` defaults to true on every platform (matching Git's
    +    ``PROTECT_NTFS_DEFAULT=1``) because a repository authored on POSIX can
    +    still be cloned on Windows later; ``core.protectHFS`` defaults to true on
    +    macOS. With both disabled this falls back to the default validator, which
    +    only refuses ``.git``, ``.`` and ``..``.
    +
    +    Args:
    +        config: Repository configuration object
    +
    +    Returns:
    +        Function that validates a single path element for the configured
    +        filesystem protections.
    +    """
    +    if config.get_boolean(b"core", b"protectNTFS", True):
    +        return validate_path_element_ntfs
    +    elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
    +        return validate_path_element_hfs
    +    else:
    +        return validate_path_element_default
    +
    +
     def validate_path(
         path: bytes,
         element_validator: Callable[[bytes], bool] = validate_path_element_default,
    
  • dulwich/porcelain/submodule.py+28 2 modified
    @@ -22,7 +22,7 @@
     """Porcelain functions for working with submodules."""
     
     import os
    -from collections.abc import Iterator, Sequence
    +from collections.abc import Callable, Iterator, Sequence
     from typing import TYPE_CHECKING, BinaryIO
     
     from ..config import ConfigFile, read_submodules
    @@ -87,6 +87,29 @@ def submodule_init(repo: str | os.PathLike[str] | Repo) -> None:
             config.write_to_path()
     
     
    +def _check_submodule_path(path: bytes, validator: Callable[[bytes], bool]) -> None:
    +    """Reject submodule paths that would escape the working tree.
    +
    +    Args:
    +      path: Submodule path as it appears in the tree gitlink entry.
    +      validator: Path-element validator selected for this repository.
    +
    +    Raises:
    +      Error: If the path is absolute or carries a component (e.g. ``.git`` or
    +        ``..``) that the validator rejects. This is the same bar git applies
    +        to submodule paths, not a stricter one.
    +    """
    +    from ..index import validate_path
    +    from . import Error
    +
    +    # Tree paths always use "/" as the separator; a leading "/" or "\\" would
    +    # make os.path.join discard the repository root, so treat it as absolute.
    +    if path.startswith((b"/", b"\\")):
    +        raise Error(f"refusing submodule with absolute path: {path!r}")
    +    if not validate_path(path, validator):
    +        raise Error(f"refusing submodule with unsafe path: {path!r}")
    +
    +
     def submodule_list(repo: "RepoPath") -> Iterator[tuple[str, str]]:
         """List submodules.
     
    @@ -122,7 +145,7 @@ def submodule_update(
           errstream: Error stream for error messages
         """
         from ..client import get_transport_and_path
    -    from ..index import build_index_from_tree
    +    from ..index import build_index_from_tree, get_path_element_validator
         from ..refs import HEADREF
         from ..submodule import iter_cached_submodules
         from . import (
    @@ -138,12 +161,14 @@ def submodule_update(
     
             config = r.get_config()
             gitmodules_path = os.path.join(r.path, ".gitmodules")
    +        path_validator = get_path_element_validator(config)
     
             # Get list of submodules to update
             submodules_to_update = []
             head_commit = r[r.head()]
             assert isinstance(head_commit, Commit)
             for path, sha in iter_cached_submodules(r.object_store, head_commit.tree):
    +            _check_submodule_path(path, path_validator)
                 path_str = (
                     path.decode(DEFAULT_ENCODING) if isinstance(path, bytes) else path
                 )
    @@ -231,6 +256,7 @@ def submodule_update(
                             sub_repo.index_path(),
                             sub_repo.object_store,
                             tree_id,
    +                        validate_path_element=path_validator,
                         )
                 else:
                     # Fetch and checkout in existing submodule
    
  • dulwich/stash.py+2 14 modified
    @@ -28,7 +28,6 @@
     ]
     
     import os
    -import sys
     from typing import TYPE_CHECKING, TypedDict
     
     from .diff_tree import tree_changes
    @@ -38,14 +37,12 @@
         _tree_to_fs_path,
         build_file_from_blob,
         commit_tree,
    +    get_path_element_validator,
         index_entry_from_stat,
         iter_fresh_objects,
         symlink,
         update_working_tree,
         validate_path,
    -    validate_path_element_default,
    -    validate_path_element_hfs,
    -    validate_path_element_ntfs,
     )
     from .object_store import iter_tree_contents
     from .objects import S_IFGITLINK, Blob, Commit, ObjectID, TreeEntry
    @@ -163,16 +160,7 @@ def pop(self, index: int, *, config: "Config | None" = None) -> "Entry":
             # Get config for working directory update
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", 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
    -        else:
    -            validate_path_element = validate_path_element_default
    +        validate_path_element = get_path_element_validator(config)
     
             if config.get_boolean(b"core", b"symlinks", True):
                 symlink_fn = symlink
    
  • dulwich/worktree.py+2 13 modified
    @@ -43,7 +43,6 @@
     import os
     import shutil
     import stat
    -import sys
     import tempfile
     import time
     import warnings
    @@ -807,10 +806,8 @@ def reset_index(
             stacked_config = config
             from .index import (
                 build_index_from_tree,
    +            get_path_element_validator,
                 symlink,
    -            validate_path_element_default,
    -            validate_path_element_hfs,
    -            validate_path_element_ntfs,
             )
     
             if tree is None:
    @@ -824,15 +821,7 @@ def reset_index(
                 tree = head.tree
             config = self._repo.get_config()
             honor_filemode = config.get_boolean(b"core", b"filemode", 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
    -        else:
    -            validate_path_element = validate_path_element_default
    +        validate_path_element = get_path_element_validator(config)
             if config.get_boolean(b"core", b"symlinks", True):
                 symlink_fn = symlink
             else:
    
  • NEWS+14 1 modified
    @@ -1,5 +1,18 @@
     1.2.5	UNRELEASED
     
    + * SECURITY(GHSA-gfhv-vqv2-4544): Validate submodule paths in
    +   ``porcelain.submodule_update`` (and thus
    +   ``porcelain.clone(recurse_submodules=True)``). A crafted upstream
    +   repository could carry a submodule whose path was ``.git/hooks`` (or
    +   any other path inside ``.git`` or above the work tree), causing the
    +   submodule's tree contents to be written there with their executable
    +   bits intact -- dropping a hook that later commands would run. Submodule
    +   paths are now rejected if they are absolute or carry a component that
    +   the configured path validator refuses, and the submodule's own tree is
    +   materialized with the same validator. This is the dulwich analogue of git's
    +   CVE-2024-32002 / CVE-2024-32004.
    +   (Jelmer Vernooij; reported by tonghuaroot)
    +
      * SECURITY(CVE-2026-42305): Harden tree path validation against entry
        names that are harmless on POSIX but dangerous when checked out on
        Windows. A crafted tree could previously carry such names through to
    @@ -47,7 +60,7 @@
        unlimited, matching git's semantics) and ``ReceivePackHandler``
        reads ``receive.maxInputSize`` from the repository config and
        passes it through. Exceeding the cap raises ``PackInputTooLarge``.
    -   (Jelmer Vernooij; Reported by Liyi, Ziyue, Strick, Maurice and Chenchen @ Univeristy of Sydney)
    +   (Jelmer Vernooij; Reported by Liyi, Ziyue, Strick, Maurice and Chenchen @ University of Sydney)
     
     1.2.4	2026-05-21
     
    
  • tests/porcelain/__init__.py+100 0 modified
    @@ -5645,6 +5645,106 @@ def test_update_recursive(self) -> None:
             with open(nested_submodule_file) as f:
                 self.assertEqual(f.read(), "nested submodule content")
     
    +    def _build_malicious_submodule_repo(self, submodule_path):
    +        """Build a parent repo whose gitlink path is attacker-controlled.
    +
    +        Returns the path to a bare attacker submodule repository and commits a
    +        matching ``.gitmodules`` plus tree gitlink entry into ``self.repo``,
    +        both pointing at ``submodule_path``.
    +        """
    +        attacker_path = tempfile.mkdtemp()
    +        self.addCleanup(shutil.rmtree, attacker_path)
    +        attacker = Repo.init_bare(attacker_path, mkdir=False)
    +        self.addCleanup(attacker.close)
    +
    +        payload = Blob.from_string(b"#!/bin/sh\necho PWNED\n")
    +        attacker.object_store.add_object(payload)
    +        tree = Tree()
    +        tree.add(b"post-checkout", 0o100755, payload.id)
    +        attacker.object_store.add_object(tree)
    +        commit = Commit()
    +        commit.tree = tree.id
    +        commit.author = commit.committer = b"a <a@a>"
    +        commit.author_time = commit.commit_time = 0
    +        commit.author_timezone = commit.commit_timezone = 0
    +        commit.message = b"payload"
    +        attacker.object_store.add_object(commit)
    +        attacker.refs[b"refs/heads/master"] = commit.id
    +        attacker.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
    +
    +        gitmodules = (
    +            b'[submodule "evil"]\n'
    +            b"\tpath = " + submodule_path + b"\n"
    +            b"\turl = " + attacker_path.encode() + b"\n"
    +        )
    +        # A real clone checks .gitmodules out into the work tree; write it
    +        # directly so submodule_update can read it without a full checkout.
    +        with open(os.path.join(self.repo.path, ".gitmodules"), "wb") as f:
    +            f.write(gitmodules)
    +        gmb = Blob.from_string(gitmodules)
    +        self.repo.object_store.add_object(gmb)
    +        vt = Tree()
    +        vt.add(b".gitmodules", 0o100644, gmb.id)
    +        vt.add(submodule_path, 0o160000, commit.id)
    +        self.repo.object_store.add_object(vt)
    +        vc = Commit()
    +        vc.tree = vt.id
    +        vc.author = vc.committer = b"a <a@a>"
    +        vc.author_time = vc.commit_time = 0
    +        vc.author_timezone = vc.commit_timezone = 0
    +        vc.message = b"parent"
    +        self.repo.object_store.add_object(vc)
    +        self.repo.refs[b"refs/heads/master"] = vc.id
    +        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
    +        return attacker_path
    +
    +    def test_update_rejects_dotgit_path(self) -> None:
    +        # A submodule path of .git/hooks would drop the attacker's tree
    +        # into the parent's .git/hooks directory (CVE-style RCE via hooks).
    +        self._build_malicious_submodule_repo(b".git/hooks")
    +        self.assertRaises(
    +            porcelain.Error,
    +            porcelain.submodule_update,
    +            self.repo,
    +            init=True,
    +        )
    +        hook = os.path.join(self.repo.path, ".git", "hooks", "post-checkout")
    +        self.assertFalse(os.path.exists(hook))
    +
    +    def test_update_rejects_parent_traversal_path(self) -> None:
    +        self._build_malicious_submodule_repo(b"../escape")
    +        self.assertRaises(
    +            porcelain.Error,
    +            porcelain.submodule_update,
    +            self.repo,
    +            init=True,
    +        )
    +
    +    def test_check_submodule_path(self) -> None:
    +        from dulwich.index import (
    +            validate_path_element_default,
    +            validate_path_element_ntfs,
    +        )
    +        from dulwich.porcelain.submodule import _check_submodule_path
    +
    +        # .git and .. components are rejected on every platform.
    +        for bad in (b".git/hooks", b"..", b"a/../b", b"/abs", b".git"):
    +            self.assertRaises(
    +                porcelain.Error, _check_submodule_path, bad, validate_path_element_ntfs
    +            )
    +
    +        # A path that is only unsafe on NTFS (a reserved device name) is
    +        # refused under the default protectNTFS validator but, like git with
    +        # core.protectNTFS=false, accepted by the default validator so an
    +        # existing POSIX repository can still be updated.
    +        self.assertRaises(
    +            porcelain.Error, _check_submodule_path, b"aux", validate_path_element_ntfs
    +        )
    +        _check_submodule_path(b"aux", validate_path_element_default)
    +
    +        # Ordinary nested paths pass under either validator.
    +        _check_submodule_path(b"libs/foo", validate_path_element_ntfs)
    +
     
     class PushTests(PorcelainTestCase):
         def test_simple(self) -> None:
    
c2446e51b49e

patch: Sanitize commit subject in get_summary to prevent format_patch escapes

https://github.com/jelmer/dulwichJelmer VernooijMay 19, 2026Fixed in dulwich-1.2.5via ghsa-release-walk
4 files changed · +178 5
  • dulwich/patch.py+52 2 modified
    @@ -171,16 +171,66 @@ def write_commit_patch(
             f.write(version.encode(encoding) + b"\n")
     
     
    +def _sanitize_subject_for_filename(text: str, max_length: int = 52) -> str:
    +    """Sanitize a string for safe use as part of a filename.
    +
    +    Matches git's ``format_sanitized_subject`` behavior:
    +
    +    - Only ``[A-Za-z0-9._]`` are kept; other characters become ``-``
    +      (collapsed across runs).
    +    - Consecutive ``.`` are collapsed to a single ``.``.
    +    - The result is truncated to ``max_length`` characters.
    +    - Trailing ``.`` and ``-`` are stripped.
    +
    +    Args:
    +      text: Input string (typically a commit subject line).
    +      max_length: Maximum length of the returned string.
    +
    +    Returns: Sanitized string safe to embed in a filename.
    +    """
    +    result: list[str] = []
    +    # 2 = initial, 1 = saw a non-title char, 0 = saw a title char
    +    space = 2
    +    i = 0
    +    text_len = len(text)
    +    while i < text_len:
    +        c = text[i]
    +        if ("A" <= c <= "Z") or ("a" <= c <= "z") or ("0" <= c <= "9") or c in "._":
    +            if space == 1:
    +                result.append("-")
    +            space = 0
    +            result.append(c)
    +            if c == ".":
    +                while i + 1 < text_len and text[i + 1] == ".":
    +                    i += 1
    +        else:
    +            space |= 1
    +        i += 1
    +        if len(result) >= max_length:
    +            break
    +
    +    return "".join(result)[:max_length].rstrip(".-")
    +
    +
     def get_summary(commit: "Commit") -> str:
         """Determine the summary line for use in a filename.
     
    +    Sanitizes the commit subject so it is safe to use as a filename
    +    component, matching git's ``format_sanitized_subject`` behavior:
    +    characters outside ``[A-Za-z0-9._]`` are replaced with ``-`` (with
    +    runs collapsed) and consecutive ``.`` are collapsed. The result is
    +    also length-limited to prevent overly long filenames.
    +
         Args:
           commit: Commit
    -    Returns: Summary string
    +    Returns: Sanitized summary string suitable for use as a filename
    +      component.
         """
         decoded = commit.message.decode(errors="replace")
         lines = decoded.splitlines()
    -    return lines[0].replace(" ", "-") if lines else ""
    +    if not lines:
    +        return ""
    +    return _sanitize_subject_for_filename(lines[0])
     
     
     #  Unified Diff
    
  • NEWS+8 0 modified
    @@ -1,3 +1,11 @@
    +1.2.3	UNRELEASED
    +
    + * SECURITY: Sanitize commit subjects used in
    +   ``porcelain.format_patch`` filenames so a malicious subject (e.g.
    +   ``x/../../x``) cannot direct the generated patch outside ``outdir``.
    +   ``get_summary`` now matches git's ``format_sanitized_subject``.
    +   (Jelmer Vernooij)
    +
     1.2.2	2026-05-19
     
      * Normalise hex SHAs to binary in ``DiskObjectStore.contains_packed``
    
  • tests/porcelain/__init__.py+70 0 modified
    @@ -3155,6 +3155,76 @@ def test_format_patch_no_commits(self) -> None:
             )
             self.assertEqual(patches, [])
     
    +    def test_format_patch_subject_cannot_escape_outdir(self) -> None:
    +        # A malicious commit subject must not be able to direct the
    +        # generated patch file outside the requested output directory.
    +        tree1 = Tree()
    +        c1 = make_commit(tree=tree1, message=b"Initial commit")
    +        self.repo.object_store.add_objects([(tree1, None), (c1, None)])
    +
    +        blob = Blob.from_string(b"data")
    +        tree2 = Tree()
    +        tree2.add(b"f.txt", 0o100644, blob.id)
    +        # Subjects that try to traverse out of outdir via path separators
    +        # or parent-directory components.
    +        for evil in (b"x/../../x", b"x\\..\\..\\x", b"a:b"):
    +            c2 = make_commit(
    +                tree=tree2,
    +                parents=[c1.id],
    +                message=evil,
    +            )
    +            self.repo.object_store.add_objects(
    +                [(blob, None), (tree2, None), (c2, None)]
    +            )
    +            self.repo[b"HEAD"] = c2.id
    +
    +            with tempfile.TemporaryDirectory() as tmpdir:
    +                patches = porcelain.format_patch(
    +                    self.repo.path,
    +                    committish=c2.id,
    +                    outdir=tmpdir,
    +                )
    +                self.assertEqual(len(patches), 1)
    +                real_outdir = os.path.realpath(tmpdir)
    +                real_patch = os.path.realpath(patches[0])
    +                self.assertEqual(
    +                    os.path.dirname(real_patch),
    +                    real_outdir,
    +                    f"patch for subject {evil!r} escaped outdir: {patches[0]}",
    +                )
    +                # Filename must not contain path separators or parent refs.
    +                base = os.path.basename(patches[0])
    +                self.assertNotIn("/", base)
    +                self.assertNotIn("\\", base)
    +                self.assertNotIn("..", base)
    +
    +    def test_format_patch_long_subject_truncated(self) -> None:
    +        tree1 = Tree()
    +        c1 = make_commit(tree=tree1, message=b"Initial commit")
    +        self.repo.object_store.add_objects([(tree1, None), (c1, None)])
    +
    +        blob = Blob.from_string(b"data")
    +        tree2 = Tree()
    +        tree2.add(b"f.txt", 0o100644, blob.id)
    +        c2 = make_commit(
    +            tree=tree2,
    +            parents=[c1.id],
    +            message=b"a" * 500,
    +        )
    +        self.repo.object_store.add_objects([(blob, None), (tree2, None), (c2, None)])
    +        self.repo[b"HEAD"] = c2.id
    +
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            patches = porcelain.format_patch(
    +                self.repo.path,
    +                committish=c2.id,
    +                outdir=tmpdir,
    +            )
    +            self.assertEqual(len(patches), 1)
    +            # Whatever the cap is, an extremely long subject must not
    +            # produce a pathologically long filename.
    +            self.assertLess(len(os.path.basename(patches[0])), 100)
    +
     
     class SymbolicRefTests(PorcelainTestCase):
         def test_set_wrong_symbolic_ref(self) -> None:
    
  • tests/test_patch.py+48 3 modified
    @@ -641,19 +641,64 @@ def test_object_diff_kind_change(self) -> None:
     
     
     class GetSummaryTests(TestCase):
    -    def test_simple(self) -> None:
    -        c = make_commit(
    +    def _make_commit(self, message: bytes) -> Commit:
    +        return make_commit(
                 author=b"Jelmer <jelmer@samba.org>",
                 committer=b"Jelmer <jelmer@samba.org>",
                 author_time=1271350201,
                 commit_time=1271350201,
                 author_timezone=0,
                 commit_timezone=0,
    -            message=b"This is the first line\nAnd this is the second line.\n",
    +            message=message,
                 tree=Tree().id,
             )
    +
    +    def test_simple(self) -> None:
    +        c = self._make_commit(
    +            b"This is the first line\nAnd this is the second line.\n",
    +        )
             self.assertEqual("This-is-the-first-line", get_summary(c))
     
    +    def test_empty_message(self) -> None:
    +        c = self._make_commit(b"")
    +        self.assertEqual("", get_summary(c))
    +
    +    def test_path_traversal_unix(self) -> None:
    +        c = self._make_commit(b"x/../../x\n")
    +        # Path separators and consecutive dots must be sanitized so the
    +        # result cannot escape the requested output directory.
    +        summary = get_summary(c)
    +        self.assertNotIn("/", summary)
    +        self.assertNotIn("..", summary)
    +        self.assertEqual("x-.-.-x", summary)
    +
    +    def test_path_traversal_windows(self) -> None:
    +        c = self._make_commit(b"x\\..\\..\\x\n")
    +        summary = get_summary(c)
    +        self.assertNotIn("\\", summary)
    +        self.assertNotIn("..", summary)
    +        self.assertEqual("x-.-.-x", summary)
    +
    +    def test_colon_sanitized(self) -> None:
    +        c = self._make_commit(b"feature: do something\n")
    +        summary = get_summary(c)
    +        self.assertNotIn(":", summary)
    +        self.assertEqual("feature-do-something", summary)
    +
    +    def test_long_subject_truncated(self) -> None:
    +        c = self._make_commit(b"a" * 500 + b"\n")
    +        summary = get_summary(c)
    +        self.assertLessEqual(len(summary), 64)
    +
    +    def test_trailing_dots_and_dashes_stripped(self) -> None:
    +        c = self._make_commit(b"hello...\n")
    +        self.assertEqual("hello", get_summary(c))
    +
    +    def test_only_problematic_chars(self) -> None:
    +        c = self._make_commit(b"/\\:..\n")
    +        # After sanitization there is nothing usable left.
    +        self.assertEqual("", get_summary(c))
    +
     
     class DiffAlgorithmTests(TestCase):
         """Tests for diff algorithm selection."""
    

Vulnerability mechanics

Root cause

"Missing validation of Windows-interpreted bytes (\, :, and git~<digits> short-name forms) in tree path elements, combined with a config lookup bug that silently ignored user-set core.protectNTFS/core.protectHFS values and a platform-dependent default that left POSIX clones unprotected."

Attack vector

An attacker crafts a Git tree on POSIX containing entries such as `.git\hooks\pre-commit.exe` (backslash as path separator), `..\outside.txt` (work-tree escape), `.git::$INDEX_ALLOCATION` (alternate data stream), or `git~2` (NTFS 8.3 short-name alias of `.git`). When a victim clones or checks out this repository on Windows using Dulwich, the path-element validator accepts these entries because it only rejected the literal `git~1` and did not check for `\` or `:`. The operating system materializes the backslash as nested directories, planting a file inside `.git\hooks\` which Git for Windows executes on the next `git commit`, achieving arbitrary code execution in the victim's user context [ref_id=1][ref_id=2]. The attack requires no authentication beyond network access to the repository; any Dulwich-based clone, fetch, or checkout on Windows is vulnerable.

Affected code

The vulnerable path-element validation lives in `dulwich/index.py` in the `validate_path_element_ntfs` function, which previously only checked for the literal `git~1` and did not reject `\` or `:` [patch_id=3016214]. The config lookup bug affected three call sites: `dulwich/porcelain/__init__.py`, `dulwich/stash.py`, and `dulwich/worktree.py`, all of which used the wrong option name `core.protectNTFS` instead of `protectNTFS` [patch_id=3016213]. The platform-dependent default (`os.name == "nt"`) was changed in all three files plus the default in `validate_path_element_ntfs` selection logic [patch_id=3016215].

What the fix does

Three commits fix the vulnerability. [patch_id=3016213] corrects the config key lookup: the code was reading `get_boolean(b"core", b"core.protectNTFS", ...)` (a key that never matches a real config entry), so user-set values were silently ignored; the fix changes the option name to `protectNTFS` (and `protectHFS` respectively). [patch_id=3016215] changes the default of `core.protectNTFS` from `os.name == "nt"` (Windows-only) to `True` on every platform, matching Git's `PROTECT_NTFS_DEFAULT=1` so a POSIX clone no longer accepts paths that would be dangerous on a later Windows clone. [patch_id=3016214] hardens `validate_path_element_ntfs` to reject any element containing `\` (Windows path separator), `:` (alternate data stream marker), or any `git~<digits>` form (NTFS 8.3 short-name alias of `.git`), not just the literal `git~1` [ref_id=1][ref_id=2].

Preconditions

  • platformVictim must be running Windows (NTFS filesystem)
  • inputVictim must clone, fetch, or check out a malicious repository using Dulwich (CLI, porcelain.clone, or downstream tool)
  • networkAttacker must have network access to deliver the malicious repository (e.g., public Git hosting, direct push, or social engineering)

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.