From c6c59c88136d6b3db6bb43f344e02d8db47414c2 Mon Sep 17 00:00:00 2001 From: Markus Amalthea Magnuson Date: Thu, 5 Mar 2026 18:15:50 +0100 Subject: [PATCH] Add support for Django 6.0 built-in CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Django 6.0 ships with native Content Security Policy support via `ContentSecurityPolicyMiddleware`. When `django-csp` is not installed, `djangosaml2` now detects the native CSP middleware and uses `csp_override` to merge `form-action: https:` into the existing `SECURE_CSP` settings. Detection order: `django-csp` → Django native CSP → warning + no-op. --- .github/workflows/python-package.yml | 9 +++- djangosaml2/utils.py | 46 ++++++++++++----- setup.py | 1 + tests/testprofiles/tests.py | 77 +++++++++++++++++++++++++++- tox.ini | 2 + 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 11f11123..10dc907c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] - django-version: ["4.2", "5.0", "5.1", "5.2"] + django-version: ["4.2", "5.0", "5.1", "5.2", "6.0"] include: - python-version: "3.9" django-version: "4.2" @@ -33,6 +33,13 @@ jobs: # Django 4.2 is incompatible with Python 3.13+ - django-version: 4.2 python-version: 3.13 + # Django 6.0 requires Python 3.12+ + - django-version: 6.0 + python-version: 3.9 + - django-version: 6.0 + python-version: 3.10 + - django-version: 6.0 + python-version: 3.11 steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} diff --git a/djangosaml2/utils.py b/djangosaml2/utils.py index 10d022dd..591a4d30 100644 --- a/djangosaml2/utils.py +++ b/djangosaml2/utils.py @@ -216,8 +216,20 @@ def empty_view_decorator(view): csp_handler_string = get_custom_setting("SAML_CSP_HANDLER", None) if csp_handler_string is None: - # No CSP handler configured, attempt to use django-csp - return _django_csp_update_decorator() or empty_view_decorator + # No CSP handler configured, attempt django-csp first, then Django native CSP + handler = _django_csp_update_decorator() or _django_native_csp_decorator() + if handler: + return handler + logger.warning( + "No CSP integration found, not updating Content-Security-Policy. Please " + "make sure CSP is configured. This can be done by your reverse proxy, " + "Django's built-in CSP middleware (6.0+), django-csp, or a custom CSP " + "handler via SAML_CSP_HANDLER. See " + "https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy" + " for more information. " + "This warning can be disabled by setting `SAML_CSP_HANDLER=''` in your settings." + ) + return empty_view_decorator if csp_handler_string.strip() != "": # Non empty string is configured, attempt to import it @@ -236,22 +248,32 @@ def wrapper(*args, **kwargs): return empty_view_decorator +def _django_native_csp_decorator(): + """Returns a view CSP decorator if Django's built-in CSP (6.0+) is configured, otherwise None.""" + try: + from django.views.decorators.csp import csp_override + except ImportError: + return None + + middleware = getattr(settings, "MIDDLEWARE", []) + if "django.middleware.csp.ContentSecurityPolicyMiddleware" not in middleware: + return None + + csp_config = dict(getattr(settings, "SECURE_CSP", None) or {}) + form_action = list(csp_config.get("form-action", [])) + if "https:" not in form_action: + form_action.append("https:") + csp_config["form-action"] = form_action + + return csp_override(csp_config) + + def _django_csp_update_decorator(): """Returns a view CSP decorator if django-csp is available, otherwise None.""" try: from csp.decorators import csp_update import csp except ModuleNotFoundError: - # If csp is not installed, do not update fields as Content-Security-Policy - # is not used - logger.warning( - "django-csp could not be found, not updating Content-Security-Policy. Please " - "make sure CSP is configured. This can be done by your reverse proxy, " - "django-csp or a custom CSP handler via SAML_CSP_HANDLER. See " - "https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy" - " for more information. " - "This warning can be disabled by setting `SAML_CSP_HANDLER=''` in your settings." - ) return else: # autosubmit of forms uses nonce per default diff --git a/setup.py b/setup.py index f02a5c84..753dbdd8 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ def read(*rnames): "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", + "Framework :: Django :: 6.0", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", diff --git a/tests/testprofiles/tests.py b/tests/testprofiles/tests.py index 1904ba3b..ba032953 100644 --- a/tests/testprofiles/tests.py +++ b/tests/testprofiles/tests.py @@ -569,9 +569,13 @@ def test_get_csp_handler_none(self): with override_settings(SAML_CSP_HANDLER=None): csp_handler = get_csp_handler() self.assertIn( - csp_handler.__module__, ["csp.decorators", "djangosaml2.utils"] + csp_handler.__module__, + ["csp.decorators", "django.views.decorators.csp", "djangosaml2.utils"], + ) + self.assertIn( + csp_handler.__name__, + ["decorator", "_wrapped_view", "empty_view_decorator"], ) - self.assertIn(csp_handler.__name__, ["decorator", "empty_view_decorator"]) def test_get_csp_handler_empty(self): get_csp_handler.cache_clear() @@ -594,3 +598,72 @@ def test_get_csp_handler_specified_missing(self): with override_settings(SAML_CSP_HANDLER="does.not.exist"): with self.assertRaises(ImportError): get_csp_handler() + + def test_get_csp_handler_django_native_csp(self): + """Test that Django's built-in CSP (6.0+) is detected when configured.""" + try: + from django.views.decorators.csp import csp_override # noqa: F401 + except ImportError: + self.skipTest("Django native CSP not available (requires Django 6.0+)") + + get_csp_handler.cache_clear() + middleware = list(settings.MIDDLEWARE) + [ + "django.middleware.csp.ContentSecurityPolicyMiddleware", + ] + with override_settings( + SAML_CSP_HANDLER=None, + MIDDLEWARE=middleware, + SECURE_CSP={"default-src": ["'self'"]}, + ): + csp_handler = get_csp_handler() + self.assertEqual(csp_handler.__module__, "django.views.decorators.csp") + + def test_get_csp_handler_django_native_csp_merges_form_action(self): + """Test that form-action https: is merged into existing SECURE_CSP.""" + try: + from django.views.decorators.csp import csp_override # noqa: F401 + except ImportError: + self.skipTest("Django native CSP not available (requires Django 6.0+)") + + get_csp_handler.cache_clear() + middleware = list(settings.MIDDLEWARE) + [ + "django.middleware.csp.ContentSecurityPolicyMiddleware", + ] + with override_settings( + SAML_CSP_HANDLER=None, + MIDDLEWARE=middleware, + SECURE_CSP={"default-src": ["'self'"], "form-action": ["'self'"]}, + ): + csp_handler = get_csp_handler() + # Apply the decorator to a dummy view and call it + from django.http import HttpRequest, HttpResponse + + @csp_handler + def dummy_view(request): + return HttpResponse("test") + + request = HttpRequest() + request.method = "GET" + response = dummy_view(request) + csp_header = response.headers.get("Content-Security-Policy", "") + self.assertIn("form-action", csp_header) + self.assertIn("https:", csp_header) + self.assertIn("'self'", csp_header) + + def test_get_csp_handler_django_native_csp_no_middleware(self): + """Test that Django native CSP is skipped when middleware is not configured.""" + try: + from django.views.decorators.csp import csp_override # noqa: F401 + except ImportError: + self.skipTest("Django native CSP not available (requires Django 6.0+)") + + get_csp_handler.cache_clear() + # Default test MIDDLEWARE doesn't include CSP middleware + with override_settings(SAML_CSP_HANDLER=None): + csp_handler = get_csp_handler() + # Without django-csp and without native CSP middleware, falls back to empty + try: + import csp # noqa: F401 + # django-csp is installed, it will be used instead + except ImportError: + self.assertEqual(csp_handler.__name__, "empty_view_decorator") diff --git a/tox.ini b/tox.ini index 10c26331..609d7396 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = py{3.9,3.10,3.11,3.12,3.13}-django{4.2,5.0,5.1,5.2} + py{3.12,3.13}-django6.0 [testenv] commands = @@ -12,6 +13,7 @@ deps = django5.0: django~=5.0 django5.1: django~=5.1 django5.2: django~=5.2 + django6.0: django~=6.0 djangomaster: https://github.com/django/django/archive/master.tar.gz .