[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
This commit is contained in:
Pratyush Bhandari
2026-09-10 16:23:08 +02:00
committed by GitHub
parent 94333d005e
commit fcd7003510
4 changed files with 159 additions and 1 deletions
+10 -1
View File
@@ -128,7 +128,7 @@ from awx.api.versioning import reverse
from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField, DeprecatedCredentialField
# AWX Utils # AWX Utils
from awx.api.validators import HostnameRegexValidator from awx.api.validators import HostnameRegexValidator, contains_path_traversal
logger = logging.getLogger('awx.api.serializers') logger = logging.getLogger('awx.api.serializers')
@@ -2350,6 +2350,15 @@ class InventorySourceOptionsSerializer(BaseSerializer):
res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential}) res['credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.credential})
return res 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): def validate_source_vars(self, value):
ret = vars_validate_or_raise(value) ret = vars_validate_or_raise(value)
for env_k in parse_yaml_or_json(value): for env_k in parse_yaml_or_json(value):
+12
View File
@@ -4,6 +4,18 @@ from django.core.validators import RegexValidator, validate_ipv46_address
from django.core.exceptions import ValidationError 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): class HostnameRegexValidator(RegexValidator):
""" """
Fully validates a domain name that is compliant with norms in Linux/RHEL Fully validates a domain name that is compliant with norms in Linux/RHEL
@@ -1004,3 +1004,80 @@ def test_inventory_names_unique_per_organization(post, admin_user):
expect=400, expect=400,
) )
assert 'Inventory with this Name and Organization already exists' in json.dumps(resp3.data) 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'
@@ -2,10 +2,13 @@
import pytest import pytest
from unittest import mock from unittest import mock
from rest_framework.exceptions import ValidationError
# AWX # AWX
from awx.api.serializers import ( from awx.api.serializers import (
InventorySourceSerializer, InventorySourceSerializer,
) )
from awx.api.validators import contains_path_traversal
from awx.main.models import InventorySource 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): def test_get_related(self, test_get_related, inventory_source, related_resource_name):
test_get_related(InventorySourceSerializer, inventory_source, 'inventory_sources', 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')