Skip to content

Commit dbfa264

Browse files
authored
Merge pull request #2134 from gitpython-developers/validate-ref-creation
prevent out-of-repo access when manipulating references.
2 parents 4199cb8 + 4af8463 commit dbfa264

5 files changed

Lines changed: 164 additions & 11 deletions

File tree

git/refs/log.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
__all__ = ["RefLog", "RefLogEntry"]
55

66
from mmap import mmap
7-
import os.path as osp
87
import re
98
import time as _time
109

@@ -212,8 +211,11 @@ def path(cls, ref: "SymbolicReference") -> str:
212211
213212
:param ref:
214213
:class:`~git.refs.symbolic.SymbolicReference` instance
214+
215+
:raise ValueError:
216+
If `ref.path` is invalid or escapes the repository's reflog directory.
215217
"""
216-
return osp.join(ref.repo.git_dir, "logs", to_native_path(ref.path))
218+
return to_native_path(ref._get_validated_reflog_path(ref.repo, ref.path))
217219

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

git/refs/remote.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,17 +58,20 @@ def delete(cls, repo: "Repo", *refs: "RemoteReference", **kwargs: Any) -> None:
5858
`kwargs` are given for comparability with the base class method as we
5959
should not narrow the signature.
6060
"""
61+
for ref in refs:
62+
cls._check_ref_name_valid(ref.path)
63+
6164
repo.git.branch("-d", "-r", *refs)
6265
# The official deletion method will ignore remote symbolic refs - these are
6366
# generally ignored in the refs/ folder. We don't though and delete remainders
6467
# manually.
6568
for ref in refs:
6669
try:
67-
os.remove(os.path.join(repo.common_dir, ref.path))
70+
os.remove(cls._get_validated_path(repo.common_dir, ref.path))
6871
except OSError:
6972
pass
7073
try:
71-
os.remove(os.path.join(repo.git_dir, ref.path))
74+
os.remove(cls._get_validated_path(repo.git_dir, ref.path))
7275
except OSError:
7376
pass
7477
# 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.

git/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ def join_path(a: PathLike, *p: PathLike) -> PathLike:
289289

290290
if sys.platform == "win32":
291291

292-
def to_native_path_windows(path: PathLike) -> PathLike:
292+
def to_native_path_windows(path: PathLike) -> str:
293293
path = os.fspath(path)
294294
return path.replace("/", "\\")
295295

test/test_refs.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# This module is part of GitPython and is released under the
44
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
55

6+
import contextlib
67
from itertools import chain
78
import os.path as osp
89
from pathlib import Path
@@ -18,6 +19,7 @@
1819
RefLog,
1920
Reference,
2021
RemoteReference,
22+
Repo,
2123
SymbolicReference,
2224
TagReference,
2325
)
@@ -29,6 +31,18 @@
2931

3032

3133
class TestRefs(TestBase):
34+
@contextlib.contextmanager
35+
def _repo_with_initial_commit(self, base_dir):
36+
repo_dir = base_dir / "repo"
37+
repo = Repo.init(repo_dir)
38+
(repo_dir / "file.txt").write_text("initial\n", encoding="utf-8")
39+
repo.index.add(["file.txt"])
40+
repo.index.commit("initial")
41+
try:
42+
yield repo
43+
finally:
44+
repo.git.clear_cache()
45+
3246
def test_from_path(self):
3347
# Should be able to create any reference directly.
3448
for ref_type in (Reference, Head, TagReference, RemoteReference):
@@ -648,6 +662,115 @@ def test_refs_outside_repo(self):
648662
ref_file_name = Path(ref_file.name).name
649663
self.assertRaises(BadName, self.rorepo.commit, f"../../{ref_file_name}")
650664

665+
def test_reference_create_rejects_path_traversal(self):
666+
with tempfile.TemporaryDirectory() as tmp_dir:
667+
base_dir = Path(tmp_dir)
668+
with self._repo_with_initial_commit(base_dir) as repo:
669+
outside_path = base_dir / "outside_write.txt"
670+
671+
self.assertRaises(ValueError, Reference.create, repo, "../../../outside_write.txt", "HEAD")
672+
assert not outside_path.exists()
673+
674+
def test_symbolic_reference_create_rejects_path_traversal(self):
675+
with tempfile.TemporaryDirectory() as tmp_dir:
676+
base_dir = Path(tmp_dir)
677+
with self._repo_with_initial_commit(base_dir) as repo:
678+
outside_path = base_dir / "outside_write.txt"
679+
680+
self.assertRaises(ValueError, SymbolicReference.create, repo, "../../outside_write.txt", "HEAD")
681+
assert not outside_path.exists()
682+
683+
def test_symbolic_reference_set_reference_rejects_path_traversal(self):
684+
with tempfile.TemporaryDirectory() as tmp_dir:
685+
base_dir = Path(tmp_dir)
686+
with self._repo_with_initial_commit(base_dir) as repo:
687+
outside_path = base_dir / "outside_write.txt"
688+
689+
self.assertRaises(ValueError, SymbolicReference(repo, "../../outside_write.txt").set_reference, "HEAD")
690+
assert not outside_path.exists()
691+
692+
def test_symbolic_reference_rename_rejects_path_traversal(self):
693+
with tempfile.TemporaryDirectory() as tmp_dir:
694+
base_dir = Path(tmp_dir)
695+
with self._repo_with_initial_commit(base_dir) as repo:
696+
outside_path = base_dir / "outside_move.txt"
697+
ref = SymbolicReference.create(repo, "SAFE_RENAME_SOURCE", "HEAD")
698+
699+
self.assertRaises(ValueError, ref.rename, "../../outside_move.txt")
700+
assert not outside_path.exists()
701+
assert Path(ref.abspath).is_file()
702+
703+
def test_symbolic_reference_delete_rejects_path_traversal(self):
704+
with tempfile.TemporaryDirectory() as tmp_dir:
705+
base_dir = Path(tmp_dir)
706+
with self._repo_with_initial_commit(base_dir) as repo:
707+
outside_path = base_dir / "outside_delete.txt"
708+
outside_path.write_text("do not delete\n", encoding="utf-8")
709+
710+
self.assertRaises(ValueError, SymbolicReference.delete, repo, "../../outside_delete.txt")
711+
assert outside_path.read_text(encoding="utf-8") == "do not delete\n"
712+
713+
def test_symbolic_reference_log_append_rejects_path_traversal(self):
714+
with tempfile.TemporaryDirectory() as tmp_dir:
715+
base_dir = Path(tmp_dir)
716+
with self._repo_with_initial_commit(base_dir) as repo:
717+
outside_path = base_dir / "outside_reflog.txt"
718+
719+
ref = SymbolicReference(repo, "../../../outside_reflog.txt")
720+
self.assertRaises(
721+
ValueError, ref.log_append, Commit.NULL_BIN_SHA, "do not write", repo.head.commit.binsha
722+
)
723+
assert not outside_path.exists()
724+
725+
def test_symbolic_reference_set_reference_rejects_symlink_escape(self):
726+
with tempfile.TemporaryDirectory() as tmp_dir:
727+
base_dir = Path(tmp_dir)
728+
with self._repo_with_initial_commit(base_dir) as repo:
729+
outside_dir = base_dir / "outside_refs"
730+
outside_dir.mkdir()
731+
outside_path = outside_dir / "escaped"
732+
733+
refs_heads_dir = Path(repo.common_dir) / "refs" / "heads"
734+
refs_heads_dir.mkdir(parents=True, exist_ok=True)
735+
symlink_path = refs_heads_dir / "link_out"
736+
try:
737+
symlink_path.symlink_to(outside_dir, target_is_directory=True)
738+
except (OSError, NotImplementedError) as ex:
739+
self.skipTest("symlinks unavailable on this platform: %s" % ex)
740+
if osp.realpath(symlink_path / "escaped") == osp.abspath(symlink_path / "escaped"):
741+
self.skipTest("realpath does not resolve directory symlinks on this platform")
742+
743+
ref = SymbolicReference(repo, "refs/heads/link_out/escaped")
744+
self.assertRaises(ValueError, ref.set_reference, "HEAD")
745+
assert not outside_path.exists()
746+
747+
def test_remote_reference_delete_cleanup_rejects_path_traversal(self):
748+
with tempfile.TemporaryDirectory() as tmp_dir:
749+
base_dir = Path(tmp_dir)
750+
git_dir = base_dir / "repo" / ".git"
751+
git_dir.mkdir(parents=True)
752+
outside_path = base_dir / "outside_remote_delete.txt"
753+
outside_path.write_text("do not delete\n", encoding="utf-8")
754+
755+
class GitStub:
756+
branch_called = False
757+
758+
def branch(self, *args):
759+
self.branch_called = True
760+
761+
class RepoStub:
762+
pass
763+
764+
repo = RepoStub()
765+
repo.git = GitStub()
766+
repo.common_dir = str(git_dir)
767+
repo.git_dir = str(git_dir)
768+
ref = RemoteReference(repo, "../../outside_remote_delete.txt", check_path=False)
769+
770+
self.assertRaises(ValueError, RemoteReference.delete, repo, ref)
771+
assert not repo.git.branch_called
772+
assert outside_path.read_text(encoding="utf-8") == "do not delete\n"
773+
651774
def test_validity_ref_names(self):
652775
"""Ensure ref names are checked for validity.
653776

0 commit comments

Comments
 (0)