From fcd70035106f3803992ee7220405186e1bb892e9 Mon Sep 17 00:00:00 2001 From: Pratyush Bhandari <112966709+prat98@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:23:08 -0400 Subject: [PATCH] [AAP-78700] Add field level validations for source path traversal (#16637) * Add field level validations for source path traversal * Reject absolute UNC and drive-qualified paths * Remove absolute path check --- awx/api/serializers.py | 11 ++- awx/api/validators.py | 12 +++ .../tests/functional/api/test_inventory.py | 77 +++++++++++++++++++ .../serializers/test_inventory_serializers.py | 60 +++++++++++++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 31b625e2b4..6337b6eab7 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -128,7 +128,7 @@ from awx.api.versioning import reverse from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField # AWX Utils -from awx.api.validators import HostnameRegexValidator +from awx.api.validators import HostnameRegexValidator, contains_path_traversal logger = logging.getLogger('awx.api.serializers') @@ -2350,6 +2350,15 @@ class InventorySourceOptionsSerializer(BaseSerializer): res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential}) return res + def validate_source_path(self, value): + # Unchanged values on update are grandfathered so pre-existing traversal + # paths are not rejected until they are edited (AAP-78700). + if self.instance is not None and getattr(self.instance, 'source_path', None) == value: + return value + if contains_path_traversal(value): + raise serializers.ValidationError(_("Enter a path that does not include '..' path segments.")) + return value + def validate_source_vars(self, value): ret = vars_validate_or_raise(value) for env_k in parse_yaml_or_json(value): diff --git a/awx/api/validators.py b/awx/api/validators.py index d119e85292..b8f9be10a5 100644 --- a/awx/api/validators.py +++ b/awx/api/validators.py @@ -4,6 +4,18 @@ from django.core.validators import RegexValidator, validate_ipv46_address from django.core.exceptions import ValidationError +def contains_path_traversal(value): + """Return True if value contains a '..' path segment. + + Matches POSIX (`../`) and Windows (`..\\`) traversal. A file name that + merely contains two dots (e.g. `backup..yml`) is not traversal. + Absolute paths are allowed; they are not parent-directory traversal. + """ + if not isinstance(value, str) or not value: + return False + return any(part == '..' for part in value.replace('\\', '/').split('/')) + + class HostnameRegexValidator(RegexValidator): """ Fully validates a domain name that is compliant with norms in Linux/RHEL diff --git a/awx/main/tests/functional/api/test_inventory.py b/awx/main/tests/functional/api/test_inventory.py index 6d6481db29..f37af494ea 100644 --- a/awx/main/tests/functional/api/test_inventory.py +++ b/awx/main/tests/functional/api/test_inventory.py @@ -1004,3 +1004,80 @@ def test_inventory_names_unique_per_organization(post, admin_user): expect=400, ) assert 'Inventory with this Name and Organization already exists' in json.dumps(resp3.data) + + +@pytest.mark.django_db +class TestSourcePathTraversal: + """Serializer-level path traversal prevention on InventorySource.source_path (AAP-78700).""" + + def _create_payload(self, inventory, project, source_path, name='scm-src'): + return { + 'inventory': inventory.pk, + 'name': name, + 'source': 'scm', + 'source_project': project.pk, + 'source_path': source_path, + 'source_vars': 'plugin: a.b.c', + } + + @pytest.mark.parametrize( + 'source_path', + [ + '../etc/passwd', + 'inventories/../secrets', + '..\\windows\\path', + ], + ) + def test_create_rejects_traversal(self, post, inventory, project, admin_user, source_path): + response = post( + reverse('api:inventory_source_list'), + self._create_payload(inventory, project, source_path), + admin_user, + expect=400, + ) + assert 'source_path' in response.data + + def test_create_accepts_valid_path(self, post, inventory, project, admin_user): + response = post( + reverse('api:inventory_source_list'), + self._create_payload(inventory, project, 'playbooks/main.yml'), + admin_user, + expect=201, + ) + assert response.data['source_path'] == 'playbooks/main.yml' + + def test_update_rejects_changed_traversal_path(self, patch, inventory, project, admin_user): + with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): + inv_src = InventorySource.objects.create( + inventory=inventory, + name='scm-src', + source='scm', + source_project=project, + source_path='playbooks/main.yml', + ) + response = patch( + inv_src.get_absolute_url(), + {'source_path': '../etc/passwd'}, + admin_user, + expect=400, + ) + assert 'source_path' in response.data + + def test_update_grandfathers_unchanged_traversal_path(self, patch, inventory, project, admin_user): + legacy_path = '../legacy/hosts' + with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): + inv_src = InventorySource.objects.create( + inventory=inventory, + name='legacy-src', + source='scm', + source_project=project, + source_path=legacy_path, + ) + response = patch( + inv_src.get_absolute_url(), + {'source_path': legacy_path, 'name': 'legacy-src-renamed'}, + admin_user, + expect=200, + ) + assert response.data['source_path'] == legacy_path + assert response.data['name'] == 'legacy-src-renamed' diff --git a/awx/main/tests/unit/api/serializers/test_inventory_serializers.py b/awx/main/tests/unit/api/serializers/test_inventory_serializers.py index 98fa53901f..16b2566e5f 100644 --- a/awx/main/tests/unit/api/serializers/test_inventory_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_inventory_serializers.py @@ -2,10 +2,13 @@ import pytest from unittest import mock +from rest_framework.exceptions import ValidationError + # AWX from awx.api.serializers import ( InventorySourceSerializer, ) +from awx.api.validators import contains_path_traversal from awx.main.models import InventorySource @@ -35,3 +38,60 @@ class TestInventorySourceSerializerGetRelated(object): ) def test_get_related(self, test_get_related, inventory_source, related_resource_name): test_get_related(InventorySourceSerializer, inventory_source, 'inventory_sources', related_resource_name) + + +@pytest.mark.parametrize( + 'value,expected', + [ + ('playbooks/main.yml', False), + ('inventories/hosts', False), + ('backup..yml', False), + ('', False), + (None, False), + ('/var/lib/awx/example_source_path/', False), + ('../etc/passwd', True), + ('inventories/../secrets', True), + ('..\\windows\\path', True), + ('foo\\..\\bar', True), + ('..', True), + ('foo/..', True), + ], +) +def test_contains_path_traversal(value, expected): + assert contains_path_traversal(value) is expected + + +class TestInventorySourcePathTraversal: + def test_rejects_posix_traversal(self): + serializer = InventorySourceSerializer() + with pytest.raises(ValidationError) as exc: + serializer.validate_source_path('../etc/passwd') + assert 'path segments' in str(exc.value.detail) + + def test_rejects_windows_traversal(self): + serializer = InventorySourceSerializer() + with pytest.raises(ValidationError): + serializer.validate_source_path('..\\windows\\path') + + def test_accepts_valid_relative_path(self): + serializer = InventorySourceSerializer() + assert serializer.validate_source_path('playbooks/main.yml') == 'playbooks/main.yml' + + def test_accepts_absolute_path(self): + serializer = InventorySourceSerializer() + assert serializer.validate_source_path('/var/lib/awx/example_source_path/') == '/var/lib/awx/example_source_path/' + + def test_accepts_empty_path(self): + serializer = InventorySourceSerializer() + assert serializer.validate_source_path('') == '' + + def test_grandfathers_unchanged_traversal_path(self): + serializer = InventorySourceSerializer() + serializer.instance = mock.Mock(source_path='../legacy/hosts') + assert serializer.validate_source_path('../legacy/hosts') == '../legacy/hosts' + + def test_rejects_changed_traversal_path_on_update(self): + serializer = InventorySourceSerializer() + serializer.instance = mock.Mock(source_path='playbooks/main.yml') + with pytest.raises(ValidationError): + serializer.validate_source_path('../etc/passwd')