Repository: wagtail/wagtail

Issue:
csrf_token missing for admin dashboard workflow actions (resulting in 403)
### Issue Summary

When a `POST` is made to the workflow URLs `admin/pages/workflow/action/` via the dashboard buttons (e.g. Approve) it does not contain the `csrfToken` resulting in a 403 each time.

### Steps to Reproduce

1. Start a new project with `wagtail start myproject`
2. Create a new page & publish
3. Edit that same page but this time put to moderator's approval
4. Go to the dashboard (admin home)
5. Click 'Approve'
6. Expected: Should work & approve the page's moderation, Actual: user is shown a 403 error

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: No - Wagtail bakerydemo without changes only.


<img width="1873" alt="Screen Shot 2021-11-08 at 9 10 12 pm" src="https://user-images.githubusercontent.com/1396140/140733351-f955d1ed-283b-45b8-a85e-fc3ce20b6d49.png">


<img width="1873" alt="Screen Shot 2021-11-08 at 9 10 36 pm" src="https://user-images.githubusercontent.com/1396140/140733360-6657420b-6e14-4f28-9233-f81c95812305.png">


### Technical details

* Python version: 2.7.6
* Django version: 3.2.6
* Wagtail version: 2.15
* Browser version: Chrome 95 https://www.whatsmybrowser.org/b/VAX8V

### Likely root cause

* Dashboard renders buttons with `data-workflow-action-url` to indicate the action URL to POST to.
* `client/src/entrypoints/admin/workflow-action.js` has a function `ActivateWorkflowActionsForDashboard` which accepts a `csrfToken` and for non-modal workflow actions (e.g. approve but not approve with comment) it will `POST` to `buttonElement.dataset.workflowActionUrl` which in the approve case is something like `admin/pages/workflow/action/`
* This function is initiated in the template `wagtail/admin/templates/wagtailadmin/home/workflow_pages_to_moderate.html` - see `document.addEventListener('DOMContentLoaded', ActivateWorkflowActionsForDashboard('{{ csrf_token|escapejs }}'));`
* This template is used by `wagtail/admin/views/home.py` - see
* ```class WorkflowPagesToModeratePanel(Component):
    name = 'workflow_pages_to_moderate'
    template_name = 'wagtailadmin/home/workflow_pages_to_moderate.html'
    order = 220```
* However, the `get_context_data` does not appear to return the context variable `csrf_token` at all


### Recommended fix

* It is probably best to align the `csrf_token` with the Django tag usage instead of passing it down as a variable through the component's context.
* However, it appears that a similar approach has been taken for wagtail/admin/templates/wagtailadmin/home/locked_pages.html (not sure if that is broken also) - but it seems like there is an intentional escapejs usage



Discussion:
I experience this same issue with:

python version: 3.10.0
django version: 3.2.9
**wagtail version: 2.15**
browser version: chrome 95
cookiecutter-django installation
the same error!

Resolving patch:
diff --git a/wagtail/admin/views/home.py b/wagtail/admin/views/home.py
--- a/wagtail/admin/views/home.py
+++ b/wagtail/admin/views/home.py
@@ -51,6 +51,7 @@ def get_context_data(self, parent_context):
             user_perms.revisions_for_moderation().select_related('page', 'user').order_by('-created_at')
         )
         context['request'] = request
+        context['csrf_token'] = parent_context['csrf_token']
         return context
 
 
@@ -99,6 +100,7 @@ def get_context_data(self, parent_context):
         else:
             context['states'] = []
         context['request'] = request
+        context['csrf_token'] = parent_context['csrf_token']
         return context
 
 
@@ -117,6 +119,7 @@ def get_context_data(self, parent_context):
             ),
             'can_remove_locks': UserPagePermissionsProxy(request.user).can_remove_locks(),
             'request': request,
+            'csrf_token': parent_context['csrf_token'],
         })
         return context
 


Test patch:
diff --git a/wagtail/admin/tests/pages/test_page_locking.py b/wagtail/admin/tests/pages/test_page_locking.py
--- a/wagtail/admin/tests/pages/test_page_locking.py
+++ b/wagtail/admin/tests/pages/test_page_locking.py
@@ -126,6 +126,16 @@ def test_lock_post_bad_permissions(self):
         self.assertIsNone(page.locked_by)
         self.assertIsNone(page.locked_at)
 
+    def test_locked_pages_dashboard_panel(self):
+        self.child_page.locked = True
+        self.child_page.locked_by = self.user
+        self.child_page.locked_at = timezone.now()
+        self.child_page.save()
+        response = self.client.get(reverse('wagtailadmin_home'))
+        self.assertContains(response, "Your locked pages")
+        # check that LockUnlockAction is present and passes a valid csrf token
+        self.assertRegex(response.content.decode('utf-8'), r"LockUnlockAction\(\'\w+\'\, \'\/admin\/'\)")
+
     def test_unlock_post(self):
         # Lock the page
         self.child_page.locked = True
diff --git a/wagtail/admin/tests/test_moderation_list.py b/wagtail/admin/tests/test_moderation_list.py
--- a/wagtail/admin/tests/test_moderation_list.py
+++ b/wagtail/admin/tests/test_moderation_list.py
@@ -83,6 +83,9 @@ def test_edit_page(self):
         # Check response
         self.assertContains(response, self.edit_page_url, count=2)
 
+        # page should contain Approve and Reject forms including a valid CSRF token
+        self.assertRegex(response.content.decode('utf-8'), r'<input type="hidden" name="csrfmiddlewaretoken" value="\w+">')
+
     def test_preview_for_moderation(self):
         # Login as moderator without edit permissions
         self.login_as_moderator_without_edit()
diff --git a/wagtail/admin/tests/test_workflows.py b/wagtail/admin/tests/test_workflows.py
--- a/wagtail/admin/tests/test_workflows.py
+++ b/wagtail/admin/tests/test_workflows.py
@@ -1018,6 +1018,12 @@ def test_approve_task_and_workflow(self):
         self.assertEqual(mock_call['instance'], self.page)
         self.assertIsInstance(mock_call['instance'], self.page.specific_class)
 
+    def test_workflow_dashboard_panel(self):
+        response = self.client.get(reverse('wagtailadmin_home'))
+        self.assertContains(response, "Awaiting your review")
+        # check that ActivateWorkflowActionsForDashboard is present and passes a valid csrf token
+        self.assertRegex(response.content.decode('utf-8'), r"ActivateWorkflowActionsForDashboard\(\'\w+\'\)")
+
     def test_workflow_action_get(self):
         """
         This tests that a GET request to the workflow action view (for the approve action) returns a modal with a form for extra data entry: