Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 }}
Expand Down
46 changes: 34 additions & 12 deletions djangosaml2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 75 additions & 2 deletions tests/testprofiles/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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")
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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
.

Expand Down