VYPR
High severityGHSA Advisory· Published May 13, 2026· Updated May 13, 2026

Nautobot: GitRepository.current_head field should not be writable through REST API

CVE-2026-44798

Description

Impact

A user with access to add/change a GitRepository record could use the REST API to directly set the current_head field on the record, which was not intended to be user-editable. Doing so could cause Nautobot's local clone(s) of the relevant repository to checkout a commit other than the latest commit on the specified branch (resulting in misleading state), or potentially to be unable to make use of the repository at all (until manually remediated) due to the current_head pointing to a nonexistent commit hash or malformed value.

Patches

The issue has been remediated in Nautobot v2.4.33 and 3.1.2.

Workarounds

Note that many of the same end-result symptoms could be caused by a user with the same level of access simply changing the branch or remote_url of a GitRepository rather than crafting the current_head. Administrators are encouraged to carefully review which users are granted permissions to create and modify GitRepository records.

References

  • 2.4.33 (<a href="https://github.com/nautobot/nautobot/commit/9deddfc91ad9260ad17b5e20084e9e2d15be3609">patch</a>)
  • 3.1.2 (<a href="https://github.com/nautobot/nautobot/commit/c46f97040b2bde4320be36b23577f19a8bcbd8c3">patch</a>)

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
nautobotPyPI
>= 3.0.0a2, < 3.1.23.1.2
nautobotPyPI
< 2.4.332.4.33

Affected products

1

Patches

2
9deddfc91ad9

Merge commit from fork

https://github.com/nautobot/nautobotGlenn MatthewsMay 7, 2026via ghsa
7 files changed · +293 4
  • changes/GHSA-p3hx-pwf3-j8wr.security+2 0 added
    @@ -0,0 +1,2 @@
    +Fixed `GitRepository.current_head` being incorrectly user-editable through the REST API (GHSA-p3hx-pwf3-j8wr).
    +Added additional data validation to `GitRepository.clean()` and to various methods of the `GitRepo` helper class.
    
  • nautobot/core/tests/test_utils_git.py+123 0 added
    @@ -0,0 +1,123 @@
    +"""Tests for `nautobot.core.utils.git`."""
    +
    +import tempfile
    +
    +from nautobot.core.testing import TestCase
    +from nautobot.core.utils.git import BranchDoesNotExist, GitRepo
    +from nautobot.extras.tests.git_helper import create_and_populate_git_repository
    +
    +
    +class GitRepoTestCase(TestCase):
    +    """Tests verifying logic in the `GitRepo` helper class."""
    +
    +    @classmethod
    +    def setUpClass(cls):
    +        super().setUpClass()
    +        cls._remote_dir = tempfile.TemporaryDirectory()  # pylint: disable=consider-using-with
    +        create_and_populate_git_repository(cls._remote_dir.name)
    +        cls.remote_url = "file://" + cls._remote_dir.name
    +
    +    @classmethod
    +    def tearDownClass(cls):
    +        cls._remote_dir.cleanup()
    +        super().tearDownClass()
    +
    +    def setUp(self):
    +        super().setUp()
    +        self._local_dir = tempfile.TemporaryDirectory()  # pylint: disable=consider-using-with
    +        self.addCleanup(self._local_dir.cleanup)
    +        self.repo = GitRepo(self._local_dir.name, self.remote_url, branch="main")
    +
    +    def test_init_rejects_invalid_branch(self):
    +        """`GitRepo(branch=...)` rejects whitespace / dash-prefixed values before invoking git clone."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("main\n", "must not contain whitespace"),
    +            ("--upload-pack=evil", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with tempfile.TemporaryDirectory() as fresh_dir:
    +                    with self.assertRaisesRegex(ValueError, message_regex):
    +                        GitRepo(fresh_dir, self.remote_url, branch=value)
    +
    +    def test_init_accepts_none_branch(self):
    +        """`branch=None` (the default) means "clone the remote's default branch" and must remain accepted."""
    +        with tempfile.TemporaryDirectory() as fresh_dir:
    +            repo = GitRepo(fresh_dir, self.remote_url, branch=None)
    +            self.assertEqual(repo.head, self.repo.head)
    +
    +    def test_checkout_rejects_invalid_branch(self):
    +        """`branch` must be a non-empty string with no whitespace and no leading dash."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (None, "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("main\n", "must not contain whitespace"),
    +            ("ma in", "must not contain whitespace"),
    +            ("\tmain", "must not contain whitespace"),
    +            ("--orphan", r"must not start with '-'"),
    +            ("-h", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.checkout(value)
    +
    +    def test_checkout_rejects_invalid_commit_hexsha(self):
    +        """Non-empty `commit_hexsha` must not contain whitespace or start with a dash.
    +
    +        We only enforce the CLI-injection-shape rules here; strict hex validation lives on
    +        `GitRepository.current_head` where the value is genuinely a commit hash.
    +        """
    +        cases = [
    +            ("--detach", r"must not start with '-'"),
    +            ("-f", r"must not start with '-'"),
    +            ("abc def", "must not contain whitespace"),
    +            ("abc\n", "must not contain whitespace"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(commit_hexsha=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.checkout("main", commit_hexsha=value)
    +
    +    def test_checkout_accepts_empty_commit_hexsha_as_unspecified(self):
    +        """Empty `commit_hexsha` is treated as "not provided" — callers pass `repo.current_head`,
    +        which is "" for repos that have never been synced. Must remain accepted for backwards compat.
    +        """
    +        head, changed = self.repo.checkout("main", commit_hexsha="")
    +        self.assertTrue(changed)
    +        self.assertEqual(head, self.repo.head)
    +
    +    def test_checkout_short_hex_branch_not_treated_as_commit(self):
    +        """A 1-3 char hex-only `branch` like "abc" should not be misinterpreted as a commit hash.
    +
    +        Previously the `set(branch).issubset(string.hexdigits)` heuristic admitted any hex-only
    +        string and handed it to `git checkout`, producing a cryptic GitCommandError. After
    +        hardening, such a value (when it isn't a real branch/tag) surfaces as `BranchDoesNotExist`.
    +        """
    +        for value in ("a", "ab", "abc"):
    +            with self.subTest(branch=value):
    +                with self.assertRaises(BranchDoesNotExist):
    +                    self.repo.checkout(value)
    +
    +    def test_checkout_accepts_valid_refs(self):
    +        """Sanity check: real branch and tag names still check out successfully after validation."""
    +        for value in ("main", "valid-files"):
    +            with self.subTest(ref=value):
    +                head, _ = self.repo.checkout(value)
    +                self.assertEqual(head, self.repo.head)
    +
    +    def test_diff_remote_rejects_invalid_branch(self):
    +        """`diff_remote()` validates `branch` the same way `checkout()` does."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (None, "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("--orphan", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.diff_remote(value)
    
  • nautobot/core/utils/git.py+77 3 modified
    @@ -56,6 +56,50 @@ class BranchDoesNotExist(Exception):
         pass
     
     
    +# Upper bound for commit identifiers; matches `GitRepository.current_head`'s `max_length=48`.
    +MAX_COMMIT_HEXSHA_LENGTH = 48
    +MIN_COMMIT_HEXSHA_LENGTH = 4
    +
    +
    +def validate_git_ref(value, field_name="branch"):
    +    """Validate a git ref name (branch, tag, or commit identifier).
    +
    +    Rejects values that aren't strings, are empty, contain whitespace, or start with `-`
    +    (which git CLI would parse as an option rather than a ref).
    +
    +    Raises:
    +        ValueError: If `value` fails any of the above checks.
    +    """
    +    if not isinstance(value, str) or not value:
    +        raise ValueError(f"{field_name} must be a non-empty string")
    +    if any(c.isspace() for c in value):
    +        raise ValueError(f"Invalid {field_name} {value!r}: must not contain whitespace")
    +    if value.startswith("-"):
    +        raise ValueError(f"Invalid {field_name} {value!r}: must not start with '-'")
    +
    +
    +def validate_commit_hexsha(value, field_name="commit_hexsha"):
    +    """Validate a (possibly abbreviated) commit hash.
    +
    +    Empty string and None are accepted as "no commit specified". Any non-empty value must be
    +    a hexadecimal string of length 4-48 (matching `GitRepository.current_head`'s `max_length`).
    +
    +    Raises:
    +        ValueError: If `value` is non-empty and not a valid hex commit identifier.
    +    """
    +    if not value:
    +        return
    +    if not isinstance(value, str):
    +        raise ValueError(f"{field_name} must be a string if provided")
    +    if not (MIN_COMMIT_HEXSHA_LENGTH <= len(value) <= MAX_COMMIT_HEXSHA_LENGTH) or not set(value).issubset(
    +        string.hexdigits
    +    ):
    +        raise ValueError(
    +            f"Invalid {field_name} {value!r}: "
    +            f"must be {MIN_COMMIT_HEXSHA_LENGTH}-{MAX_COMMIT_HEXSHA_LENGTH} hexadecimal characters"
    +        )
    +
    +
     class GitRepo:
         def __init__(self, path, url, clone_initially=True, branch=None, depth=0):
             """
    @@ -67,7 +111,12 @@ def __init__(self, path, url, clone_initially=True, branch=None, depth=0):
                 clone_initially (bool): True if the repo needs to be cloned
                 branch (str): branch to checkout
                 depth (int): depth of the clone
    +
    +        Raises:
    +            ValueError: If `branch` is provided and contains whitespace or starts with `-`.
             """
    +        if branch is not None:
    +            validate_git_ref(branch, field_name="branch")
             self.url = url
             self.sanitized_url = sanitize(url)
             if os.path.isdir(path) and os.path.isdir(os.path.join(path, ".git")):
    @@ -106,23 +155,41 @@ def checkout(self, branch, commit_hexsha=None):
             If `commit_hexsha` is specified and `branch` is either a tag or a commit identifier, they must match.
             If `commit_hexsha` is specified and `branch` is a branch name, it must contain the specified commit.
     
    +        Raises:
    +            ValueError: If `branch` is empty, or if `branch` or `commit_hexsha` contain whitespace
    +                or start with `-` (which git CLI would parse as an option).
    +
             Returns:
                 (str, bool): commit_hexsha the repo contains now, and whether any change occurred
             """
    +        validate_git_ref(branch, field_name="branch")
    +        # `commit_hexsha` is named for its primary use, but in practice callers (e.g.
    +        # `GitRepository.clone_to_directory`) also pass tag names here. We only validate
    +        # the shape needed to prevent CLI argument injection — strict hex validation lives
    +        # on the model field where the value is genuinely a hash.
    +        if commit_hexsha:
    +            validate_git_ref(commit_hexsha, field_name="commit_hexsha")
    +
             # Short-circuit logic - do we already have this commit checked out?
             if commit_hexsha and self.head.startswith(commit_hexsha):
                 logger.debug(f"Commit {commit_hexsha} is already checked out.")
                 return (self.head, False)
             # User might specify the commit as a "branch" name...
    -        if not commit_hexsha and set(branch).issubset(string.hexdigits) and self.head.startswith(branch):
    +        if (
    +            not commit_hexsha
    +            and len(branch) >= MIN_COMMIT_HEXSHA_LENGTH
    +            and set(branch).issubset(string.hexdigits)
    +            and self.head.startswith(branch)
    +        ):
                 logger.debug("Commit %s is already checked out.", branch)
                 return (self.head, False)
     
             self.fetch()
             # Is `branch` actually a branch, a tag, or a commit? Heuristics:
             is_branch = branch in self.repo.remotes.origin.refs
             is_tag = branch in self.repo.tags
    -        maybe_commit = set(branch).issubset(string.hexdigits)
    +        # Hex-only strings shorter than MIN_COMMIT_HEXSHA_LENGTH could just as easily be ref names.
    +        maybe_commit = len(branch) >= MIN_COMMIT_HEXSHA_LENGTH and set(branch).issubset(string.hexdigits)
             logger.debug(
                 "Branch %s --> is_branch: %s, is_tag: %s, maybe_commit: %s",
                 branch,
    @@ -205,12 +272,19 @@ def checkout(self, branch, commit_hexsha=None):
             )
     
         def diff_remote(self, branch):
    +        """Diff the local working tree against the named remote branch/tag/commit.
    +
    +        Raises:
    +            ValueError: If `branch` is empty, contains whitespace, or starts with `-`.
    +        """
    +        validate_git_ref(branch, field_name="branch")
             logger.debug("Fetching from remote.")
             self.fetch()
             # Is `branch` actually a branch, a tag, or a commit? Heuristics:
             is_branch = branch in self.repo.remotes.origin.refs
             is_tag = branch in self.repo.tags
    -        maybe_commit = set(branch).issubset(string.hexdigits)
    +        # Hex-only strings shorter than MIN_COMMIT_HEXSHA_LENGTH could just as easily be ref names.
    +        maybe_commit = len(branch) >= MIN_COMMIT_HEXSHA_LENGTH and set(branch).issubset(string.hexdigits)
             logger.debug(
                 "Branch %s --> is_branch: %s, is_tag: %s, maybe_commit: %s",
                 branch,
    
  • nautobot/extras/api/serializers.py+1 0 modified
    @@ -470,6 +470,7 @@ class GitRepositorySerializer(TaggedModelSerializerMixin, NautobotModelSerialize
         class Meta:
             model = GitRepository
             fields = "__all__"
    +        read_only_fields = ["current_head"]
     
     
     #
    
  • nautobot/extras/models/datasources.py+15 1 modified
    @@ -15,7 +15,7 @@
     from nautobot.core.models.fields import AutoSlugField, LaxURLField, slugify_dashes_to_underscores
     from nautobot.core.models.generics import PrimaryModel
     from nautobot.core.models.validators import EnhancedURLValidator
    -from nautobot.core.utils.git import GitRepo
    +from nautobot.core.utils.git import GitRepo, validate_commit_hexsha, validate_git_ref
     from nautobot.core.utils.module_loading import check_name_safe_to_import_privately
     from nautobot.extras.utils import extras_features
     
    @@ -94,6 +94,15 @@ def __str__(self):
         def clean(self):
             super().clean()
     
    +        try:
    +            validate_git_ref(self.branch, field_name="branch")
    +        except ValueError as exc:
    +            raise ValidationError({"branch": str(exc)}) from exc
    +        try:
    +            validate_commit_hexsha(self.current_head, field_name="current_head")
    +        except ValueError as exc:
    +            raise ValidationError({"current_head": str(exc)}) from exc
    +
             # Autogenerate slug now, rather than in pre_save(), if not set already, as we need to check it below.
             if self.slug == "":
                 self._meta.get_field("slug").create_slug(self, add=(not self.present_in_database))
    @@ -224,6 +233,11 @@ def clone_to_directory(self, path=None, branch=None, head=None, depth=0):
     
             if branch and head:
                 raise ValueError("Cannot specify both branch and head")
    +        # Validate up-front so a malformed value doesn't leak a temp dir created below.
    +        if branch is not None:
    +            validate_git_ref(branch, field_name="branch")
    +        if head:
    +            validate_git_ref(head, field_name="head")
     
             try:
                 path_name = tempfile.mkdtemp(dir=path, prefix=self.slug)
    
  • nautobot/extras/tests/test_api.py+30 0 modified
    @@ -1352,6 +1352,36 @@ def test_create_with_app_provided_contents(self):
             self.assertHttpStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(list(response.data["provided_contents"]), data["provided_contents"])
     
    +    def test_current_head_is_read_only(self):
    +        """`current_head` is set by the sync job and must not be writable via the REST API."""
    +        self.add_permissions("extras.add_gitrepository")
    +        self.add_permissions("extras.change_gitrepository")
    +        bogus_sha = "0000000000000000000000000000000000000000"
    +
    +        # Create: any client-supplied `current_head` should be ignored.
    +        create_url = self._get_list_url()
    +        create_data = {
    +            "name": "read_only_head_create",
    +            "slug": "read_only_head_create",
    +            "remote_url": "https://example.com/read_only_head_create.git",
    +            "current_head": bogus_sha,
    +        }
    +        response = self.client.post(create_url, create_data, format="json", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_201_CREATED)
    +        created = GitRepository.objects.get(slug="read_only_head_create")
    +        self.assertEqual(created.current_head, "")
    +        self.assertNotEqual(response.data["current_head"], bogus_sha)
    +
    +        # Update: PATCHing `current_head` should not modify the stored value.
    +        repo = self.repos[0]
    +        original_head = repo.current_head
    +        detail_url = self._get_detail_url(repo)
    +        response = self.client.patch(detail_url, {"current_head": bogus_sha}, format="json", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_200_OK)
    +        repo.refresh_from_db()
    +        self.assertEqual(repo.current_head, original_head)
    +        self.assertNotEqual(response.data["current_head"], bogus_sha)
    +
     
     class GraphQLQueryTest(APIViewTestCases.APIViewTestCase):
         model = GraphQLQuery
    
  • nautobot/extras/tests/test_models.py+45 0 modified
    @@ -1319,6 +1319,51 @@ def test_remote_url_hostname(self):
             self.repo.remote_url = "http://some-private-host/example.git"
             self.repo.validated_save()
     
    +    def test_clean_rejects_invalid_branch(self):
    +        """`clean()` rejects branch values that are empty, contain whitespace, or start with '-'."""
    +        for bad_value in ("", " main", "main\n", "--orphan"):
    +            with self.subTest(branch=bad_value):
    +                self.repo.branch = bad_value
    +                with self.assertRaises(ValidationError) as handler:
    +                    self.repo.validated_save()
    +                self.assertIn("branch", handler.exception.message_dict)
    +
    +    def test_clean_rejects_invalid_current_head(self):
    +        """`clean()` rejects non-empty `current_head` values that aren't valid hex commit identifiers."""
    +        for bad_value in ("--detach", "deadbeef-not-hex", "abc", "z" * 40):
    +            with self.subTest(current_head=bad_value):
    +                self.repo.current_head = bad_value
    +                with self.assertRaises(ValidationError) as handler:
    +                    self.repo.validated_save()
    +                self.assertIn("current_head", handler.exception.message_dict)
    +
    +    def test_clean_accepts_empty_current_head(self):
    +        """Empty `current_head` is the default for un-synced repos and must remain accepted."""
    +        self.repo.current_head = ""
    +        self.repo.validated_save()
    +
    +    def test_clone_to_directory_rejects_invalid_inputs_without_tempdir_leak(self):
    +        """`clone_to_directory()` must validate `branch`/`head` before creating its temp dir.
    +
    +        Otherwise an option-shaped or whitespace-containing value would only fail inside
    +        `GitRepo`, by which point the empty temp dir has already been created and orphaned.
    +        """
    +        target_dir = tempfile.mkdtemp()
    +        try:
    +            cases = [
    +                {"branch": "--upload-pack=evil"},
    +                {"branch": " main"},
    +                {"head": "--detach"},
    +                {"head": "ref\nname"},
    +            ]
    +            for kwargs in cases:
    +                with self.subTest(**kwargs):
    +                    with self.assertRaises(ValueError):
    +                        self.repo.clone_to_directory(path=target_dir, **kwargs)
    +                    self.assertEqual(os.listdir(target_dir), [])
    +        finally:
    +            shutil.rmtree(target_dir, ignore_errors=True)
    +
         def test_clone_to_directory_context_manager(self):
             """Confirm that the clone_to_directory_context() context manager method works as expected."""
             try:
    
c46f97040b2b

Merge commit from fork

https://github.com/nautobot/nautobotGlenn MatthewsMay 7, 2026via ghsa
7 files changed · +293 4
  • changes/GHSA-p3hx-pwf3-j8wr.security+2 0 added
    @@ -0,0 +1,2 @@
    +Fixed `GitRepository.current_head` being incorrectly user-editable through the REST API (GHSA-p3hx-pwf3-j8wr).
    +Added additional data validation to `GitRepository.clean()` and to various methods of the `GitRepo` helper class.
    
  • nautobot/core/tests/test_utils_git.py+123 0 added
    @@ -0,0 +1,123 @@
    +"""Tests for `nautobot.core.utils.git`."""
    +
    +import tempfile
    +
    +from nautobot.core.testing import TestCase
    +from nautobot.core.utils.git import BranchDoesNotExist, GitRepo
    +from nautobot.extras.tests.git_helper import create_and_populate_git_repository
    +
    +
    +class GitRepoTestCase(TestCase):
    +    """Tests verifying logic in the `GitRepo` helper class."""
    +
    +    @classmethod
    +    def setUpClass(cls):
    +        super().setUpClass()
    +        cls._remote_dir = tempfile.TemporaryDirectory()  # pylint: disable=consider-using-with
    +        create_and_populate_git_repository(cls._remote_dir.name)
    +        cls.remote_url = "file://" + cls._remote_dir.name
    +
    +    @classmethod
    +    def tearDownClass(cls):
    +        cls._remote_dir.cleanup()
    +        super().tearDownClass()
    +
    +    def setUp(self):
    +        super().setUp()
    +        self._local_dir = tempfile.TemporaryDirectory()  # pylint: disable=consider-using-with
    +        self.addCleanup(self._local_dir.cleanup)
    +        self.repo = GitRepo(self._local_dir.name, self.remote_url, branch="main")
    +
    +    def test_init_rejects_invalid_branch(self):
    +        """`GitRepo(branch=...)` rejects whitespace / dash-prefixed values before invoking git clone."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("main\n", "must not contain whitespace"),
    +            ("--upload-pack=evil", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with tempfile.TemporaryDirectory() as fresh_dir:
    +                    with self.assertRaisesRegex(ValueError, message_regex):
    +                        GitRepo(fresh_dir, self.remote_url, branch=value)
    +
    +    def test_init_accepts_none_branch(self):
    +        """`branch=None` (the default) means "clone the remote's default branch" and must remain accepted."""
    +        with tempfile.TemporaryDirectory() as fresh_dir:
    +            repo = GitRepo(fresh_dir, self.remote_url, branch=None)
    +            self.assertEqual(repo.head, self.repo.head)
    +
    +    def test_checkout_rejects_invalid_branch(self):
    +        """`branch` must be a non-empty string with no whitespace and no leading dash."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (None, "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("main\n", "must not contain whitespace"),
    +            ("ma in", "must not contain whitespace"),
    +            ("\tmain", "must not contain whitespace"),
    +            ("--orphan", r"must not start with '-'"),
    +            ("-h", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.checkout(value)
    +
    +    def test_checkout_rejects_invalid_commit_hexsha(self):
    +        """Non-empty `commit_hexsha` must not contain whitespace or start with a dash.
    +
    +        We only enforce the CLI-injection-shape rules here; strict hex validation lives on
    +        `GitRepository.current_head` where the value is genuinely a commit hash.
    +        """
    +        cases = [
    +            ("--detach", r"must not start with '-'"),
    +            ("-f", r"must not start with '-'"),
    +            ("abc def", "must not contain whitespace"),
    +            ("abc\n", "must not contain whitespace"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(commit_hexsha=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.checkout("main", commit_hexsha=value)
    +
    +    def test_checkout_accepts_empty_commit_hexsha_as_unspecified(self):
    +        """Empty `commit_hexsha` is treated as "not provided" — callers pass `repo.current_head`,
    +        which is "" for repos that have never been synced. Must remain accepted for backwards compat.
    +        """
    +        head, changed = self.repo.checkout("main", commit_hexsha="")
    +        self.assertTrue(changed)
    +        self.assertEqual(head, self.repo.head)
    +
    +    def test_checkout_short_hex_branch_not_treated_as_commit(self):
    +        """A 1-3 char hex-only `branch` like "abc" should not be misinterpreted as a commit hash.
    +
    +        Previously the `set(branch).issubset(string.hexdigits)` heuristic admitted any hex-only
    +        string and handed it to `git checkout`, producing a cryptic GitCommandError. After
    +        hardening, such a value (when it isn't a real branch/tag) surfaces as `BranchDoesNotExist`.
    +        """
    +        for value in ("a", "ab", "abc"):
    +            with self.subTest(branch=value):
    +                with self.assertRaises(BranchDoesNotExist):
    +                    self.repo.checkout(value)
    +
    +    def test_checkout_accepts_valid_refs(self):
    +        """Sanity check: real branch and tag names still check out successfully after validation."""
    +        for value in ("main", "valid-files"):
    +            with self.subTest(ref=value):
    +                head, _ = self.repo.checkout(value)
    +                self.assertEqual(head, self.repo.head)
    +
    +    def test_diff_remote_rejects_invalid_branch(self):
    +        """`diff_remote()` validates `branch` the same way `checkout()` does."""
    +        cases = [
    +            ("", "branch must be a non-empty string"),
    +            (None, "branch must be a non-empty string"),
    +            (" main", "must not contain whitespace"),
    +            ("--orphan", r"must not start with '-'"),
    +        ]
    +        for value, message_regex in cases:
    +            with self.subTest(branch=value):
    +                with self.assertRaisesRegex(ValueError, message_regex):
    +                    self.repo.diff_remote(value)
    
  • nautobot/core/utils/git.py+77 3 modified
    @@ -56,6 +56,50 @@ class BranchDoesNotExist(Exception):
         pass
     
     
    +# Upper bound for commit identifiers; matches `GitRepository.current_head`'s `max_length=48`.
    +MAX_COMMIT_HEXSHA_LENGTH = 48
    +MIN_COMMIT_HEXSHA_LENGTH = 4
    +
    +
    +def validate_git_ref(value, field_name="branch"):
    +    """Validate a git ref name (branch, tag, or commit identifier).
    +
    +    Rejects values that aren't strings, are empty, contain whitespace, or start with `-`
    +    (which git CLI would parse as an option rather than a ref).
    +
    +    Raises:
    +        ValueError: If `value` fails any of the above checks.
    +    """
    +    if not isinstance(value, str) or not value:
    +        raise ValueError(f"{field_name} must be a non-empty string")
    +    if any(c.isspace() for c in value):
    +        raise ValueError(f"Invalid {field_name} {value!r}: must not contain whitespace")
    +    if value.startswith("-"):
    +        raise ValueError(f"Invalid {field_name} {value!r}: must not start with '-'")
    +
    +
    +def validate_commit_hexsha(value, field_name="commit_hexsha"):
    +    """Validate a (possibly abbreviated) commit hash.
    +
    +    Empty string and None are accepted as "no commit specified". Any non-empty value must be
    +    a hexadecimal string of length 4-48 (matching `GitRepository.current_head`'s `max_length`).
    +
    +    Raises:
    +        ValueError: If `value` is non-empty and not a valid hex commit identifier.
    +    """
    +    if not value:
    +        return
    +    if not isinstance(value, str):
    +        raise ValueError(f"{field_name} must be a string if provided")
    +    if not (MIN_COMMIT_HEXSHA_LENGTH <= len(value) <= MAX_COMMIT_HEXSHA_LENGTH) or not set(value).issubset(
    +        string.hexdigits
    +    ):
    +        raise ValueError(
    +            f"Invalid {field_name} {value!r}: "
    +            f"must be {MIN_COMMIT_HEXSHA_LENGTH}-{MAX_COMMIT_HEXSHA_LENGTH} hexadecimal characters"
    +        )
    +
    +
     class GitRepo:
         def __init__(self, path, url, clone_initially=True, branch=None, depth=0):
             """
    @@ -67,7 +111,12 @@ def __init__(self, path, url, clone_initially=True, branch=None, depth=0):
                 clone_initially (bool): True if the repo needs to be cloned
                 branch (str): branch to checkout
                 depth (int): depth of the clone
    +
    +        Raises:
    +            ValueError: If `branch` is provided and contains whitespace or starts with `-`.
             """
    +        if branch is not None:
    +            validate_git_ref(branch, field_name="branch")
             self.url = url
             self.sanitized_url = sanitize(url)
             if os.path.isdir(path) and os.path.isdir(os.path.join(path, ".git")):
    @@ -106,23 +155,41 @@ def checkout(self, branch, commit_hexsha=None):
             If `commit_hexsha` is specified and `branch` is either a tag or a commit identifier, they must match.
             If `commit_hexsha` is specified and `branch` is a branch name, it must contain the specified commit.
     
    +        Raises:
    +            ValueError: If `branch` is empty, or if `branch` or `commit_hexsha` contain whitespace
    +                or start with `-` (which git CLI would parse as an option).
    +
             Returns:
                 (str, bool): commit_hexsha the repo contains now, and whether any change occurred
             """
    +        validate_git_ref(branch, field_name="branch")
    +        # `commit_hexsha` is named for its primary use, but in practice callers (e.g.
    +        # `GitRepository.clone_to_directory`) also pass tag names here. We only validate
    +        # the shape needed to prevent CLI argument injection — strict hex validation lives
    +        # on the model field where the value is genuinely a hash.
    +        if commit_hexsha:
    +            validate_git_ref(commit_hexsha, field_name="commit_hexsha")
    +
             # Short-circuit logic - do we already have this commit checked out?
             if commit_hexsha and self.head.startswith(commit_hexsha):
                 logger.debug(f"Commit {commit_hexsha} is already checked out.")
                 return (self.head, False)
             # User might specify the commit as a "branch" name...
    -        if not commit_hexsha and set(branch).issubset(string.hexdigits) and self.head.startswith(branch):
    +        if (
    +            not commit_hexsha
    +            and len(branch) >= MIN_COMMIT_HEXSHA_LENGTH
    +            and set(branch).issubset(string.hexdigits)
    +            and self.head.startswith(branch)
    +        ):
                 logger.debug("Commit %s is already checked out.", branch)
                 return (self.head, False)
     
             self.fetch()
             # Is `branch` actually a branch, a tag, or a commit? Heuristics:
             is_branch = branch in self.repo.remotes.origin.refs
             is_tag = branch in self.repo.tags
    -        maybe_commit = set(branch).issubset(string.hexdigits)
    +        # Hex-only strings shorter than MIN_COMMIT_HEXSHA_LENGTH could just as easily be ref names.
    +        maybe_commit = len(branch) >= MIN_COMMIT_HEXSHA_LENGTH and set(branch).issubset(string.hexdigits)
             logger.debug(
                 "Branch %s --> is_branch: %s, is_tag: %s, maybe_commit: %s",
                 branch,
    @@ -205,12 +272,19 @@ def checkout(self, branch, commit_hexsha=None):
             )
     
         def diff_remote(self, branch):
    +        """Diff the local working tree against the named remote branch/tag/commit.
    +
    +        Raises:
    +            ValueError: If `branch` is empty, contains whitespace, or starts with `-`.
    +        """
    +        validate_git_ref(branch, field_name="branch")
             logger.debug("Fetching from remote.")
             self.fetch()
             # Is `branch` actually a branch, a tag, or a commit? Heuristics:
             is_branch = branch in self.repo.remotes.origin.refs
             is_tag = branch in self.repo.tags
    -        maybe_commit = set(branch).issubset(string.hexdigits)
    +        # Hex-only strings shorter than MIN_COMMIT_HEXSHA_LENGTH could just as easily be ref names.
    +        maybe_commit = len(branch) >= MIN_COMMIT_HEXSHA_LENGTH and set(branch).issubset(string.hexdigits)
             logger.debug(
                 "Branch %s --> is_branch: %s, is_tag: %s, maybe_commit: %s",
                 branch,
    
  • nautobot/extras/api/serializers.py+1 0 modified
    @@ -555,6 +555,7 @@ class GitRepositorySerializer(TaggedModelSerializerMixin, NautobotModelSerialize
         class Meta:
             model = GitRepository
             fields = "__all__"
    +        read_only_fields = ["current_head"]
     
     
     #
    
  • nautobot/extras/models/datasources.py+15 1 modified
    @@ -19,7 +19,7 @@
     from nautobot.core.models.querysets import RestrictedQuerySet
     from nautobot.core.models.validators import EnhancedURLValidator
     from nautobot.core.utils.cache import construct_cache_key
    -from nautobot.core.utils.git import GitRepo
    +from nautobot.core.utils.git import GitRepo, validate_commit_hexsha, validate_git_ref
     from nautobot.core.utils.module_loading import check_name_safe_to_import_privately
     from nautobot.extras.utils import extras_features
     
    @@ -116,6 +116,15 @@ def __str__(self):
         def clean(self):
             super().clean()
     
    +        try:
    +            validate_git_ref(self.branch, field_name="branch")
    +        except ValueError as exc:
    +            raise ValidationError({"branch": str(exc)}) from exc
    +        try:
    +            validate_commit_hexsha(self.current_head, field_name="current_head")
    +        except ValueError as exc:
    +            raise ValidationError({"current_head": str(exc)}) from exc
    +
             # Autogenerate slug now, rather than in pre_save(), if not set already, as we need to check it below.
             if self.slug == "":
                 self._meta.get_field("slug").create_slug(self, add=(not self.present_in_database))
    @@ -246,6 +255,11 @@ def clone_to_directory(self, path=None, branch=None, head=None, depth=0):
     
             if branch and head:
                 raise ValueError("Cannot specify both branch and head")
    +        # Validate up-front so a malformed value doesn't leak a temp dir created below.
    +        if branch is not None:
    +            validate_git_ref(branch, field_name="branch")
    +        if head:
    +            validate_git_ref(head, field_name="head")
     
             try:
                 path_name = tempfile.mkdtemp(dir=path, prefix=self.slug)
    
  • nautobot/extras/tests/test_api.py+30 0 modified
    @@ -2194,6 +2194,36 @@ def test_create_with_app_provided_contents(self):
             self.assertHttpStatus(response, status.HTTP_201_CREATED)
             self.assertEqual(list(response.data["provided_contents"]), data["provided_contents"])
     
    +    def test_current_head_is_read_only(self):
    +        """`current_head` is set by the sync job and must not be writable via the REST API."""
    +        self.add_permissions("extras.add_gitrepository")
    +        self.add_permissions("extras.change_gitrepository")
    +        bogus_sha = "0000000000000000000000000000000000000000"
    +
    +        # Create: any client-supplied `current_head` should be ignored.
    +        create_url = self._get_list_url()
    +        create_data = {
    +            "name": "read_only_head_create",
    +            "slug": "read_only_head_create",
    +            "remote_url": "https://example.com/read_only_head_create.git",
    +            "current_head": bogus_sha,
    +        }
    +        response = self.client.post(create_url, create_data, format="json", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_201_CREATED)
    +        created = GitRepository.objects.get(slug="read_only_head_create")
    +        self.assertEqual(created.current_head, "")
    +        self.assertNotEqual(response.data["current_head"], bogus_sha)
    +
    +        # Update: PATCHing `current_head` should not modify the stored value.
    +        repo = self.repos[0]
    +        original_head = repo.current_head
    +        detail_url = self._get_detail_url(repo)
    +        response = self.client.patch(detail_url, {"current_head": bogus_sha}, format="json", **self.header)
    +        self.assertHttpStatus(response, status.HTTP_200_OK)
    +        repo.refresh_from_db()
    +        self.assertEqual(repo.current_head, original_head)
    +        self.assertNotEqual(response.data["current_head"], bogus_sha)
    +
     
     class GraphQLQueryTest(APIViewTestCases.APIViewTestCase):
         model = GraphQLQuery
    
  • nautobot/extras/tests/test_models.py+45 0 modified
    @@ -1843,6 +1843,51 @@ def test_remote_url_hostname(self):
             self.repo.remote_url = "http://some-private-host/example.git"
             self.repo.validated_save()
     
    +    def test_clean_rejects_invalid_branch(self):
    +        """`clean()` rejects branch values that are empty, contain whitespace, or start with '-'."""
    +        for bad_value in ("", " main", "main\n", "--orphan"):
    +            with self.subTest(branch=bad_value):
    +                self.repo.branch = bad_value
    +                with self.assertRaises(ValidationError) as handler:
    +                    self.repo.validated_save()
    +                self.assertIn("branch", handler.exception.message_dict)
    +
    +    def test_clean_rejects_invalid_current_head(self):
    +        """`clean()` rejects non-empty `current_head` values that aren't valid hex commit identifiers."""
    +        for bad_value in ("--detach", "deadbeef-not-hex", "abc", "z" * 40):
    +            with self.subTest(current_head=bad_value):
    +                self.repo.current_head = bad_value
    +                with self.assertRaises(ValidationError) as handler:
    +                    self.repo.validated_save()
    +                self.assertIn("current_head", handler.exception.message_dict)
    +
    +    def test_clean_accepts_empty_current_head(self):
    +        """Empty `current_head` is the default for un-synced repos and must remain accepted."""
    +        self.repo.current_head = ""
    +        self.repo.validated_save()
    +
    +    def test_clone_to_directory_rejects_invalid_inputs_without_tempdir_leak(self):
    +        """`clone_to_directory()` must validate `branch`/`head` before creating its temp dir.
    +
    +        Otherwise an option-shaped or whitespace-containing value would only fail inside
    +        `GitRepo`, by which point the empty temp dir has already been created and orphaned.
    +        """
    +        target_dir = tempfile.mkdtemp()
    +        try:
    +            cases = [
    +                {"branch": "--upload-pack=evil"},
    +                {"branch": " main"},
    +                {"head": "--detach"},
    +                {"head": "ref\nname"},
    +            ]
    +            for kwargs in cases:
    +                with self.subTest(**kwargs):
    +                    with self.assertRaises(ValueError):
    +                        self.repo.clone_to_directory(path=target_dir, **kwargs)
    +                    self.assertEqual(os.listdir(target_dir), [])
    +        finally:
    +            shutil.rmtree(target_dir, ignore_errors=True)
    +
         def test_clone_to_directory_context_manager(self):
             """Confirm that the clone_to_directory_context() context manager method works as expected."""
             try:
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

6

News mentions

0

No linked articles in our index yet.