This is an automated email from the ASF dual-hosted git repository. maximebeauchemin pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push: new 3f29a1d Secure unsecured views and prevent regressions (#6553) 3f29a1d is described below commit 3f29a1dd70ce2ecd435ef6dd18b808c3e0046cb2 Author: Maxime Beauchemin <maximebeauche...@gmail.com> AuthorDate: Tue Dec 18 11:57:13 2018 -0800 Secure unsecured views and prevent regressions (#6553) * Secure views and prevent regressions * Force POST on shortner * Fix tests --- superset/views/core.py | 15 ++++++++------- superset/views/datasource.py | 1 + superset/views/sql_lab.py | 2 ++ tests/import_export_tests.py | 9 ++++++++- tests/security_tests.py | 38 ++++++++++++++++++++++++++++++++++---- 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/superset/views/core.py b/superset/views/core.py index 08aa7ac..a18ce78 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -621,6 +621,8 @@ class DashboardModelView(SupersetModelView, DeleteMixin): # noqa return redirect( '/dashboard/export_dashboards_form?{}'.format(ids[1:])) + @log_this + @has_access @expose('/export_dashboards_form') def download_dashboards(self): if request.args.get('action') == 'go': @@ -721,6 +723,7 @@ class KV(BaseSupersetView): """Used for storing and retrieving key value pairs""" @log_this + @has_access_api @expose('/store/', methods=['POST']) def store(self): try: @@ -735,6 +738,7 @@ class KV(BaseSupersetView): status=200) @log_this + @has_access_api @expose('/<key_id>/', methods=['GET']) def get_value(self, key_id): kv = None @@ -763,7 +767,8 @@ class R(BaseSupersetView): return redirect('/') @log_this - @expose('/shortner/', methods=['POST', 'GET']) + @has_access_api + @expose('/shortner/', methods=['POST']) def shortner(self): url = request.form.get('data') obj = models.Url(url=url) @@ -774,12 +779,6 @@ class R(BaseSupersetView): scheme=request.scheme, request=request, obj=obj), mimetype='text/plain') - @expose('/msg/') - def msg(self): - """Redirects to specified url while flash a message""" - flash(Markup(request.args.get('msg')), 'info') - return redirect(request.args.get('url')) - appbuilder.add_view_no_menu(R) @@ -2063,6 +2062,7 @@ class Superset(BaseSupersetView): [{'slice_id': slc.id, 'slice_name': slc.slice_name} for slc in slices])) + @has_access_api @expose('/favstar/<class_name>/<obj_id>/<action>/') def favstar(self, class_name, obj_id, action): """Toggle favorite stars on Slices and Dashboard""" @@ -2631,6 +2631,7 @@ class Superset(BaseSupersetView): security_manager.assert_datasource_permission(datasource) return json_success(json.dumps(datasource.data)) + @has_access_api @expose('/queries/<last_updated_ms>') def queries(self, last_updated_ms): """Get the updated queries.""" diff --git a/superset/views/datasource.py b/superset/views/datasource.py index db37ce7..5df20a2 100644 --- a/superset/views/datasource.py +++ b/superset/views/datasource.py @@ -35,6 +35,7 @@ class Datasource(BaseSupersetView): return self.json_response(data) @expose('/external_metadata/<datasource_type>/<datasource_id>/') + @has_access_api def external_metadata(self, datasource_type=None, datasource_id=None): """Gets column info from the source system""" orm_datasource = ConnectorRegistry.get_datasource( diff --git a/superset/views/sql_lab.py b/superset/views/sql_lab.py index d60fd3d..3c2fab3 100644 --- a/superset/views/sql_lab.py +++ b/superset/views/sql_lab.py @@ -2,6 +2,7 @@ from flask import g, redirect from flask_appbuilder import expose from flask_appbuilder.models.sqla.interface import SQLAInterface +from flask_appbuilder.security.decorators import has_access from flask_babel import gettext as __ from flask_babel import lazy_gettext as _ @@ -92,6 +93,7 @@ appbuilder.add_link( class SqlLab(BaseSupersetView): """The base views for Superset!""" @expose('/my_queries/') + @has_access def my_queries(self): """Assigns a list of found users to the given role.""" return redirect( diff --git a/tests/import_export_tests.py b/tests/import_export_tests.py index fc26385..d461658 100644 --- a/tests/import_export_tests.py +++ b/tests/import_export_tests.py @@ -129,7 +129,8 @@ class ImportExportTests(SupersetTestCase): id=dash_id).first() def get_dash_by_slug(self, dash_slug): - return db.session.query(models.Dashboard).filter_by( + sesh = db.session() + return sesh.query(models.Dashboard).filter_by( slug=dash_slug).first() def get_datasource(self, datasource_id): @@ -195,6 +196,7 @@ class ImportExportTests(SupersetTestCase): json.loads(expected_slc.params), json.loads(actual_slc.params)) def test_export_1_dashboard(self): + self.login('admin') birth_dash = self.get_dash_by_slug('births') export_dash_url = ( '/dashboard/export_dashboards_form?id={}&action=go' @@ -206,6 +208,7 @@ class ImportExportTests(SupersetTestCase): object_hook=utils.decode_dashboards, )['dashboards'] + birth_dash = self.get_dash_by_slug('births') self.assert_dash_equals(birth_dash, exported_dashboards[0]) self.assertEquals( birth_dash.id, @@ -223,6 +226,7 @@ class ImportExportTests(SupersetTestCase): self.get_table_by_name('birth_names'), exported_tables[0]) def test_export_2_dashboards(self): + self.login('admin') birth_dash = self.get_dash_by_slug('births') world_health_dash = self.get_dash_by_slug('world_health') export_dash_url = ( @@ -236,12 +240,15 @@ class ImportExportTests(SupersetTestCase): )['dashboards'], key=lambda d: d.dashboard_title) self.assertEquals(2, len(exported_dashboards)) + + birth_dash = self.get_dash_by_slug('births') self.assert_dash_equals(birth_dash, exported_dashboards[0]) self.assertEquals( birth_dash.id, json.loads(exported_dashboards[0].json_metadata)['remote_id'], ) + world_health_dash = self.get_dash_by_slug('world_health') self.assert_dash_equals(world_health_dash, exported_dashboards[1]) self.assertEquals( world_health_dash.id, diff --git a/tests/security_tests.py b/tests/security_tests.py index edeb2b7..6f046ba 100644 --- a/tests/security_tests.py +++ b/tests/security_tests.py @@ -1,4 +1,6 @@ -from superset import app, security_manager +import inspect + +from superset import app, appbuilder, security_manager from .base_tests import SupersetTestCase @@ -12,9 +14,6 @@ def get_perm_tuples(role_name): class RolePermissionTests(SupersetTestCase): """Testing export import functionality for dashboards""" - def __init__(self, *args, **kwargs): - super(RolePermissionTests, self).__init__(*args, **kwargs) - def assert_can_read(self, view_menu, permissions_set): self.assertIn(('can_show', view_menu), permissions_set) self.assertIn(('can_list', view_menu), permissions_set) @@ -216,3 +215,34 @@ class RolePermissionTests(SupersetTestCase): self.assertIn(('can_fave_slices', 'Superset'), gamma_perm_set) self.assertIn(('can_save_dash', 'Superset'), gamma_perm_set) self.assertIn(('can_slice', 'Superset'), gamma_perm_set) + + def test_views_are_secured(self): + """Preventing the addition of unsecured views without has_access decorator""" + # These FAB views are secured in their body as opposed to by decorators + method_whitelist = ('action', 'action_post') + # List of redirect & other benign views + views_whitelist = [ + ['MyIndexView', 'index'], + ['UtilView', 'back'], + ['LocaleView', 'index'], + ['AuthDBView', 'login'], + ['AuthDBView', 'logout'], + ['R', 'index'], + ['Superset', 'log'], + ['Superset', 'theme'], + ['Superset', 'welcome'], + ] + unsecured_views = [] + for view_class in appbuilder.baseviews: + class_name = view_class.__class__.__name__ + for name, value in inspect.getmembers(view_class, predicate=inspect.ismethod): + if ( + name not in method_whitelist and + [class_name, name] not in views_whitelist and + hasattr(value, '_urls') and + not hasattr(value, '_permission_name') + ): + unsecured_views.append((class_name, name)) + if unsecured_views: + view_str = '\n'.join([str(v) for v in unsecured_views]) + raise Exception(f'Some views are not secured:\n{view_str}')