blacklist certain sensitive fields and relations as search arguments

see: #5465
see: #5478
This commit is contained in:
Ryan Petrello
2017-02-21 12:18:40 -05:00
parent 0a5b43acae
commit d24fb32358
13 changed files with 99 additions and 32 deletions

View File

@@ -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:

View File

@@ -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):

View File

@@ -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:

View File

@@ -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'))

View File

@@ -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)

View File

@@ -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

View File

@@ -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',

View File

@@ -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)

View File

@@ -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):
''' '''

View File

@@ -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,

View File

@@ -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='',

View File

@@ -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)

View File

@@ -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)