Skip to content

Commit 25ba54d

Browse files
codexByron
andcommitted
prevent out-of-repo access when manipulating references.
This previously made it possible to create, modify and delete files outside outside of the repository, which is a problem if inputs aren't trusted. Co-authored-by: Sebastian Thiel <sebastian.thiel@icloud.com>
1 parent 4199cb8 commit 25ba54d

4 files changed

Lines changed: 126 additions & 9 deletions

File tree

git/refs/log.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def path(cls, ref: "SymbolicReference") -> str:
213213
:param ref:
214214
:class:`~git.refs.symbolic.SymbolicReference` instance
215215
"""
216-
return osp.join(ref.repo.git_dir, "logs", to_native_path(ref.path))
216+
return to_native_path(ref._get_validated_reflog_path(ref.repo, ref.path))
217217

218218
@classmethod
219219
def iter_entries(cls, stream: Union[str, "BytesIO", mmap]) -> Iterator[RefLogEntry]:

git/refs/remote.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ def delete(cls, repo: "Repo", *refs: "RemoteReference", **kwargs: Any) -> None:
6363
# generally ignored in the refs/ folder. We don't though and delete remainders
6464
# manually.
6565
for ref in refs:
66+
cls._check_ref_name_valid(ref.path)
6667
try:
67-
os.remove(os.path.join(repo.common_dir, ref.path))
68+
os.remove(cls._get_validated_path(repo.common_dir, ref.path))
6869
except OSError:
6970
pass
7071
try:
71-
os.remove(os.path.join(repo.git_dir, ref.path))
72+
os.remove(cls._get_validated_path(repo.git_dir, ref.path))
7273
except OSError:
7374
pass
7475
# END for each ref

git/refs/symbolic.py

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ def name(self) -> str:
110110
def abspath(self) -> PathLike:
111111
return join_path_native(_git_dir(self.repo, self.path), self.path)
112112

113+
@staticmethod
114+
def _get_validated_path(base: PathLike, path: PathLike) -> str:
115+
path = os.fspath(path)
116+
base_path = os.path.realpath(os.fspath(base))
117+
abs_path = os.path.realpath(os.path.join(base_path, path))
118+
try:
119+
common_path = os.path.commonpath([base_path, abs_path])
120+
except ValueError as e:
121+
raise ValueError("Reference path %r escapes the repository" % path) from e
122+
if os.path.normcase(common_path) != os.path.normcase(base_path):
123+
raise ValueError("Reference path %r escapes the repository" % path)
124+
return abs_path
125+
126+
@classmethod
127+
def _get_validated_ref_path(cls, repo: "Repo", path: PathLike) -> str:
128+
"""Return the absolute filesystem path for a ref after validating it."""
129+
cls._check_ref_name_valid(path)
130+
ref_path = os.fspath(path)
131+
return cls._get_validated_path(_git_dir(repo, ref_path), ref_path)
132+
133+
@classmethod
134+
def _get_validated_reflog_path(cls, repo: "Repo", path: PathLike) -> str:
135+
"""Return the absolute filesystem path for a reflog after validating it."""
136+
cls._check_ref_name_valid(path)
137+
return cls._get_validated_path(os.path.join(repo.git_dir, "logs"), path)
138+
113139
@classmethod
114140
def _get_packed_refs_path(cls, repo: "Repo") -> str:
115141
return os.path.join(repo.common_dir, "packed-refs")
@@ -485,7 +511,7 @@ def set_reference(
485511
# END handle non-existing
486512
# END retrieve old hexsha
487513

488-
fpath = self.abspath
514+
fpath = self._get_validated_ref_path(self.repo, self.path)
489515
assure_directory_exists(fpath, is_file=True)
490516

491517
lfd = LockedFD(fpath)
@@ -632,7 +658,7 @@ def delete(cls, repo: "Repo", path: PathLike) -> None:
632658
Alternatively the symbolic reference to be deleted.
633659
"""
634660
full_ref_path = cls.to_full_path(path)
635-
abs_path = os.path.join(repo.common_dir, full_ref_path)
661+
abs_path = cls._get_validated_ref_path(repo, full_ref_path)
636662
if os.path.exists(abs_path):
637663
os.remove(abs_path)
638664
else:
@@ -695,9 +721,8 @@ def _create(
695721
symbolic reference. Otherwise it will be resolved to the corresponding object
696722
and a detached symbolic reference will be created instead.
697723
"""
698-
git_dir = _git_dir(repo, path)
699724
full_ref_path = cls.to_full_path(path)
700-
abs_ref_path = os.path.join(git_dir, full_ref_path)
725+
abs_ref_path = cls._get_validated_ref_path(repo, full_ref_path)
701726

702727
# Figure out target data.
703728
target = reference
@@ -789,8 +814,8 @@ def rename(self, new_path: PathLike, force: bool = False) -> "SymbolicReference"
789814
if self.path == new_path:
790815
return self
791816

792-
new_abs_path = os.path.join(_git_dir(self.repo, new_path), new_path)
793-
cur_abs_path = os.path.join(_git_dir(self.repo, self.path), self.path)
817+
new_abs_path = self._get_validated_ref_path(self.repo, new_path)
818+
cur_abs_path = self._get_validated_ref_path(self.repo, self.path)
794819
if os.path.isfile(new_abs_path):
795820
if not force:
796821
# If they point to the same file, it's not an error.

test/test_refs.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
RefLog,
1919
Reference,
2020
RemoteReference,
21+
Repo,
2122
SymbolicReference,
2223
TagReference,
2324
)
@@ -29,6 +30,14 @@
2930

3031

3132
class TestRefs(TestBase):
33+
def _repo_with_initial_commit(self, base_dir):
34+
repo_dir = base_dir / "repo"
35+
repo = Repo.init(repo_dir)
36+
(repo_dir / "file.txt").write_text("initial\n", encoding="utf-8")
37+
repo.index.add(["file.txt"])
38+
repo.index.commit("initial")
39+
return repo
40+
3241
def test_from_path(self):
3342
# Should be able to create any reference directly.
3443
for ref_type in (Reference, Head, TagReference, RemoteReference):
@@ -648,6 +657,88 @@ def test_refs_outside_repo(self):
648657
ref_file_name = Path(ref_file.name).name
649658
self.assertRaises(BadName, self.rorepo.commit, f"../../{ref_file_name}")
650659

660+
def test_reference_create_rejects_path_traversal(self):
661+
with tempfile.TemporaryDirectory() as tmp_dir:
662+
base_dir = Path(tmp_dir)
663+
repo = self._repo_with_initial_commit(base_dir)
664+
outside_path = base_dir / "outside_write.txt"
665+
666+
self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD")
667+
assert not outside_path.exists()
668+
669+
def test_symbolic_reference_create_rejects_path_traversal(self):
670+
with tempfile.TemporaryDirectory() as tmp_dir:
671+
base_dir = Path(tmp_dir)
672+
repo = self._repo_with_initial_commit(base_dir)
673+
outside_path = base_dir / "outside_write.txt"
674+
675+
self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD")
676+
assert not outside_path.exists()
677+
678+
def test_symbolic_reference_set_reference_rejects_path_traversal(self):
679+
with tempfile.TemporaryDirectory() as tmp_dir:
680+
base_dir = Path(tmp_dir)
681+
repo = self._repo_with_initial_commit(base_dir)
682+
outside_path = base_dir / "outside_write.txt"
683+
684+
self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD")
685+
assert not outside_path.exists()
686+
687+
def test_symbolic_reference_rename_rejects_path_traversal(self):
688+
with tempfile.TemporaryDirectory() as tmp_dir:
689+
base_dir = Path(tmp_dir)
690+
repo = self._repo_with_initial_commit(base_dir)
691+
outside_path = base_dir / "outside_move.txt"
692+
ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD")
693+
694+
self.assertRaises(ValueError, ref.rename, "../../outside_move.txt")
695+
assert not outside_path.exists()
696+
assert Path(ref.abspath).is_file()
697+
698+
def test_symbolic_reference_delete_rejects_path_traversal(self):
699+
with tempfile.TemporaryDirectory() as tmp_dir:
700+
base_dir = Path(tmp_dir)
701+
repo = self._repo_with_initial_commit(base_dir)
702+
outside_path = base_dir / "outside_delete.txt"
703+
outside_path.write_text("do not delete\n", encoding="utf-8")
704+
705+
self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt")
706+
assert outside_path.read_text(encoding="utf-8") == "do not delete\n"
707+
708+
def test_symbolic_reference_log_append_rejects_path_traversal(self):
709+
with tempfile.TemporaryDirectory() as tmp_dir:
710+
base_dir = Path(tmp_dir)
711+
repo = self._repo_with_initial_commit(base_dir)
712+
outside_path = base_dir / "outside_reflog.txt"
713+
714+
ref = SymbolicReference(repo, "../../../outside_reflog.txt")
715+
self.assertRaises(ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha)
716+
assert not outside_path.exists()
717+
718+
def test_remote_reference_delete_cleanup_rejects_path_traversal(self):
719+
with tempfile.TemporaryDirectory() as tmp_dir:
720+
base_dir = Path(tmp_dir)
721+
git_dir = base_dir / "repo" / ".git"
722+
git_dir.mkdir(parents=True)
723+
outside_path = base_dir / "outside_remote_delete.txt"
724+
outside_path.write_text("do not delete\n", encoding="utf-8")
725+
726+
class GitStub:
727+
def branch(self, *args):
728+
pass
729+
730+
class RepoStub:
731+
pass
732+
733+
repo = RepoStub()
734+
repo.git = GitStub()
735+
repo.common_dir = str(git_dir)
736+
repo.git_dir = str(git_dir)
737+
ref = RemoteReference(repo, "../../outside_remote_delete.txt", check_path=False)
738+
739+
self.assertRaises(ValueError, RemoteReference.delete, repo, ref)
740+
assert outside_path.read_text(encoding="utf-8") == "do not delete\n"
741+
651742
def test_validity_ref_names(self):
652743
"""Ensure ref names are checked for validity.
653744

0 commit comments

Comments
 (0)