mirror of
https://github.com/ansible/awx.git
synced 2026-02-03 10:38:15 -03:30
blacklist certain sensitive fields and relations as search arguments
see: #5465 see: #5478
This commit is contained in:
@@ -89,7 +89,8 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
# those lookups combined with request.user.get_queryset(Model) to make
|
# those lookups combined with request.user.get_queryset(Model) to make
|
||||||
# sure user cannot query using objects he could not view.
|
# sure user cannot query using objects he could not view.
|
||||||
new_parts = []
|
new_parts = []
|
||||||
for n, name in enumerate(parts[:-1]):
|
|
||||||
|
for name in parts[:-1]:
|
||||||
# HACK: Make project and inventory source filtering by old field names work for backwards compatibility.
|
# HACK: Make project and inventory source filtering by old field names work for backwards compatibility.
|
||||||
if model._meta.object_name in ('Project', 'InventorySource'):
|
if model._meta.object_name in ('Project', 'InventorySource'):
|
||||||
name = {
|
name = {
|
||||||
@@ -111,6 +112,10 @@ class FieldLookupBackend(BaseFilterBackend):
|
|||||||
field = model._meta.pk
|
field = model._meta.pk
|
||||||
else:
|
else:
|
||||||
field = model._meta.get_field_by_name(name)[0]
|
field = model._meta.get_field_by_name(name)[0]
|
||||||
|
if isinstance(field, ForeignObjectRel) and getattr(field.field, '__prevent_search__', False):
|
||||||
|
raise PermissionDenied('Filtering on %s is not allowed.' % name)
|
||||||
|
elif getattr(field, '__prevent_search__', False):
|
||||||
|
raise PermissionDenied('Filtering on %s is not allowed.' % name)
|
||||||
model = getattr(field, 'related_model', None) or field.model
|
model = getattr(field, 'related_model', None) or field.model
|
||||||
|
|
||||||
if parts:
|
if parts:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from rest_framework import status
|
|||||||
from rest_framework import views
|
from rest_framework import views
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
|
from awx.api.filters import FieldLookupBackend
|
||||||
from awx.main.models import * # noqa
|
from awx.main.models import * # noqa
|
||||||
from awx.main.utils import * # noqa
|
from awx.main.utils import * # noqa
|
||||||
from awx.api.serializers import ResourceAccessListElementSerializer
|
from awx.api.serializers import ResourceAccessListElementSerializer
|
||||||
@@ -297,7 +298,16 @@ class ListAPIView(generics.ListAPIView, GenericAPIView):
|
|||||||
if relationship.related_model._meta.app_label != 'main':
|
if relationship.related_model._meta.app_label != 'main':
|
||||||
continue
|
continue
|
||||||
fields.append('{}__search'.format(relationship.name))
|
fields.append('{}__search'.format(relationship.name))
|
||||||
return fields
|
|
||||||
|
allowed_fields = []
|
||||||
|
for field in fields:
|
||||||
|
try:
|
||||||
|
FieldLookupBackend().get_field_from_lookup(self.model, field)
|
||||||
|
except PermissionDenied:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
allowed_fields.append(field)
|
||||||
|
return allowed_fields
|
||||||
|
|
||||||
|
|
||||||
class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
|
class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import json
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
# Tower
|
# Tower
|
||||||
from awx.main.models.base import CreatedModifiedModel
|
from awx.main.models.base import CreatedModifiedModel, prevent_search
|
||||||
from awx.main.fields import JSONField
|
from awx.main.fields import JSONField
|
||||||
from awx.main.utils import encrypt_field
|
from awx.main.utils import encrypt_field
|
||||||
from awx.conf import settings_registry
|
from awx.conf import settings_registry
|
||||||
@@ -24,14 +24,14 @@ class Setting(CreatedModifiedModel):
|
|||||||
value = JSONField(
|
value = JSONField(
|
||||||
null=True,
|
null=True,
|
||||||
)
|
)
|
||||||
user = models.ForeignKey(
|
user = prevent_search(models.ForeignKey(
|
||||||
'auth.User',
|
'auth.User',
|
||||||
related_name='settings',
|
related_name='settings',
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
editable=False,
|
editable=False,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
)
|
))
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ _PythonSerializer.handle_m2m_field = _new_handle_m2m_field
|
|||||||
|
|
||||||
|
|
||||||
# Add custom methods to User model for permissions checks.
|
# Add custom methods to User model for permissions checks.
|
||||||
from django.contrib.auth.models import User # noqa
|
from django.contrib.auth.models import User # noqa
|
||||||
from awx.main.access import * # noqa
|
from awx.main.access import * # noqa
|
||||||
|
|
||||||
|
|
||||||
@@ -128,3 +128,6 @@ activity_stream_registrar.connect(User)
|
|||||||
activity_stream_registrar.connect(WorkflowJobTemplate)
|
activity_stream_registrar.connect(WorkflowJobTemplate)
|
||||||
activity_stream_registrar.connect(WorkflowJobTemplateNode)
|
activity_stream_registrar.connect(WorkflowJobTemplateNode)
|
||||||
activity_stream_registrar.connect(WorkflowJob)
|
activity_stream_registrar.connect(WorkflowJob)
|
||||||
|
|
||||||
|
# prevent API filtering on certain Django-supplied sensitive fields
|
||||||
|
prevent_search(User._meta.get_field('password'))
|
||||||
|
|||||||
@@ -83,10 +83,10 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
|
|||||||
editable=False,
|
editable=False,
|
||||||
through='AdHocCommandEvent',
|
through='AdHocCommandEvent',
|
||||||
)
|
)
|
||||||
extra_vars = models.TextField(
|
extra_vars = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
))
|
||||||
|
|
||||||
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from crum import get_current_user
|
|||||||
# Ansible Tower
|
# Ansible Tower
|
||||||
from awx.main.utils import encrypt_field
|
from awx.main.utils import encrypt_field
|
||||||
|
|
||||||
__all__ = ['VarsDictProperty', 'BaseModel', 'CreatedModifiedModel',
|
__all__ = ['prevent_search', 'VarsDictProperty', 'BaseModel', 'CreatedModifiedModel',
|
||||||
'PasswordFieldsModel', 'PrimordialModel', 'CommonModel',
|
'PasswordFieldsModel', 'PrimordialModel', 'CommonModel',
|
||||||
'CommonModelNameNotUnique', 'NotificationFieldsModel',
|
'CommonModelNameNotUnique', 'NotificationFieldsModel',
|
||||||
'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ',
|
'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ',
|
||||||
@@ -343,3 +343,21 @@ class NotificationFieldsModel(BaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
related_name='%(class)s_notification_templates_for_any'
|
related_name='%(class)s_notification_templates_for_any'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def prevent_search(relation):
|
||||||
|
"""
|
||||||
|
Used to mark a model field or relation as "restricted from filtering"
|
||||||
|
e.g.,
|
||||||
|
|
||||||
|
class AuthToken(BaseModel):
|
||||||
|
user = prevent_search(models.ForeignKey(...))
|
||||||
|
sensitive_data = prevent_search(models.CharField(...))
|
||||||
|
|
||||||
|
The flag set by this function is used by
|
||||||
|
`awx.api.filters.FieldLookupBackend` to blacklist fields and relations that
|
||||||
|
should not be searchable/filterable via search query params
|
||||||
|
"""
|
||||||
|
setattr(relation, '__prevent_search__', True)
|
||||||
|
return relation
|
||||||
|
|||||||
@@ -1284,11 +1284,11 @@ class CustomInventoryScript(CommonModelNameNotUnique, ResourceMixin):
|
|||||||
unique_together = [('name', 'organization')]
|
unique_together = [('name', 'organization')]
|
||||||
ordering = ('name',)
|
ordering = ('name',)
|
||||||
|
|
||||||
script = models.TextField(
|
script = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
help_text=_('Inventory script contents'),
|
help_text=_('Inventory script contents'),
|
||||||
)
|
))
|
||||||
organization = models.ForeignKey(
|
organization = models.ForeignKey(
|
||||||
'Organization',
|
'Organization',
|
||||||
related_name='custom_inventory_scripts',
|
related_name='custom_inventory_scripts',
|
||||||
|
|||||||
@@ -117,10 +117,10 @@ class JobOptions(BaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
default=0,
|
default=0,
|
||||||
)
|
)
|
||||||
extra_vars = models.TextField(
|
extra_vars = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
))
|
||||||
job_tags = models.CharField(
|
job_tags = models.CharField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -1252,10 +1252,10 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
extra_vars = models.TextField(
|
extra_vars = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
))
|
||||||
|
|
||||||
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.contrib.auth.models import User # noqa
|
from django.contrib.auth.models import User # noqa
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
|
from awx.main.models.base import prevent_search
|
||||||
from awx.main.models.rbac import (
|
from awx.main.models.rbac import (
|
||||||
Role, RoleAncestorEntry, get_roles_on_resource
|
Role, RoleAncestorEntry, get_roles_on_resource
|
||||||
)
|
)
|
||||||
@@ -86,10 +87,10 @@ class SurveyJobTemplateMixin(models.Model):
|
|||||||
survey_enabled = models.BooleanField(
|
survey_enabled = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
survey_spec = JSONField(
|
survey_spec = prevent_search(JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default={},
|
default={},
|
||||||
)
|
))
|
||||||
|
|
||||||
def survey_password_variables(self):
|
def survey_password_variables(self):
|
||||||
vars = []
|
vars = []
|
||||||
@@ -215,11 +216,11 @@ class SurveyJobMixin(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
survey_passwords = JSONField(
|
survey_passwords = prevent_search(JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default={},
|
default={},
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
))
|
||||||
|
|
||||||
def display_extra_vars(self):
|
def display_extra_vars(self):
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -220,12 +220,13 @@ class AuthToken(BaseModel):
|
|||||||
app_label = 'main'
|
app_label = 'main'
|
||||||
|
|
||||||
key = models.CharField(max_length=40, primary_key=True)
|
key = models.CharField(max_length=40, primary_key=True)
|
||||||
user = models.ForeignKey('auth.User', related_name='auth_tokens',
|
user = prevent_search(models.ForeignKey('auth.User',
|
||||||
on_delete=models.CASCADE)
|
related_name='auth_tokens', on_delete=models.CASCADE))
|
||||||
created = models.DateTimeField(auto_now_add=True)
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
modified = models.DateTimeField(auto_now=True)
|
modified = models.DateTimeField(auto_now=True)
|
||||||
expires = models.DateTimeField(default=tz_now)
|
expires = models.DateTimeField(default=tz_now)
|
||||||
request_hash = models.CharField(max_length=40, blank=True, default='')
|
request_hash = prevent_search(models.CharField(max_length=40, blank=True,
|
||||||
|
default=''))
|
||||||
reason = models.CharField(
|
reason = models.CharField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
blank=True,
|
blank=True,
|
||||||
|
|||||||
@@ -503,33 +503,33 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
|||||||
editable=False,
|
editable=False,
|
||||||
help_text=_("Elapsed time in seconds that the job ran."),
|
help_text=_("Elapsed time in seconds that the job ran."),
|
||||||
)
|
)
|
||||||
job_args = models.TextField(
|
job_args = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
))
|
||||||
job_cwd = models.CharField(
|
job_cwd = models.CharField(
|
||||||
max_length=1024,
|
max_length=1024,
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
)
|
||||||
job_env = JSONField(
|
job_env = prevent_search(JSONField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default={},
|
default={},
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
))
|
||||||
job_explanation = models.TextField(
|
job_explanation = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text=_("A status field to indicate the state of the job if it wasn't able to run and capture stdout"),
|
help_text=_("A status field to indicate the state of the job if it wasn't able to run and capture stdout"),
|
||||||
)
|
)
|
||||||
start_args = models.TextField(
|
start_args = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
editable=False,
|
editable=False,
|
||||||
)
|
))
|
||||||
result_stdout_text = models.TextField(
|
result_stdout_text = models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.core.urlresolvers import reverse
|
|||||||
#from django import settings as tower_settings
|
#from django import settings as tower_settings
|
||||||
|
|
||||||
# AWX
|
# AWX
|
||||||
from awx.main.models import UnifiedJobTemplate, UnifiedJob
|
from awx.main.models import prevent_search, UnifiedJobTemplate, UnifiedJob
|
||||||
from awx.main.models.notifications import (
|
from awx.main.models.notifications import (
|
||||||
NotificationTemplate,
|
NotificationTemplate,
|
||||||
JobNotificationMixin
|
JobNotificationMixin
|
||||||
@@ -280,10 +280,10 @@ class WorkflowJobOptions(BaseModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
extra_vars = models.TextField(
|
extra_vars = prevent_search(models.TextField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default='',
|
default='',
|
||||||
)
|
))
|
||||||
|
|
||||||
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
extra_vars_dict = VarsDictProperty('extra_vars', True)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import pytest
|
|||||||
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from awx.api.filters import FieldLookupBackend
|
from awx.api.filters import FieldLookupBackend
|
||||||
from awx.main.models import Credential, JobTemplate
|
from awx.main.models import (AdHocCommand, AuthToken, CustomInventoryScript,
|
||||||
|
Credential, Job, JobTemplate, SystemJob,
|
||||||
|
UnifiedJob, User, WorkflowJob,
|
||||||
|
WorkflowJobTemplate, WorkflowJobOptions)
|
||||||
|
from awx.main.models.jobs import JobOptions
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(u"empty_value", [u'', ''])
|
@pytest.mark.parametrize(u"empty_value", [u'', ''])
|
||||||
@@ -38,3 +42,28 @@ def test_filter_on_related_password_field(password_field, lookup_suffix):
|
|||||||
with pytest.raises(PermissionDenied) as excinfo:
|
with pytest.raises(PermissionDenied) as excinfo:
|
||||||
field, new_lookup = field_lookup.get_field_from_lookup(JobTemplate, lookup)
|
field, new_lookup = field_lookup.get_field_from_lookup(JobTemplate, lookup)
|
||||||
assert 'not allowed' in str(excinfo.value)
|
assert 'not allowed' in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('model, query', [
|
||||||
|
(AuthToken, 'request_hash__icontains'),
|
||||||
|
(User, 'password__icontains'),
|
||||||
|
(User, 'auth_tokens__key__icontains'),
|
||||||
|
(User, 'settings__value__icontains'),
|
||||||
|
(UnifiedJob, 'job_args__icontains'),
|
||||||
|
(UnifiedJob, 'job_env__icontains'),
|
||||||
|
(UnifiedJob, 'start_args__icontains'),
|
||||||
|
(AdHocCommand, 'extra_vars__icontains'),
|
||||||
|
(JobOptions, 'extra_vars__icontains'),
|
||||||
|
(SystemJob, 'extra_vars__icontains'),
|
||||||
|
(WorkflowJobOptions, 'extra_vars__icontains'),
|
||||||
|
(Job, 'survey_passwords__icontains'),
|
||||||
|
(WorkflowJob, 'survey_passwords__icontains'),
|
||||||
|
(JobTemplate, 'survey_spec__icontains'),
|
||||||
|
(WorkflowJobTemplate, 'survey_spec__icontains'),
|
||||||
|
(CustomInventoryScript, 'script__icontains')
|
||||||
|
])
|
||||||
|
def test_filter_sensitive_fields_and_relations(model, query):
|
||||||
|
field_lookup = FieldLookupBackend()
|
||||||
|
with pytest.raises(PermissionDenied) as excinfo:
|
||||||
|
field, new_lookup = field_lookup.get_field_from_lookup(model, query)
|
||||||
|
assert 'not allowed' in str(excinfo.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user