Repository: flask-admin/flask-admin

Issue:
Missing CSRF token for bulk actions
When bulk actions (such as mass delete) are available then the associated form does not have a `csrf_token` field. In addition, the backend doesn't expect one and so happily performs those actions without the presence of one.

`flask_admin/templates/bootstrap3/admin/actions.html` and `flask_admin/templates/bootstrap2/admin/actions.html` contain code that checks for the presence of this field but even with CSRF protection enabled as per documentation, this field is never injected.

Everything else does have CSRF protection so the configuration seems correct.


Discussion:

Resolving patch:
diff --git a/flask_admin/actions.py b/flask_admin/actions.py
--- a/flask_admin/actions.py
+++ b/flask_admin/actions.py
@@ -3,7 +3,7 @@
 
 from flask_admin import tools
 from flask_admin._compat import text_type
-from flask_admin.helpers import get_redirect_target
+from flask_admin.helpers import get_redirect_target, flash_errors
 
 
 def action(name, text, confirmation=None):
@@ -104,16 +104,22 @@ def handle_action(self, return_view=None):
                 If not provided, will return user to the return url in the form
                 or the list view.
         """
-        action = request.form.get('action')
-        ids = request.form.getlist('rowid')
+        form = self.action_form()
 
-        handler = self._actions_data.get(action)
+        if self.validate_form(form):
+            # using getlist instead of FieldList for backward compatibility
+            ids = request.form.getlist('rowid')
+            action = form.action.data
 
-        if handler and self.is_action_allowed(action):
-            response = handler[0](ids)
+            handler = self._actions_data.get(action)
 
-            if response is not None:
-                return response
+            if handler and self.is_action_allowed(action):
+                response = handler[0](ids)
+
+                if response is not None:
+                    return response
+        else:
+            flash_errors(form, message='Failed to perform action. %(error)s')
 
         if return_view:
             url = self.get_url('.' + return_view)
diff --git a/flask_admin/contrib/fileadmin/__init__.py b/flask_admin/contrib/fileadmin/__init__.py
--- a/flask_admin/contrib/fileadmin/__init__.py
+++ b/flask_admin/contrib/fileadmin/__init__.py
@@ -413,6 +413,19 @@ class DeleteForm(self.form_base_class):
 
         return DeleteForm
 
+    def get_action_form(self):
+        """
+            Create form class for model action.
+
+            Override to implement customized behavior.
+        """
+        class ActionForm(self.form_base_class):
+            action = fields.HiddenField()
+            url = fields.HiddenField()
+            # rowid is retrieved using getlist, for backward compatibility
+
+        return ActionForm
+
     def upload_form(self):
         """
             Instantiate file upload form and return it.
@@ -471,6 +484,18 @@ def delete_form(self):
         else:
             return delete_form_class()
 
+    def action_form(self):
+        """
+            Instantiate action form and return it.
+
+            Override to implement custom behavior.
+        """
+        action_form_class = self.get_action_form()
+        if request.form:
+            return action_form_class(request.form)
+        else:
+            return action_form_class()
+
     def is_file_allowed(self, filename):
         """
             Verify if file can be uploaded.
@@ -812,6 +837,10 @@ def index_view(self, path=None):
 
         # Actions
         actions, actions_confirmation = self.get_actions_list()
+        if actions:
+            action_form = self.action_form()
+        else:
+            action_form = None
 
         def sort_url(column, invert=False):
             desc = None
@@ -829,6 +858,7 @@ def sort_url(column, invert=False):
                            items=items,
                            actions=actions,
                            actions_confirmation=actions_confirmation,
+                           action_form=action_form,
                            delete_form=delete_form,
                            sort_column=sort_column,
                            sort_desc=sort_desc,
diff --git a/flask_admin/model/base.py b/flask_admin/model/base.py
--- a/flask_admin/model/base.py
+++ b/flask_admin/model/base.py
@@ -795,6 +795,7 @@ def _refresh_forms_cache(self):
         self._create_form_class = self.get_create_form()
         self._edit_form_class = self.get_edit_form()
         self._delete_form_class = self.get_delete_form()
+        self._action_form_class = self.get_action_form()
 
         # List View In-Line Editing
         if self.column_editable_list:
@@ -1254,6 +1255,19 @@ class DeleteForm(self.form_base_class):
 
         return DeleteForm
 
+    def get_action_form(self):
+        """
+            Create form class for a model action.
+
+            Override to implement customized behavior.
+        """
+        class ActionForm(self.form_base_class):
+            action = HiddenField()
+            url = HiddenField()
+            # rowid is retrieved using getlist, for backward compatibility
+
+        return ActionForm
+
     def create_form(self, obj=None):
         """
             Instantiate model creation form and return it.
@@ -1295,6 +1309,14 @@ def list_form(self, obj=None):
         """
         return self._list_form_class(get_form_data(), obj=obj)
 
+    def action_form(self, obj=None):
+        """
+            Instantiate model action form and return it.
+
+            Override to implement custom behavior.
+        """
+        return self._action_form_class(get_form_data(), obj=obj)
+
     def validate_form(self, form):
         """
             Validate the form on submit.
@@ -1540,7 +1562,7 @@ def after_model_delete(self, model):
         """
         pass
 
-    def on_form_prefill (self, form, id):
+    def on_form_prefill(self, form, id):
         """
             Perform additional actions to pre-fill the edit form.
 
@@ -1874,6 +1896,10 @@ def page_size_url(s):
 
         # Actions
         actions, actions_confirmation = self.get_actions_list()
+        if actions:
+            action_form = self.action_form()
+        else:
+            action_form = None
 
         clear_search_url = self._get_list_url(view_args.clone(page=0,
                                                               sort=view_args.sort,
@@ -1886,6 +1912,7 @@ def page_size_url(s):
             data=data,
             list_forms=list_forms,
             delete_form=delete_form,
+            action_form=action_form,
 
             # List
             list_columns=self._list_columns,


Test patch:
diff --git a/flask_admin/tests/test_model.py b/flask_admin/tests/test_model.py
--- a/flask_admin/tests/test_model.py
+++ b/flask_admin/tests/test_model.py
@@ -379,7 +379,7 @@ def get_csrf_token(data):
 
     # Create with CSRF token
     rv = client.post('/admin/secure/new/', data=dict(name='test1',
-                                                   csrf_token=csrf_token))
+                                                     csrf_token=csrf_token))
     eq_(rv.status_code, 302)
 
     ###############
@@ -424,6 +424,23 @@ def get_csrf_token(data):
     eq_(rv.status_code, 200)
     ok_(u'Record was successfully deleted.' in rv.data.decode('utf-8'))
 
+    ################
+    # actions
+    ################
+    rv = client.get('/admin/secure/')
+    eq_(rv.status_code, 200)
+    ok_(u'name="csrf_token"' in rv.data.decode('utf-8'))
+
+    csrf_token = get_csrf_token(rv.data.decode('utf-8'))
+
+    # Delete without CSRF token, test validation errors
+    rv = client.post('/admin/secure/action/',
+                     data=dict(rowid='1', url='/admin/secure/', action='delete'),
+                     follow_redirects=True)
+    eq_(rv.status_code, 200)
+    ok_(u'Record was successfully deleted.' not in rv.data.decode('utf-8'))
+    ok_(u'Failed to perform action.' in rv.data.decode('utf-8'))
+
 
 def test_custom_form():
     app, admin = setup()