|
4 | 4 | # 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/ |
5 | 5 |
|
6 | 6 | import contextlib |
7 | | -from itertools import chain |
8 | 7 | import os.path as osp |
9 | | -from pathlib import Path |
10 | 8 | import tempfile |
| 9 | +import unittest |
| 10 | +from itertools import chain |
| 11 | +from pathlib import Path |
| 12 | +from unittest.mock import patch |
11 | 13 |
|
12 | 14 | from gitdb.exc import BadName |
13 | 15 |
|
| 16 | +import git.refs as refs |
14 | 17 | from git import ( |
15 | 18 | Commit, |
16 | 19 | GitCommandError, |
17 | 20 | GitConfigParser, |
18 | 21 | Head, |
19 | | - RefLog, |
20 | 22 | Reference, |
| 23 | + RefLog, |
21 | 24 | RemoteReference, |
22 | 25 | Repo, |
23 | 26 | SymbolicReference, |
24 | 27 | TagReference, |
25 | 28 | ) |
26 | 29 | from git.exc import UnsafeOptionError |
27 | 30 | from git.objects.tag import TagObject |
28 | | -import git.refs as refs |
29 | 31 | from git.util import Actor, rmtree |
30 | | - |
31 | | -from test.lib import TestBase, requires_symlinks, with_rw_repo, PathLikeMock |
| 32 | +from test.lib import PathLikeMock, TestBase, requires_symlinks, with_rw_repo |
32 | 33 |
|
33 | 34 |
|
34 | 35 | class TestRefs(TestBase): |
@@ -912,3 +913,158 @@ def test_validity_ref_names(self): |
912 | 913 |
|
913 | 914 | # Valid reference name should not raise. |
914 | 915 | check_ref("valid/ref/name") |
| 916 | + |
| 917 | + |
| 918 | +class TestSymbolicReferenceSecurity(unittest.TestCase): |
| 919 | + def setUp(self): |
| 920 | + self.tmp = tempfile.TemporaryDirectory() |
| 921 | + self.addCleanup(self.tmp.cleanup) |
| 922 | + self.repo = Repo.init(Path(self.tmp.name) / "repo") |
| 923 | + self.addCleanup(self.repo.close) |
| 924 | + actor = Actor("Reference Test", "reference@example.invalid") |
| 925 | + self.commit = self.repo.index.commit("initial", author=actor, committer=actor) |
| 926 | + self.next_commit = self.repo.index.commit("next", author=actor, committer=actor, head=False) |
| 927 | + |
| 928 | + def write_ref(self, path, value): |
| 929 | + file = Path(self.repo.git_dir) / path |
| 930 | + file.parent.mkdir(parents=True, exist_ok=True) |
| 931 | + file.write_text(value + "\n", encoding="utf-8") |
| 932 | + |
| 933 | + def chain(self, count, terminal=None): |
| 934 | + paths = ["HEAD"] + [f"refs/heads/link-{i}" for i in range(1, count)] |
| 935 | + for path, target in zip(paths, paths[1:]): |
| 936 | + self.write_ref(path, "ref: " + target) |
| 937 | + self.write_ref(paths[-1], self.commit.hexsha if terminal is None else terminal) |
| 938 | + return paths |
| 939 | + |
| 940 | + @contextlib.contextmanager |
| 941 | + def bounded_reads(self): |
| 942 | + # Make the regression fail deterministically instead of hanging on a cycle. |
| 943 | + read_ref = SymbolicReference._get_ref_info |
| 944 | + reads = 0 |
| 945 | + |
| 946 | + def read(repo, path): |
| 947 | + nonlocal reads |
| 948 | + reads += 1 |
| 949 | + if reads > 100: |
| 950 | + self.fail("Reference traversal exceeded its read budget") |
| 951 | + return read_ref(repo, path) |
| 952 | + |
| 953 | + with patch.object(SymbolicReference, "_get_ref_info", side_effect=read): |
| 954 | + yield |
| 955 | + |
| 956 | + def test_cycles_fail_in_all_object_read_entry_points(self): |
| 957 | + for target in ("refs/heads/a", "refs/heads/b"): |
| 958 | + self.write_ref("HEAD", "ref: refs/heads/a") |
| 959 | + self.write_ref("refs/heads/a", "ref: " + target) |
| 960 | + self.write_ref("refs/heads/b", "ref: refs/heads/a") |
| 961 | + self.write_ref("refs/tags/loop", "ref: refs/heads/a") |
| 962 | + for read in ( |
| 963 | + lambda: SymbolicReference.dereference_recursive(self.repo, "HEAD"), |
| 964 | + lambda: self.repo.head.commit, |
| 965 | + lambda: self.repo.head.object, |
| 966 | + lambda: Head(self.repo, "refs/heads/a").commit, |
| 967 | + lambda: TagReference(self.repo, "refs/tags/loop").object, |
| 968 | + ): |
| 969 | + with self.subTest(target=target, read=read), self.bounded_reads(): |
| 970 | + with self.assertRaisesRegex(ValueError, "symbolic reference"): |
| 971 | + read() |
| 972 | + for read in (lambda: self.repo.commit("HEAD"), lambda: self.repo.rev_parse("a")): |
| 973 | + with self.subTest(target=target, read=read), self.bounded_reads(): |
| 974 | + with self.assertRaises(BadName): |
| 975 | + read() |
| 976 | + with self.bounded_reads(): |
| 977 | + self.assertFalse(self.repo.head.is_valid()) |
| 978 | + |
| 979 | + def test_depth_limit_matches_git_including_terminal_reference(self): |
| 980 | + for count in range(1, 7): |
| 981 | + self.chain(count) |
| 982 | + with self.subTest(count=count), self.bounded_reads(): |
| 983 | + if count <= 5: |
| 984 | + self.assertEqual(self.repo.head.commit, self.commit) |
| 985 | + self.assertEqual(self.repo.git.rev_parse("--verify", "HEAD"), self.commit.hexsha) |
| 986 | + else: |
| 987 | + with self.assertRaises(ValueError): |
| 988 | + self.repo.head.commit |
| 989 | + with self.assertRaises(GitCommandError): |
| 990 | + self.repo.git.rev_parse("--verify", "HEAD") |
| 991 | + |
| 992 | + def test_packed_terminal_reference_resolves(self): |
| 993 | + paths = self.chain(5) |
| 994 | + (Path(self.repo.git_dir) / paths[-1]).unlink() |
| 995 | + self.write_ref("packed-refs", f"{self.commit.hexsha} {paths[-1]}") |
| 996 | + self.assertEqual(self.repo.head.commit, self.commit) |
| 997 | + |
| 998 | + def test_writes_reject_cycles_and_excessive_depth_without_side_effects(self): |
| 999 | + for terminal in ("ref: refs/heads/link-1", self.commit.hexsha): |
| 1000 | + paths = self.chain(6, terminal) |
| 1001 | + before = {path: (Path(self.repo.git_dir) / path).read_bytes() for path in paths} |
| 1002 | + head_log = Path(self.repo.git_dir) / "logs" / "HEAD" |
| 1003 | + old_log = head_log.read_bytes() |
| 1004 | + for logmsg in (None, "must not write"): |
| 1005 | + with self.subTest(terminal=terminal, logmsg=logmsg), self.bounded_reads(): |
| 1006 | + with self.assertRaisesRegex(ValueError, "symbolic reference"): |
| 1007 | + self.repo.head.set_object(self.next_commit, logmsg) |
| 1008 | + self.assertEqual(before, {path: (Path(self.repo.git_dir) / path).read_bytes() for path in paths}) |
| 1009 | + self.assertEqual(head_log.read_bytes(), old_log) |
| 1010 | + |
| 1011 | + def test_valid_chain_updates_preserve_symbols_and_head_reflog(self): |
| 1012 | + paths = self.chain(5) |
| 1013 | + self.repo.head.set_object(self.next_commit, "updated through chain") |
| 1014 | + self.assertEqual(self.repo.head.commit, self.next_commit) |
| 1015 | + for path, target in zip(paths, paths[1:]): |
| 1016 | + self.assertEqual((Path(self.repo.git_dir) / path).read_text(), "ref: " + target + "\n") |
| 1017 | + for path in ("HEAD", paths[-1]): |
| 1018 | + entry = SymbolicReference(self.repo, path).log()[-1] |
| 1019 | + self.assertEqual(entry.oldhexsha, self.commit.hexsha) |
| 1020 | + self.assertEqual(entry.newhexsha, self.next_commit.hexsha) |
| 1021 | + self.assertEqual(entry.message, "updated through chain") |
| 1022 | + |
| 1023 | + def test_writes_create_unborn_terminal_reference(self): |
| 1024 | + paths = self.chain(5) |
| 1025 | + (Path(self.repo.git_dir) / paths[-1]).unlink() |
| 1026 | + self.repo.head.set_object(self.next_commit) |
| 1027 | + self.assertEqual(self.repo.head.commit, self.next_commit) |
| 1028 | + self.assertEqual(self.repo.head.reference.path, paths[1]) |
| 1029 | + |
| 1030 | + def test_set_object_preserves_subclass_override_signature_and_dispatch(self): |
| 1031 | + calls = [] |
| 1032 | + |
| 1033 | + class CustomReference(Reference): |
| 1034 | + @classmethod |
| 1035 | + def from_path(cls, repo, path): |
| 1036 | + return cls(repo, path, check_path=False) |
| 1037 | + |
| 1038 | + def set_object(self, object, logmsg=None): |
| 1039 | + calls.append(self.path) |
| 1040 | + return super().set_object(object, logmsg) |
| 1041 | + |
| 1042 | + paths = self.chain(5) |
| 1043 | + ref = CustomReference(self.repo, "HEAD", check_path=False) |
| 1044 | + self.assertIs(ref.set_object(self.next_commit, "custom reference"), ref) |
| 1045 | + self.assertEqual(calls, paths) |
| 1046 | + self.assertEqual(self.repo.head.commit, self.next_commit) |
| 1047 | + self.assertEqual(self.repo.head.log()[-1].message, "custom reference") |
| 1048 | + |
| 1049 | + def test_custom_symbolic_reference_names_do_not_recurse_during_construction(self): |
| 1050 | + self.write_ref("CUSTOM_A", "ref: CUSTOM_B") |
| 1051 | + self.write_ref("CUSTOM_B", "ref: CUSTOM_A") |
| 1052 | + with self.bounded_reads(): |
| 1053 | + ref = SymbolicReference.from_path(self.repo, "CUSTOM_A") |
| 1054 | + self.assertEqual(ref.reference.path, "CUSTOM_B") |
| 1055 | + self.assertFalse(ref.is_detached) |
| 1056 | + self.assertFalse(ref.is_valid()) |
| 1057 | + with self.assertRaises(ValueError): |
| 1058 | + ref.set_object(self.next_commit) |
| 1059 | + |
| 1060 | + def test_reference_reads_reject_symlinks_outside_repository(self): |
| 1061 | + outside = Path(self.tmp.name) / "outside" |
| 1062 | + outside.write_text(self.commit.hexsha + "\n", encoding="utf-8") |
| 1063 | + path = Path(self.repo.git_dir) / "refs" / "heads" / "external" |
| 1064 | + try: |
| 1065 | + path.symlink_to(outside) |
| 1066 | + except OSError: |
| 1067 | + self.skipTest("Symlinks are unavailable") |
| 1068 | + self.write_ref("HEAD", "ref: refs/heads/external") |
| 1069 | + with self.assertRaisesRegex(ValueError, "escapes the repository"): |
| 1070 | + self.repo.head.commit |
0 commit comments