From 8da65080849a56a2fc9f3e7817bbf0524e8397d5 Mon Sep 17 00:00:00 2001 From: Byron Date: Thu, 10 Sep 2026 08:07:55 +0200 Subject: [PATCH] fix: validate checkout positional reference options Head.checkout checked keyword options but omitted the serialized reference from its existing unsafe-option validation (GHSA-23mf-xhv8-69c2). Reference names can originate in a cloned repository, so callers could reach behavior that normally requires explicit opt-in without supplying any checkout options themselves. Pass self through the shared option-candidate helper, matching the argument actually sent to Git. This applies the existing policy to direct and cloned references, including abbreviated option spellings, while retaining allow_unsafe_options=True and ordinary checkout semantics. A leading -- separator would instead make the reference a pathspec and break branch switching. Add direct-reference regression coverage and a local clone regression using synthetic file content; the latter also verifies explicit opt-in. Both regression tests failed before the guard change. All 30 tests in test/test_refs.py pass with Python 3.12.14 and Apple Git 2.50.1; git diff --check passes. Git behavior reference: local git/git checkout at 1630431f326e15fcde608827b5ff38422528eb59, builtin/checkout.c checkout_main pathspec_from_file handling, which parses file contents as pathspecs. No Git source was copied. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 --- doc/source/changes.rst | 1 + git/refs/head.py | 2 +- test/test_refs.py | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/doc/source/changes.rst b/doc/source/changes.rst index 4803790b9..df003c7d3 100644 --- a/doc/source/changes.rst +++ b/doc/source/changes.rst @@ -8,6 +8,7 @@ Changelog Security fixes for * https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-gq48-pqfc-9p58 +* https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-23mf-xhv8-69c2 If you can, also try and provide feedback on the upcoming v4 branch https://github.com/gitpython-developers/GitPython/pull/2177 - patches welcome. diff --git a/git/refs/head.py b/git/refs/head.py index 7f563374e..5dd4a5a0a 100644 --- a/git/refs/head.py +++ b/git/refs/head.py @@ -283,7 +283,7 @@ def checkout( """ if not allow_unsafe_options: Git.check_unsafe_options( - options=Git._option_candidates([], kwargs), + options=Git._option_candidates([self], kwargs), unsafe_options=Git.unsafe_git_pathspec_from_file_options, ) kwargs["f"] = force diff --git a/test/test_refs.py b/test/test_refs.py index 32c8fbe34..ae3687c30 100644 --- a/test/test_refs.py +++ b/test/test_refs.py @@ -289,6 +289,25 @@ def test_head_checkout_rejects_pathspec_from_file(self, rw_repo): pathspec_file_nul=True, **{option_name: str(pathspecs)}, ) + for option_name in ("--pathspec-from-file", "--pathspec-from"): + branch = Head(rw_repo, f"refs/heads/{option_name}={pathspecs}") + with self.assertRaises(UnsafeOptionError): + branch.checkout() + + def test_cloned_head_checkout_rejects_pathspec_from_file(self): + with tempfile.TemporaryDirectory() as tdir: + base_dir = Path(tdir) + with self._repo_with_initial_commit(base_dir) as source: + branch = source.create_head("--pathspec-from-file=pathspecs") + source.head.reference = branch + with Repo.clone_from(source.working_tree_dir, base_dir / "clone") as cloned: + (base_dir / "clone" / "pathspecs").write_text("unmatched-private-content\n", encoding="utf-8") + assert cloned.active_branch.name == branch.name + with self.assertRaises(UnsafeOptionError): + cloned.active_branch.checkout() + with self.assertRaises(GitCommandError) as error: + cloned.active_branch.checkout(allow_unsafe_options=True) + assert "unmatched-private-content" in str(error.exception) @with_rw_repo("HEAD") def test_head_reset_rejects_pathspec_from_file(self, rw_repo):