From 50577da65ad1a24ecf1c7d9bc6ba724d9bdf6c93 Mon Sep 17 00:00:00 2001 From: Oliver Date: Sat, 20 Jun 2026 23:49:36 +1000 Subject: [PATCH] Add meaningful message on CSRF failure (#12216) * Add meaningful message on CSRF failure * Add link to CSRF_FAILURE_VIEW * Add unit test for new CSRF feedback --- src/backend/InvenTree/InvenTree/middleware.py | 26 ++++++++ src/backend/InvenTree/InvenTree/settings.py | 1 + .../InvenTree/InvenTree/test_middleware.py | 66 +++++++++++++++---- src/frontend/src/functions/auth.tsx | 4 +- 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/middleware.py b/src/backend/InvenTree/InvenTree/middleware.py index 98ffc08c22..0c6ff3c94b 100644 --- a/src/backend/InvenTree/InvenTree/middleware.py +++ b/src/backend/InvenTree/InvenTree/middleware.py @@ -106,6 +106,32 @@ apps_mfa_bypass = [ """App namespaces that bypass MFA enforcement - normal security model is still enforced.""" +def csrf_failure(request, reason=''): + """Custom CSRF failure handler. + + Returns a JSON response for API/headless requests so the frontend can + provide a meaningful error message to the user + """ + from django.views.csrf import csrf_failure as django_default + + if ( + request.path.startswith('/_allauth/') + or request.path.startswith('/api/') + or 'application/json' in request.headers.get('Accept', '') + or 'application/json' in request.headers.get('Content-Type', '') + ): + return JsonResponse( + { + 'detail': _( + 'CSRF verification failed. Ensure INVENTREE_SITE_URL and INVENTREE_TRUSTED_ORIGINS are configured correctly.' + ) + }, + status=403, + ) + + return django_default(request, reason=reason) + + class AuthRequiredMiddleware: """Check for user to be authenticated.""" diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index d5f643594c..06ba9dfc64 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -851,6 +851,7 @@ valid_cookie_modes = ['lax', 'strict', 'none'] COOKIE_MODE = COOKIE_MODE.capitalize() if COOKIE_MODE in valid_cookie_modes else False # Additional CSRF settings +CSRF_FAILURE_VIEW = 'InvenTree.middleware.csrf_failure' CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN' CSRF_COOKIE_NAME = 'csrftoken' diff --git a/src/backend/InvenTree/InvenTree/test_middleware.py b/src/backend/InvenTree/InvenTree/test_middleware.py index 410822d3cf..b6b89cf31d 100644 --- a/src/backend/InvenTree/InvenTree/test_middleware.py +++ b/src/backend/InvenTree/InvenTree/test_middleware.py @@ -2,8 +2,7 @@ from unittest.mock import patch -from django.conf import settings -from django.http import Http404 +from django.http import Http404, HttpRequest from django.urls import reverse from error_report.models import Error @@ -289,18 +288,63 @@ class MiddlewareTests(InvenTreeTestCase): response, 'window.INVENTREE_SETTINGS', status_code=500 ) - # Log stuff # TODO remove - print( - '###DBG-TST###', - 'site', - settings.SITE_URL, - 'trusted', - settings.CSRF_TRUSTED_ORIGINS, - ) - # Check that the correct step triggers the error message self.assertContains( response, 'INVE-E7: The visited path `http://testserver` does not match', status_code=500, ) + + def test_csrf_failure(self): + """Test the custom CSRF failure handler.""" + from InvenTree.middleware import csrf_failure + + EXPECTED_DETAIL = 'CSRF verification failed. Ensure INVENTREE_SITE_URL and INVENTREE_TRUSTED_ORIGINS are configured correctly.' + + def make_request(path, headers=None): + request = HttpRequest() + request.path = path + request.META['SERVER_NAME'] = 'testserver' + request.META['SERVER_PORT'] = '80' + for key, value in (headers or {}).items(): + request.META[f'HTTP_{key.upper().replace("-", "_")}'] = value + return request + + # API path -> JSON 403 with meaningful message + response = csrf_failure( + make_request('/api/part/'), reason='origin check failed' + ) + self.assertEqual(response.status_code, 403) + import json + + data = json.loads(response.content) + self.assertEqual(data['detail'], EXPECTED_DETAIL) + + # allauth headless path -> JSON 403 + response = csrf_failure(make_request('/_allauth/browser/v1/auth/login')) + self.assertEqual(response.status_code, 403) + data = json.loads(response.content) + self.assertEqual(data['detail'], EXPECTED_DETAIL) + + # Accept: application/json header -> JSON 403 + response = csrf_failure( + make_request('/some/other/path/', {'Accept': 'application/json'}), + reason='origin check failed', + ) + self.assertEqual(response.status_code, 403) + data = json.loads(response.content) + self.assertEqual(data['detail'], EXPECTED_DETAIL) + + # Content-Type: application/json header -> JSON 403 + response = csrf_failure( + make_request('/some/other/path/', {'Content-Type': 'application/json'}), + reason='origin check failed', + ) + self.assertEqual(response.status_code, 403) + data = json.loads(response.content) + self.assertEqual(data['detail'], EXPECTED_DETAIL) + + # Plain browser request -> falls back to Django default CSRF page (403 HTML, not JSON) + response = csrf_failure(make_request('/web/'), reason='origin check failed') + self.assertEqual(response.status_code, 403) + self.assertNotIn(b'application/json', response.get('Content-Type', '').encode()) diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 6f541f049c..2c940df733 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -131,7 +131,9 @@ export async function doBasicLogin( default: notifications.show({ title: `${t`Login failed`} (${err.response.status})`, - message: t`Check your input and try again.`, + message: + err.response?.data?.detail ?? + t`Check your input and try again.`, id: 'auth-login-error', color: 'red' });