Skip to content

Commit c2446e5

Browse files
committed
patch: Sanitize commit subject in get_summary to prevent format_patch escapes
A malicious commit subject (e.g. ``x/../../x`` or ``x\..\..\x``) could direct ``porcelain.format_patch`` to write its patch outside the requested ``outdir`` because ``get_summary`` only replaced spaces with dashes. Match git's ``format_sanitized_subject``: keep only ``[A-Za-z0-9._]``, collapse other character runs to ``-``, collapse consecutive ``.``, length-limit, and trim trailing ``.``/``-``.
1 parent 951b008 commit c2446e5

4 files changed

Lines changed: 178 additions & 5 deletions

File tree

NEWS

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
1.2.3 UNRELEASED
2+
3+
* SECURITY: Sanitize commit subjects used in
4+
``porcelain.format_patch`` filenames so a malicious subject (e.g.
5+
``x/../../x``) cannot direct the generated patch outside ``outdir``.
6+
``get_summary`` now matches git's ``format_sanitized_subject``.
7+
(Jelmer Vernooij)
8+
19
1.2.2 2026-05-19
210

311
* Normalise hex SHAs to binary in ``DiskObjectStore.contains_packed``

dulwich/patch.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,16 +171,66 @@ def write_commit_patch(
171171
f.write(version.encode(encoding) + b"\n")
172172

173173

174+
def _sanitize_subject_for_filename(text: str, max_length: int = 52) -> str:
175+
"""Sanitize a string for safe use as part of a filename.
176+
177+
Matches git's ``format_sanitized_subject`` behavior:
178+
179+
- Only ``[A-Za-z0-9._]`` are kept; other characters become ``-``
180+
(collapsed across runs).
181+
- Consecutive ``.`` are collapsed to a single ``.``.
182+
- The result is truncated to ``max_length`` characters.
183+
- Trailing ``.`` and ``-`` are stripped.
184+
185+
Args:
186+
text: Input string (typically a commit subject line).
187+
max_length: Maximum length of the returned string.
188+
189+
Returns: Sanitized string safe to embed in a filename.
190+
"""
191+
result: list[str] = []
192+
# 2 = initial, 1 = saw a non-title char, 0 = saw a title char
193+
space = 2
194+
i = 0
195+
text_len = len(text)
196+
while i < text_len:
197+
c = text[i]
198+
if ("A" <= c <= "Z") or ("a" <= c <= "z") or ("0" <= c <= "9") or c in "._":
199+
if space == 1:
200+
result.append("-")
201+
space = 0
202+
result.append(c)
203+
if c == ".":
204+
while i + 1 < text_len and text[i + 1] == ".":
205+
i += 1
206+
else:
207+
space |= 1
208+
i += 1
209+
if len(result) >= max_length:
210+
break
211+
212+
return "".join(result)[:max_length].rstrip(".-")
213+
214+
174215
def get_summary(commit: "Commit") -> str:
175216
"""Determine the summary line for use in a filename.
176217
218+
Sanitizes the commit subject so it is safe to use as a filename
219+
component, matching git's ``format_sanitized_subject`` behavior:
220+
characters outside ``[A-Za-z0-9._]`` are replaced with ``-`` (with
221+
runs collapsed) and consecutive ``.`` are collapsed. The result is
222+
also length-limited to prevent overly long filenames.
223+
177224
Args:
178225
commit: Commit
179-
Returns: Summary string
226+
Returns: Sanitized summary string suitable for use as a filename
227+
component.
180228
"""
181229
decoded = commit.message.decode(errors="replace")
182230
lines = decoded.splitlines()
183-
return lines[0].replace(" ", "-") if lines else ""
231+
if not lines:
232+
return ""
233+
return _sanitize_subject_for_filename(lines[0])
184234

185235

186236
# Unified Diff

tests/porcelain/__init__.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3155,6 +3155,76 @@ def test_format_patch_no_commits(self) -> None:
31553155
)
31563156
self.assertEqual(patches, [])
31573157

3158+
def test_format_patch_subject_cannot_escape_outdir(self) -> None:
3159+
# A malicious commit subject must not be able to direct the
3160+
# generated patch file outside the requested output directory.
3161+
tree1 = Tree()
3162+
c1 = make_commit(tree=tree1, message=b"Initial commit")
3163+
self.repo.object_store.add_objects([(tree1, None), (c1, None)])
3164+
3165+
blob = Blob.from_string(b"data")
3166+
tree2 = Tree()
3167+
tree2.add(b"f.txt", 0o100644, blob.id)
3168+
# Subjects that try to traverse out of outdir via path separators
3169+
# or parent-directory components.
3170+
for evil in (b"x/../../x", b"x\\..\\..\\x", b"a:b"):
3171+
c2 = make_commit(
3172+
tree=tree2,
3173+
parents=[c1.id],
3174+
message=evil,
3175+
)
3176+
self.repo.object_store.add_objects(
3177+
[(blob, None), (tree2, None), (c2, None)]
3178+
)
3179+
self.repo[b"HEAD"] = c2.id
3180+
3181+
with tempfile.TemporaryDirectory() as tmpdir:
3182+
patches = porcelain.format_patch(
3183+
self.repo.path,
3184+
committish=c2.id,
3185+
outdir=tmpdir,
3186+
)
3187+
self.assertEqual(len(patches), 1)
3188+
real_outdir = os.path.realpath(tmpdir)
3189+
real_patch = os.path.realpath(patches[0])
3190+
self.assertEqual(
3191+
os.path.dirname(real_patch),
3192+
real_outdir,
3193+
f"patch for subject {evil!r} escaped outdir: {patches[0]}",
3194+
)
3195+
# Filename must not contain path separators or parent refs.
3196+
base = os.path.basename(patches[0])
3197+
self.assertNotIn("/", base)
3198+
self.assertNotIn("\\", base)
3199+
self.assertNotIn("..", base)
3200+
3201+
def test_format_patch_long_subject_truncated(self) -> None:
3202+
tree1 = Tree()
3203+
c1 = make_commit(tree=tree1, message=b"Initial commit")
3204+
self.repo.object_store.add_objects([(tree1, None), (c1, None)])
3205+
3206+
blob = Blob.from_string(b"data")
3207+
tree2 = Tree()
3208+
tree2.add(b"f.txt", 0o100644, blob.id)
3209+
c2 = make_commit(
3210+
tree=tree2,
3211+
parents=[c1.id],
3212+
message=b"a" * 500,
3213+
)
3214+
self.repo.object_store.add_objects([(blob, None), (tree2, None), (c2, None)])
3215+
self.repo[b"HEAD"] = c2.id
3216+
3217+
with tempfile.TemporaryDirectory() as tmpdir:
3218+
patches = porcelain.format_patch(
3219+
self.repo.path,
3220+
committish=c2.id,
3221+
outdir=tmpdir,
3222+
)
3223+
self.assertEqual(len(patches), 1)
3224+
# Whatever the cap is, an extremely long subject must not
3225+
# produce a pathologically long filename.
3226+
self.assertLess(len(os.path.basename(patches[0])), 100)
3227+
31583228

31593229
class SymbolicRefTests(PorcelainTestCase):
31603230
def test_set_wrong_symbolic_ref(self) -> None:

tests/test_patch.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -641,19 +641,64 @@ def test_object_diff_kind_change(self) -> None:
641641

642642

643643
class GetSummaryTests(TestCase):
644-
def test_simple(self) -> None:
645-
c = make_commit(
644+
def _make_commit(self, message: bytes) -> Commit:
645+
return make_commit(
646646
author=b"Jelmer <jelmer@samba.org>",
647647
committer=b"Jelmer <jelmer@samba.org>",
648648
author_time=1271350201,
649649
commit_time=1271350201,
650650
author_timezone=0,
651651
commit_timezone=0,
652-
message=b"This is the first line\nAnd this is the second line.\n",
652+
message=message,
653653
tree=Tree().id,
654654
)
655+
656+
def test_simple(self) -> None:
657+
c = self._make_commit(
658+
b"This is the first line\nAnd this is the second line.\n",
659+
)
655660
self.assertEqual("This-is-the-first-line", get_summary(c))
656661

662+
def test_empty_message(self) -> None:
663+
c = self._make_commit(b"")
664+
self.assertEqual("", get_summary(c))
665+
666+
def test_path_traversal_unix(self) -> None:
667+
c = self._make_commit(b"x/../../x\n")
668+
# Path separators and consecutive dots must be sanitized so the
669+
# result cannot escape the requested output directory.
670+
summary = get_summary(c)
671+
self.assertNotIn("/", summary)
672+
self.assertNotIn("..", summary)
673+
self.assertEqual("x-.-.-x", summary)
674+
675+
def test_path_traversal_windows(self) -> None:
676+
c = self._make_commit(b"x\\..\\..\\x\n")
677+
summary = get_summary(c)
678+
self.assertNotIn("\\", summary)
679+
self.assertNotIn("..", summary)
680+
self.assertEqual("x-.-.-x", summary)
681+
682+
def test_colon_sanitized(self) -> None:
683+
c = self._make_commit(b"feature: do something\n")
684+
summary = get_summary(c)
685+
self.assertNotIn(":", summary)
686+
self.assertEqual("feature-do-something", summary)
687+
688+
def test_long_subject_truncated(self) -> None:
689+
c = self._make_commit(b"a" * 500 + b"\n")
690+
summary = get_summary(c)
691+
self.assertLessEqual(len(summary), 64)
692+
693+
def test_trailing_dots_and_dashes_stripped(self) -> None:
694+
c = self._make_commit(b"hello...\n")
695+
self.assertEqual("hello", get_summary(c))
696+
697+
def test_only_problematic_chars(self) -> None:
698+
c = self._make_commit(b"/\\:..\n")
699+
# After sanitization there is nothing usable left.
700+
self.assertEqual("", get_summary(c))
701+
657702

658703
class DiffAlgorithmTests(TestCase):
659704
"""Tests for diff algorithm selection."""

0 commit comments

Comments
 (0)