diff --git a/djangosaml2/utils.py b/djangosaml2/utils.py index 206e31a6..3299da86 100644 --- a/djangosaml2/utils.py +++ b/djangosaml2/utils.py @@ -16,6 +16,7 @@ import re import urllib import zlib +from functools import lru_cache, wraps from typing import Optional from django.conf import settings @@ -24,6 +25,7 @@ from django.shortcuts import resolve_url from django.urls import NoReverseMatch from django.utils.http import url_has_allowed_host_and_scheme +from django.utils.module_loading import import_string from saml2.config import SPConfig from saml2.mdstore import MetaDataMDX @@ -206,3 +208,55 @@ def add_idp_hinting(request, http_response) -> bool: f"Idp hinting: cannot detect request type [{http_response.status_code}]" ) return False + + +@lru_cache() +def get_csp_handler(): + """Returns a view decorator for CSP.""" + + def empty_view_decorator(view): + return 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 + + if csp_handler_string.strip() != "": + # Non empty string is configured, attempt to import it + csp_handler = import_string(csp_handler_string) + + def custom_csp_updater(f): + @wraps(f) + def wrapper(*args, **kwargs): + return csp_handler(f(*args, **kwargs)) + + return wrapper + + return custom_csp_updater + + # Fall back to empty decorator when csp_handler_string is empty + return empty_view_decorator + + +def _django_csp_update_decorator(): + """Returns a view CSP decorator if django-csp is available, otherwise None.""" + try: + from csp.decorators import csp_update + 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: + # script-src 'unsafe-inline' to autosubmit forms, + # form-action https: to send data to IdPs + return csp_update(SCRIPT_SRC=["'unsafe-inline'"], FORM_ACTION=["https:"]) diff --git a/djangosaml2/views.py b/djangosaml2/views.py index 0c88b8a3..48d68a9c 100644 --- a/djangosaml2/views.py +++ b/djangosaml2/views.py @@ -15,6 +15,7 @@ import base64 import logging +from functools import wraps from typing import Optional from urllib.parse import quote @@ -69,6 +70,7 @@ from .utils import ( add_idp_hinting, available_idps, + get_csp_handler, get_custom_setting, get_fallback_login_redirect_url, get_idp_sso_supported_bindings, @@ -78,25 +80,15 @@ logger = logging.getLogger("djangosaml2") -# Update Content-Security-Policy headers for POST-Bindings -try: - from csp.decorators import csp_update -except ModuleNotFoundError: - # If csp is not installed, do not update fields as Content-Security-Policy - # is not used - def saml2_csp_update(view): - return view - - logger.warning("django-csp could not be found, not updating Content-Security-Policy. Please " - "make sure CSP is configured at least by httpd or setup django-csp. See " - "https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy" - " for more information") -else: - # script-src 'unsafe-inline' to autosubmit forms, - # form-action https: to send data to IdPs - saml2_csp_update = csp_update( - SCRIPT_SRC=["'unsafe-inline'"], FORM_ACTION=["https:"] - ) + +def saml2_csp_update(view): + csp_handler = get_csp_handler() + + @wraps(view) + def wrapper(*args, **kwargs): + return csp_handler(view)(*args, **kwargs) + + return wrapper def _set_subject_id(session, subject_id): diff --git a/docs/source/contents/security.md b/docs/source/contents/security.md index 4ccd6cab..46f93fea 100644 --- a/docs/source/contents/security.md +++ b/docs/source/contents/security.md @@ -33,3 +33,8 @@ and [configuration](https://django-csp.readthedocs.io/en/latest/configuration.ht guides: djangosaml2 will automatically blend in and update the headers for POST-bindings, so you must not include exceptions for djangosaml2 in your global configuration. + +You can specify a custom CSP handler via the `SAML_CSP_HANDLER` setting and the +warning can be disabled by setting `SAML_CSP_HANDLER=''`. See the +[djangosaml2](https://djangosaml2.readthedocs.io/) documentation for more +information. diff --git a/docs/source/contents/setup.rst b/docs/source/contents/setup.rst index b2588abb..384979de 100644 --- a/docs/source/contents/setup.rst +++ b/docs/source/contents/setup.rst @@ -151,7 +151,7 @@ example: 'home' could be '/home' or 'home/'. If this is unfeasible, this strict validation can be turned off by setting ``SAML_STRICT_URL_VALIDATION`` to ``False`` in settings.py. -During validation, `Django named URL patterns`_ +During validation, `Django named URL patterns `_ will also be resolved. Turning off strict validation will prevent this from happening. Preferred sso binding @@ -288,6 +288,28 @@ djangosaml2 provides a hook 'is_authorized' for the SP to store assertion IDs an cache_storage.set(assertion_id, 'True', ex=time_delta) return True +CSP Configuration +================= +By default djangosaml2 will use `django-csp `_ +to configure CSP if available otherwise a warning will be logged. + +The warning can be disabled by setting:: + + SAML_CSP_HANDLER = '' + +A custom handler can similary be specified:: + + # Django settings + SAML_CSP_HANDLER = 'myapp.utils.csp_handler' + + # myapp/utils.py + def csp_handler(response): + response.headers['Content-Security-Policy'] = ... + return response + +A value of `None` is the default and will use `django-csp `_ if available. + + Users, attributes and account linking ------------------------------------- diff --git a/tests/testprofiles/tests.py b/tests/testprofiles/tests.py index a84494a2..1904ba3b 100644 --- a/tests/testprofiles/tests.py +++ b/tests/testprofiles/tests.py @@ -16,12 +16,14 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.test import TestCase, override_settings +from django.test import Client, TestCase, override_settings +from django.urls import reverse from django.contrib.auth import get_user_model from django.contrib.auth.models import User as DjangoUserModel from djangosaml2.backends import Saml2Backend, get_saml_user_model, set_attribute +from djangosaml2.utils import get_csp_handler from testprofiles.models import TestUser @@ -559,3 +561,36 @@ def test_user_cleaned_main_attribute(self): self.user.refresh_from_db() self.assertEqual(user.username, "john") + + +class CSPHandlerTests(TestCase): + def test_get_csp_handler_none(self): + get_csp_handler.cache_clear() + with override_settings(SAML_CSP_HANDLER=None): + csp_handler = get_csp_handler() + self.assertIn( + csp_handler.__module__, ["csp.decorators", "djangosaml2.utils"] + ) + self.assertIn(csp_handler.__name__, ["decorator", "empty_view_decorator"]) + + def test_get_csp_handler_empty(self): + get_csp_handler.cache_clear() + with override_settings(SAML_CSP_HANDLER=""): + csp_handler = get_csp_handler() + self.assertEqual(csp_handler.__name__, "empty_view_decorator") + + def test_get_csp_handler_specified(self): + get_csp_handler.cache_clear() + with override_settings(SAML_CSP_HANDLER="testprofiles.utils.csp_handler"): + client = Client() + response = client.get(reverse("saml2_login")) + self.assertIn("Content-Security-Policy", response.headers) + self.assertEqual( + response.headers["Content-Security-Policy"], "testing CSP value" + ) + + def test_get_csp_handler_specified_missing(self): + get_csp_handler.cache_clear() + with override_settings(SAML_CSP_HANDLER="does.not.exist"): + with self.assertRaises(ImportError): + get_csp_handler() diff --git a/tests/testprofiles/utils.py b/tests/testprofiles/utils.py new file mode 100644 index 00000000..34421df5 --- /dev/null +++ b/tests/testprofiles/utils.py @@ -0,0 +1,3 @@ +def csp_handler(response): + response.headers["Content-Security-Policy"] = "testing CSP value" + return response