Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CSP handler setting #401

Merged
Merged
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
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
Loading