mirror of
https://github.com/ansible/awx.git
synced 2026-10-29 22:03:20 -02:30
[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:
+10
-1
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user