Skip to content

Commit 6dfbff3

Browse files
authored
Add CSP handler setting (#401)
* Make csp update function configurable * Correct typo in url * Split out django-csp handler logic * Use `lru_cache` instead of `cache` for python < 3.9 compat
1 parent 1be7946 commit 6dfbff3

File tree

6 files changed

+132
-21
lines changed

6 files changed

+132
-21
lines changed

djangosaml2/utils.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import re
1717
import urllib
1818
import zlib
19+
from functools import lru_cache, wraps
1920
from typing import Optional
2021

2122
from django.conf import settings
@@ -24,6 +25,7 @@
2425
from django.shortcuts import resolve_url
2526
from django.urls import NoReverseMatch
2627
from django.utils.http import url_has_allowed_host_and_scheme
28+
from django.utils.module_loading import import_string
2729

2830
from saml2.config import SPConfig
2931
from saml2.mdstore import MetaDataMDX
@@ -206,3 +208,55 @@ def add_idp_hinting(request, http_response) -> bool:
206208
f"Idp hinting: cannot detect request type [{http_response.status_code}]"
207209
)
208210
return False
211+
212+
213+
@lru_cache()
214+
def get_csp_handler():
215+
"""Returns a view decorator for CSP."""
216+
217+
def empty_view_decorator(view):
218+
return view
219+
220+
csp_handler_string = get_custom_setting("SAML_CSP_HANDLER", None)
221+
222+
if csp_handler_string is None:
223+
# No CSP handler configured, attempt to use django-csp
224+
return _django_csp_update_decorator() or empty_view_decorator
225+
226+
if csp_handler_string.strip() != "":
227+
# Non empty string is configured, attempt to import it
228+
csp_handler = import_string(csp_handler_string)
229+
230+
def custom_csp_updater(f):
231+
@wraps(f)
232+
def wrapper(*args, **kwargs):
233+
return csp_handler(f(*args, **kwargs))
234+
235+
return wrapper
236+
237+
return custom_csp_updater
238+
239+
# Fall back to empty decorator when csp_handler_string is empty
240+
return empty_view_decorator
241+
242+
243+
def _django_csp_update_decorator():
244+
"""Returns a view CSP decorator if django-csp is available, otherwise None."""
245+
try:
246+
from csp.decorators import csp_update
247+
except ModuleNotFoundError:
248+
# If csp is not installed, do not update fields as Content-Security-Policy
249+
# is not used
250+
logger.warning(
251+
"django-csp could not be found, not updating Content-Security-Policy. Please "
252+
"make sure CSP is configured. This can be done by your reverse proxy, "
253+
"django-csp or a custom CSP handler via SAML_CSP_HANDLER. See "
254+
"https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy"
255+
" for more information. "
256+
"This warning can be disabled by setting `SAML_CSP_HANDLER=''` in your settings."
257+
)
258+
return
259+
else:
260+
# script-src 'unsafe-inline' to autosubmit forms,
261+
# form-action https: to send data to IdPs
262+
return csp_update(SCRIPT_SRC=["'unsafe-inline'"], FORM_ACTION=["https:"])

djangosaml2/views.py

Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import base64
1717
import logging
18+
from functools import wraps
1819
from typing import Optional
1920
from urllib.parse import quote
2021

@@ -69,6 +70,7 @@
6970
from .utils import (
7071
add_idp_hinting,
7172
available_idps,
73+
get_csp_handler,
7274
get_custom_setting,
7375
get_fallback_login_redirect_url,
7476
get_idp_sso_supported_bindings,
@@ -78,25 +80,15 @@
7880

7981
logger = logging.getLogger("djangosaml2")
8082

81-
# Update Content-Security-Policy headers for POST-Bindings
82-
try:
83-
from csp.decorators import csp_update
84-
except ModuleNotFoundError:
85-
# If csp is not installed, do not update fields as Content-Security-Policy
86-
# is not used
87-
def saml2_csp_update(view):
88-
return view
89-
90-
logger.warning("django-csp could not be found, not updating Content-Security-Policy. Please "
91-
"make sure CSP is configured at least by httpd or setup django-csp. See "
92-
"https://djangosaml2.readthedocs.io/contents/security.html#content-security-policy"
93-
" for more information")
94-
else:
95-
# script-src 'unsafe-inline' to autosubmit forms,
96-
# form-action https: to send data to IdPs
97-
saml2_csp_update = csp_update(
98-
SCRIPT_SRC=["'unsafe-inline'"], FORM_ACTION=["https:"]
99-
)
83+
84+
def saml2_csp_update(view):
85+
csp_handler = get_csp_handler()
86+
87+
@wraps(view)
88+
def wrapper(*args, **kwargs):
89+
return csp_handler(view)(*args, **kwargs)
90+
91+
return wrapper
10092

10193

10294
def _set_subject_id(session, subject_id):

docs/source/contents/security.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,8 @@ and [configuration](https://django-csp.readthedocs.io/en/latest/configuration.ht
3333
guides: djangosaml2 will automatically blend in and update the headers for
3434
POST-bindings, so you must not include exceptions for djangosaml2 in your
3535
global configuration.
36+
37+
You can specify a custom CSP handler via the `SAML_CSP_HANDLER` setting and the
38+
warning can be disabled by setting `SAML_CSP_HANDLER=''`. See the
39+
[djangosaml2](https://djangosaml2.readthedocs.io/) documentation for more
40+
information.

docs/source/contents/setup.rst

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ example: 'home' could be '/home' or 'home/'.
151151
If this is unfeasible, this strict validation can be turned off by setting
152152
``SAML_STRICT_URL_VALIDATION`` to ``False`` in settings.py.
153153

154-
During validation, `Django named URL patterns<https://docs.djangoproject.com/en/dev/topics/http/urls/#naming-url-patterns>`_
154+
During validation, `Django named URL patterns <https://docs.djangoproject.com/en/dev/topics/http/urls/#naming-url-patterns>`_
155155
will also be resolved. Turning off strict validation will prevent this from happening.
156156

157157
Preferred sso binding
@@ -288,6 +288,28 @@ djangosaml2 provides a hook 'is_authorized' for the SP to store assertion IDs an
288288
cache_storage.set(assertion_id, 'True', ex=time_delta)
289289
return True
290290

291+
CSP Configuration
292+
=================
293+
By default djangosaml2 will use `django-csp <https://django-csp.readthedocs.io>`_
294+
to configure CSP if available otherwise a warning will be logged.
295+
296+
The warning can be disabled by setting::
297+
298+
SAML_CSP_HANDLER = ''
299+
300+
A custom handler can similary be specified::
301+
302+
# Django settings
303+
SAML_CSP_HANDLER = 'myapp.utils.csp_handler'
304+
305+
# myapp/utils.py
306+
def csp_handler(response):
307+
response.headers['Content-Security-Policy'] = ...
308+
return response
309+
310+
A value of `None` is the default and will use `django-csp <https://django-csp.readthedocs.io>`_ if available.
311+
312+
291313
Users, attributes and account linking
292314
-------------------------------------
293315

tests/testprofiles/tests.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616

1717
from django.conf import settings
1818
from django.core.exceptions import ImproperlyConfigured
19-
from django.test import TestCase, override_settings
19+
from django.test import Client, TestCase, override_settings
20+
from django.urls import reverse
2021

2122
from django.contrib.auth import get_user_model
2223
from django.contrib.auth.models import User as DjangoUserModel
2324

2425
from djangosaml2.backends import Saml2Backend, get_saml_user_model, set_attribute
26+
from djangosaml2.utils import get_csp_handler
2527
from testprofiles.models import TestUser
2628

2729

@@ -559,3 +561,36 @@ def test_user_cleaned_main_attribute(self):
559561

560562
self.user.refresh_from_db()
561563
self.assertEqual(user.username, "john")
564+
565+
566+
class CSPHandlerTests(TestCase):
567+
def test_get_csp_handler_none(self):
568+
get_csp_handler.cache_clear()
569+
with override_settings(SAML_CSP_HANDLER=None):
570+
csp_handler = get_csp_handler()
571+
self.assertIn(
572+
csp_handler.__module__, ["csp.decorators", "djangosaml2.utils"]
573+
)
574+
self.assertIn(csp_handler.__name__, ["decorator", "empty_view_decorator"])
575+
576+
def test_get_csp_handler_empty(self):
577+
get_csp_handler.cache_clear()
578+
with override_settings(SAML_CSP_HANDLER=""):
579+
csp_handler = get_csp_handler()
580+
self.assertEqual(csp_handler.__name__, "empty_view_decorator")
581+
582+
def test_get_csp_handler_specified(self):
583+
get_csp_handler.cache_clear()
584+
with override_settings(SAML_CSP_HANDLER="testprofiles.utils.csp_handler"):
585+
client = Client()
586+
response = client.get(reverse("saml2_login"))
587+
self.assertIn("Content-Security-Policy", response.headers)
588+
self.assertEqual(
589+
response.headers["Content-Security-Policy"], "testing CSP value"
590+
)
591+
592+
def test_get_csp_handler_specified_missing(self):
593+
get_csp_handler.cache_clear()
594+
with override_settings(SAML_CSP_HANDLER="does.not.exist"):
595+
with self.assertRaises(ImportError):
596+
get_csp_handler()

tests/testprofiles/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
def csp_handler(response):
2+
response.headers["Content-Security-Policy"] = "testing CSP value"
3+
return response

0 commit comments

Comments
 (0)