From b539f08c4825bee12a0bbd6acd75c880235e7f35 Mon Sep 17 00:00:00 2001 From: serkan Date: Sat, 7 Mar 2026 15:33:06 +0800 Subject: [PATCH] Respect custom CSRF_HEADER_NAME in sortable update requests Pass Django's CSRF header setting from admin context to sortable config and use it when sending drag-and-drop update POST requests. Keep backward compatibility by falling back to X-CSRFToken. Add a regression test that verifies: - custom CSRF header is exposed in sortable config - default header is rejected when CSRF_HEADER_NAME is customized - custom header succeeds for adminsortable2_update Refs #434 --- CHANGELOG.md | 3 ++ adminsortable2/admin.py | 10 ++++ .../templates/adminsortable2/change_list.html | 1 + client/admin-sortable2.ts | 2 +- testapp/test_add_sortable.py | 52 ++++++++++++++++++- 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fece59e..c16c287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Release history of [django-admin-sortable2](https://github.com/jrief/django-admin-sortable2/) +### 2.3.2 +- fix #434: Respect Django's `CSRF_HEADER_NAME` for sortable update requests in the admin changelist. + ### 2.3.1 - fix #370: `django-compress` and `django-sass-processor` raises errors during run of compress or compilescss management command. diff --git a/adminsortable2/admin.py b/adminsortable2/admin.py index f78f80f..c9e7772 100644 --- a/adminsortable2/admin.py +++ b/adminsortable2/admin.py @@ -450,6 +450,7 @@ def _bulk_move(self, request, queryset, method): def changelist_view(self, request, extra_context=None): extra_context = extra_context or {} extra_context['sortable_update_url'] = self.get_update_url(request) + extra_context['sortable_csrf_header_name'] = self.get_csrf_header_name(request) extra_context['base_change_list_template'] = super().change_list_template or 'admin/change_list.html' return super().changelist_view(request, extra_context) @@ -459,6 +460,15 @@ def get_update_url(self, request): """ return reverse(f'{self.admin_site.name}:{self._get_update_url_name()}') + def get_csrf_header_name(self, request): + """ + Returns the HTTP header expected by Django's CSRF middleware. + """ + csrf_header_name = getattr(settings, 'CSRF_HEADER_NAME', 'HTTP_X_CSRFTOKEN') + if csrf_header_name.startswith('HTTP_'): + csrf_header_name = csrf_header_name[5:] + return csrf_header_name.replace('_', '-') + class PolymorphicSortableAdminMixin(SortableAdminMixin): """ diff --git a/adminsortable2/templates/adminsortable2/change_list.html b/adminsortable2/templates/adminsortable2/change_list.html index 8de9c8f..34a1883 100644 --- a/adminsortable2/templates/adminsortable2/change_list.html +++ b/adminsortable2/templates/adminsortable2/change_list.html @@ -5,6 +5,7 @@ ', + content, + re.S, + ) + assert config_match is not None + + config = json.loads(config_match.group(1)) + assert config['csrf_header'] == 'X-AUTH-CSRFTOKEN' + + token_match = re.search(r'name="csrfmiddlewaretoken" value="([^"]+)"', content) + assert token_match is not None + csrf_token = token_match.group(1) + + book = Book1.objects.order_by('my_order').first() + assert book is not None + + update_payload = json.dumps({'updatedItems': [[book.pk, book.my_order]]}) + update_url = config['update_url'] + + # Default header must fail when CSRF_HEADER_NAME is customized. + default_header_response = client.post( + update_url, + data=update_payload, + content_type='application/json', + HTTP_X_CSRFTOKEN=csrf_token, + ) + assert default_header_response.status_code == 403 + + # Custom header succeeds and keeps the endpoint compatible with Django settings. + custom_header_response = client.post( + update_url, + data=update_payload, + content_type='application/json', + HTTP_X_AUTH_CSRFTOKEN=csrf_token, + ) + assert custom_header_response.status_code == 200