Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ contain 20 binary bytes or 40 hexadecimal characters.
Legacy class-level ``NULL_BIN_SHA`` and ``NULL_HEX_SHA`` constants retain their
SHA-1 values; do not use their width to interpret repository object IDs.

Security fixes
--------------

- https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-f8wj-prgh-h9j7

Git executable and safety
-------------------------

Expand All @@ -23,6 +28,9 @@ Git executable and safety
option/protocol checks remain, and operands and stdin records receive additional
validation. NUL-delimited ``cat-file`` requests prevent newline injection.
Previously accepted option-like revision/ref arguments can now be rejected.
``Head.checkout()`` rejects every branch name starting with a dash, including
when ``allow_unsafe_options=True``. This setting permits explicit keyword
options while branch names remain operands.
* New plumbing calls suppress implicit hooks, filesystem monitors, automatic
maintenance, and lazy network fetches. Index staging continues to store raw
content rather than introduce clean filters. Diffs disable external diff and
Expand Down
4 changes: 4 additions & 0 deletions git/refs/head.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ def checkout(
:param allow_unsafe_options:
Allow unsafe options such as ``--pathspec-from-file`` to be passed to
:manpage:`git-checkout(1)`.
Branch names are validated regardless of this setting.

:param kwargs:
Additional keyword arguments to be passed to git checkout, e.g.
Expand All @@ -290,6 +291,9 @@ def checkout(
If there is no active branch, as the HEAD is now detached, the HEAD
reference will be returned instead.

:raise git.exc.UnsafeOptionError:
If the branch name starts with a dash or contains NUL, CR, or LF.

:note:
By default it is only allowed to checkout heads - everything else will leave
the HEAD detached which is allowed and possible, but remains a special state
Expand Down
4 changes: 3 additions & 1 deletion test/test_positional_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ def test_rename_head_cannot_select_current_branch(repo, tmp_path):


@pytest.mark.parametrize("allow_unsafe_options", [False, True])
@pytest.mark.parametrize("name", ["--force", "--detach", "--ours"])
@pytest.mark.parametrize(
"name", ["-f", "-Bcheckout-target", "--orphan=checkout-target", "--force", "--detach", "--ours", "-", "--"]
)
def test_checkout_rejects_option_shaped_branch_names(repo, tmp_path, name, allow_unsafe_options):
branch = Head(repo, "refs/heads/" + name)
with mock.patch.object(Git, "_call_process_safe", side_effect=AssertionError("Git must not run")) as run:
Expand Down
34 changes: 24 additions & 10 deletions test/test_refs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@
from test.lib.fixtures import fixture_repo, read_only_fixture, writable_fixture


@pytest.mark.parametrize(
"branch_name", ["--pathspec-from-file=pathspecs", "-f", "--orphan=checkout-target", "-Bcheckout-target"]
)
def test_cloned_head_checkout_rejects_option_shaped_branch_names(branch_name):
with fixture_repo("refs.sh", "unsafe-branch", "sha1", branch_name, write=True, subdir="clone") as cloned:
branch = cloned.active_branch
assert branch.name == branch_name
commit = cloned.head.commit
path = Path(cloned.working_tree_dir) / "file.txt"
content = path.read_bytes()
status = cloned.git.status("--porcelain")
assert status.startswith(" M file.txt")
for allow_unsafe_options in (False, True):
with patch.object(Git, "execute", autospec=True, side_effect=Git.execute) as run:
with pytest.raises(UnsafeOptionError) as error:
branch.checkout(force=False, allow_unsafe_options=allow_unsafe_options)
run.assert_not_called()
assert "unmatched-private-content" not in str(error.value)
assert cloned.active_branch == branch
assert cloned.head.commit == commit
assert path.read_bytes() == content
assert cloned.git.status("--porcelain") == status


@pytest.mark.parametrize("object_format", ["sha1", "sha256"])
def test_reference_names_preserve_unicode_line_separators(object_format):
names = [
Expand Down Expand Up @@ -658,16 +682,6 @@ def test_head_checkout_rejects_pathspec_from_file(self, rw_repo):
with self.assertRaises(UnsafeOptionError):
branch.checkout()

def test_cloned_head_checkout_rejects_pathspec_from_file(self):
with fixture_repo("refs.sh", "unsafe-branch", write=True, subdir="clone") as cloned:
branch_name = "--pathspec-from-file=pathspecs"
assert cloned.active_branch.name == branch_name
for allow_unsafe_options in (False, True):
with self.assertRaises(UnsafeOptionError) as error:
cloned.active_branch.checkout(allow_unsafe_options=allow_unsafe_options)
assert "unmatched-private-content" not in str(error.exception)
assert cloned.active_branch.name == branch_name

@with_rw_repo("HEAD")
def test_head_reset_rejects_pathspec_from_file(self, rw_repo):
pathspecs = read_only_fixture("refs-input.sh") / "pathspecs"
Expand Down
6 changes: 4 additions & 2 deletions tests/fixtures/refs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ case "$1" in
git rev-parse HEAD > ../outside-ref
;;
unsafe-branch)
git update-ref 'refs/heads/--pathspec-from-file=pathspecs' HEAD
git symbolic-ref HEAD 'refs/heads/--pathspec-from-file=pathspecs'
branch_name="$3"
git update-ref "refs/heads/$branch_name" HEAD
git symbolic-ref HEAD "refs/heads/$branch_name"
cd ..
git clone repo clone
printf 'unmatched-private-content\n' > clone/pathspecs
printf 'uncommitted\n' >> clone/file.txt
;;
esac
Loading