From f81312e9ae953d3a8abd506d5979753bd6382b62 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 15 Nov 2024 13:38:28 -0800 Subject: [PATCH] Add django_csp. Enforce CSPs, make additional hosts configurable. --- kolibri/deployment/default/settings/base.py | 26 ++++++++++++++ kolibri/deployment/default/settings/dev.py | 5 +++ .../context_translation/kolibri_plugin.py | 2 +- .../context_translation/option_defaults.py | 1 + kolibri/utils/options.py | 36 +++++++++++++++++++ requirements/base.txt | 1 + 6 files changed, 70 insertions(+), 1 deletion(-) diff --git a/kolibri/deployment/default/settings/base.py b/kolibri/deployment/default/settings/base.py index 0194242a6cd..0498f9dcb01 100644 --- a/kolibri/deployment/default/settings/base.py +++ b/kolibri/deployment/default/settings/base.py @@ -99,6 +99,7 @@ "kolibri.core.auth.middleware.KolibriSessionMiddleware", "kolibri.core.device.middleware.KolibriLocaleMiddleware", "django.middleware.common.CommonMiddleware", + "csp.middleware.CSPMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "kolibri.core.auth.middleware.CustomAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", @@ -441,3 +442,28 @@ # whether Kolibri is running within tests TESTING = False + + +# Content Security Policy header settings +# https://django-csp.readthedocs.io/en/latest/configuration.html +CSP_DEFAULT_SRC = ("'self'", "data:", "blob:") + tuple( + conf.OPTIONS["Deployment"]["CSP_HOST_SOURCES"] +) + +# Allow inline styles, as we rely on them heavily in our templates +# and the Aphrodite CSS in JS library generates inline styles +CSP_STYLE_SRC = CSP_DEFAULT_SRC + ("'unsafe-inline'",) + +# Explicitly allow iframe embedding from the our zipcontent origin +# This is necessary for the zipcontent app to work +if conf.OPTIONS["Deployment"]["ZIP_CONTENT_ORIGIN"]: + # An explicit origin has been specified, just allow that as the iframe source. + frame_src = (conf.OPTIONS["Deployment"]["ZIP_CONTENT_ORIGIN"],) +else: + # Otherwise, we allow any http origin to be the iframe source. + # Because we 'self:' is not a valid CSP source value. + frame_src = ("http:", "https:") + +# Always allow 'self' and 'data' sources to allow for the kind of +# iframe manipulation needed for epub.js. +CSP_FRAME_SRC = CSP_DEFAULT_SRC + frame_src diff --git a/kolibri/deployment/default/settings/dev.py b/kolibri/deployment/default/settings/dev.py index 07d38b3455e..f04a4405bfd 100644 --- a/kolibri/deployment/default/settings/dev.py +++ b/kolibri/deployment/default/settings/dev.py @@ -40,3 +40,8 @@ } SWAGGER_SETTINGS = {"DEFAULT_INFO": "kolibri.deployment.default.dev_urls.api_info"} + +# Ensure that the CSP is set up to allow webpack-dev-server to be accessed during development +# At the moment, this assumes the port will not change from 3000. +CSP_DEFAULT_SRC += ("localhost:3000", "ws:") # noqa F405 +CSP_STYLE_SRC += ("localhost:3000",) # noqa F405 diff --git a/kolibri/plugins/context_translation/kolibri_plugin.py b/kolibri/plugins/context_translation/kolibri_plugin.py index 1ed829d9235..e2390d1f523 100644 --- a/kolibri/plugins/context_translation/kolibri_plugin.py +++ b/kolibri/plugins/context_translation/kolibri_plugin.py @@ -25,7 +25,7 @@ def head_html(self): "\n".join( [ f"""""", - """""", + """""", ] ) ) diff --git a/kolibri/plugins/context_translation/option_defaults.py b/kolibri/plugins/context_translation/option_defaults.py index 4d0a3eeb6be..a5fe0da0e50 100644 --- a/kolibri/plugins/context_translation/option_defaults.py +++ b/kolibri/plugins/context_translation/option_defaults.py @@ -1,5 +1,6 @@ option_defaults = { "Deployment": { "LANGUAGES": "ach-ug", + "CSP_HOST_SOURCES": "https://cdn.crowdin.com,https://fonts.googleapis.com,https://fonts.gstatic.com,https://crowdin-static.downloads.crowdin.com", } } diff --git a/kolibri/utils/options.py b/kolibri/utils/options.py index 542e312e1b1..7f2ea3327c3 100644 --- a/kolibri/utils/options.py +++ b/kolibri/utils/options.py @@ -358,6 +358,32 @@ def lazy_import_callback_list(value): return out +def _process_csp_source(value): + if not isinstance(value, str): + raise VdtValueError(value) + value = value.strip() + url = urlparse(value) + if not url.scheme or not url.netloc: + raise VdtValueError(value) + return value + + +def csp_source_list(value): + value = _process_list(value) + out = [] + errors = [] + for entry in value: + try: + entry_list = _process_csp_source(entry) + out.append(entry_list) + except ValueError: + errors.append(entry) + if errors: + raise VdtValueError(errors) + + return out + + base_option_spec = { "Cache": { "CACHE_BACKEND": { @@ -688,6 +714,15 @@ def lazy_import_callback_list(value): Boolean Flag to check Whether to enable Zeroconf discovery. """, }, + "CSP_HOST_SOURCES": { + "type": "csp_source_list", + "description": """ + List of host sources to use in the Content Security Policy header. This should be a list of + host sources as described by in: + https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#host-source + Allowing deployed Kolibri servers to specify additional hosts from which content can be loaded. + """, + }, }, "Python": { "PICKLE_PROTOCOL": { @@ -746,6 +781,7 @@ def _get_validator(): "multiprocess_bool": multiprocess_bool, "cache_option": cache_option, "lazy_import_callback_list": lazy_import_callback_list, + "csp_source_list": csp_source_list, } ) diff --git a/requirements/base.txt b/requirements/base.txt index 0c9cdf1e3be..0f4c2e21395 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,5 @@ diskcache==5.6.3 +django_csp==3.8 django-filter==21.1 django-js-reverse==0.10.2 djangorestframework==3.14.0