From add9920ad62af88db369c6451a1dc9400eebb814 Mon Sep 17 00:00:00 2001 From: Keerthana KT Date: Fri, 16 Oct 2026 21:24:11 +0530 Subject: [PATCH] fix(tag): split tag object headers at line feeds only `TagObject._set_cache_` split the decoded tag object with `str.splitlines()` and then read the headers by position, taking `lines[3]` as the tagger. `str.splitlines()` also breaks at `\r`, `\v`, `\f`, `\x1c`-`\x1e`, `\x85`, U+2028 and U+2029, but git ends a header only at a line feed. A U+2028 inside the `tag` header of an untrusted tag object therefore shifts every later header by one line in GitPython only. With `tag v1tagger Linus Torvalds 1700000000 +0000` followed by a real `tagger Mallory ...` line, `git cat-file tag`, `git show` and `git for-each-ref --format=%(taggername)` report Mallory, while GitPython reported `tag == "v1"` and Linus Torvalds as the tagger. The object passes `git fsck --strict` and a clone with `transfer.fsckObjects=true`, because git only warns `badTagName`. The fix splits at `"\n"` and drops the trailing empty element, so tags that only use line feeds parse exactly as before. A `\r` or U+2028 inside the message is now kept, as git shows it. The new test in `test/test_refs.py` fails before the change and passes after it. `ruff`, `ruff format --check` and `mypy` are clean. On my machine the full suite has 3 failures (`test_empty_repo`, `test_rebasing`, `test_diff_with_staged_file`), and all 3 also fail without this change. --- git/objects/tag.py | 11 ++++++++++- test/test_refs.py | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/git/objects/tag.py b/git/objects/tag.py index e08d6ef10..2dbf79f1b 100644 --- a/git/objects/tag.py +++ b/git/objects/tag.py @@ -106,7 +106,16 @@ def _set_cache_(self, attr: str) -> None: """Cache all our attributes at once.""" if attr in TagObject.__slots__: ostream = self.repo.odb.stream(self.binsha) - lines: List[str] = ostream.read().decode(defenc, "replace").splitlines() + # Git ends a header only at a line feed, but str.splitlines() also + # breaks at "\r", "\v", "\f", U+2028 and similar. Splitting a tag + # object that way lets an untrusted "tag" header containing such a + # character shift every later header by a line. Split at "\n" only, + # and drop the trailing empty element so tags that use line feeds + # parse exactly as before. + data = ostream.read().decode(defenc, "replace") + lines: List[str] = data.split("\n") + if lines and lines[-1] == "": + lines.pop() _obj, hexsha = lines[0].split(" ") _type_token, type_name = lines[1].split(" ") diff --git a/test/test_refs.py b/test/test_refs.py index 2edb7b766..42bf5200e 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -7,10 +7,12 @@ import os.path as osp import tempfile import unittest +from io import BytesIO from itertools import chain from pathlib import Path from unittest.mock import patch +from gitdb import IStream from gitdb.exc import BadName import git.refs as refs @@ -139,6 +141,31 @@ def test_tags_author(self): tagger_name = tagobj.tagger.name assert tagger_name == "Michael Trier" + def test_tag_headers_split_at_line_feeds_only(self): + # A "tag" header value carrying a U+2028 (or any separator that + # str.splitlines() honors but git does not) must not shift the later + # headers. Git ends a header only at "\n", so GitPython must read the + # whole value as the tag name and the following line as the tagger, + # exactly as "git cat-file tag" does. See the TagObject parsing fix. + with tempfile.TemporaryDirectory() as tmp_dir: + with self._repo_with_initial_commit(Path(tmp_dir)) as repo: + commit_hexsha = repo.head.commit.hexsha + payload = ( + f"object {commit_hexsha}\n" + "type commit\n" + "tag v1
tagger Linus Torvalds 1700000000 +0000\n" + "tagger Mallory 1700000001 +0000\n" + "\n" + "release\n" + ).encode("utf-8") + istream = repo.odb.store(IStream("tag", len(payload), BytesIO(payload))) + tagobj = TagObject(repo, istream.binsha) + + assert tagobj.tag == "v1
tagger Linus Torvalds 1700000000 +0000" + assert tagobj.tagger.name == "Mallory" + assert tagobj.tagger.email == "m@evil.example" + assert tagobj.message == "release" + def test_tags(self): # Tag refs can point to tag objects or to commits. s = set()