Skip to content

Commit

Permalink
Add CSP handler setting (#401)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
GertBurger authored Apr 30, 2024
1 parent 1be7946 commit 6dfbff3
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 21 deletions.
54 changes: 54 additions & 0 deletions djangosaml2/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:"])
30 changes: 11 additions & 19 deletions djangosaml2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import base64
import logging
from functools import wraps
from typing import Optional
from urllib.parse import quote

Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
5 changes: 5 additions & 0 deletions docs/source/contents/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
24 changes: 23 additions & 1 deletion docs/source/contents/setup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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<https://docs.djangoproject.com/en/dev/topics/http/urls/#naming-url-patterns>`_
During validation, `Django named URL patterns <https://docs.djangoproject.com/en/dev/topics/http/urls/#naming-url-patterns>`_
will also be resolved. Turning off strict validation will prevent this from happening.

Preferred sso binding
Expand Down Expand Up @@ -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 <https://django-csp.readthedocs.io>`_
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 <https://django-csp.readthedocs.io>`_ if available.


Users, attributes and account linking
-------------------------------------

Expand Down
37 changes: 36 additions & 1 deletion tests/testprofiles/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
3 changes: 3 additions & 0 deletions tests/testprofiles/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def csp_handler(response):
response.headers["Content-Security-Policy"] = "testing CSP value"
return response

0 comments on commit 6dfbff3

Please sign in to comment.