diff --git a/docs/env.md b/docs/env.md index f9cd154c46..d5d83c3301 100644 --- a/docs/env.md +++ b/docs/env.md @@ -102,7 +102,8 @@ These are the environment variables you can set for the `impress-backend` contai | MALWARE_DETECTION_PARAMETERS | A dict containing all the parameters to initiate the malware detection backend | {"callback_path": "core.malware_detection.malware_detection_callback",} | | THEME_CUSTOMIZATION_FILE_PATH | full path to the file customizing the theme. An example is provided in src/backend/impress/configuration/theme/default.json | BASE_DIR/impress/configuration/theme/default.json | | THEME_CUSTOMIZATION_CACHE_TIMEOUT | Cache duration for the customization settings | 86400 | - +| CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES | Url with this prefix will not have the header Content-Security-Policy included | | +| CONTENT_SECURITY_POLICY_DIRECTIVES | A dict of directives set in the Content-Security-Policy header | All directives are set to 'none' | ## impress-frontend image diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 2250c91aa4..8390438709 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -25,6 +25,8 @@ import requests import rest_framework as drf from botocore.exceptions import ClientError +from csp.constants import NONE +from csp.decorators import csp_update from lasuite.malware_detection import malware_detection from rest_framework import filters, status, viewsets from rest_framework import response as drf_response @@ -1412,6 +1414,7 @@ def ai_translate(self, request, *args, **kwargs): name="", url_path="cors-proxy", ) + @csp_update({"img-src": [NONE, "data:"]}) def cors_proxy(self, request, *args, **kwargs): """ GET /api/v1.0/documents//cors-proxy @@ -1452,7 +1455,6 @@ def cors_proxy(self, request, *args, **kwargs): content_type=content_type, headers={ "Content-Disposition": "attachment;", - "Content-Security-Policy": "default-src 'none'; img-src 'none' data:;", }, status=response.status_code, ) diff --git a/src/backend/core/tests/documents/test_api_documents_cors_proxy.py b/src/backend/core/tests/documents/test_api_documents_cors_proxy.py index 8f5d4219f9..5d8b02c26a 100644 --- a/src/backend/core/tests/documents/test_api_documents_cors_proxy.py +++ b/src/backend/core/tests/documents/test_api_documents_cors_proxy.py @@ -23,10 +23,25 @@ def test_api_docs_cors_proxy_valid_url(): assert response.status_code == 200 assert response.headers["Content-Type"] == "image/png" assert response.headers["Content-Disposition"] == "attachment;" - assert ( - response.headers["Content-Security-Policy"] - == "default-src 'none'; img-src 'none' data:;" - ) + policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) + assert policy_list == [ + "base-uri 'none'", + "child-src 'none'", + "connect-src 'none'", + "default-src 'none'", + "font-src 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + "frame-src 'none'", + "img-src 'none' data:", + "manifest-src 'none'", + "media-src 'none'", + "object-src 'none'", + "prefetch-src 'none'", + "script-src 'none'", + "style-src 'none'", + "worker-src 'none'", + ] assert response.streaming_content @@ -77,10 +92,25 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc(): assert response.status_code == 200 assert response.headers["Content-Type"] == "image/png" assert response.headers["Content-Disposition"] == "attachment;" - assert ( - response.headers["Content-Security-Policy"] - == "default-src 'none'; img-src 'none' data:;" - ) + policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) + assert policy_list == [ + "base-uri 'none'", + "child-src 'none'", + "connect-src 'none'", + "default-src 'none'", + "font-src 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + "frame-src 'none'", + "img-src 'none' data:", + "manifest-src 'none'", + "media-src 'none'", + "object-src 'none'", + "prefetch-src 'none'", + "script-src 'none'", + "style-src 'none'", + "worker-src 'none'", + ] assert response.streaming_content diff --git a/src/backend/core/tests/test_api_config.py b/src/backend/core/tests/test_api_config.py index 2d74594c32..cdc4a9cb27 100644 --- a/src/backend/core/tests/test_api_config.py +++ b/src/backend/core/tests/test_api_config.py @@ -62,6 +62,25 @@ def test_api_config(is_authenticated): "AI_FEATURE_ENABLED": False, "theme_customization": {}, } + policy_list = sorted(response.headers["Content-Security-Policy"].split("; ")) + assert policy_list == [ + "base-uri 'none'", + "child-src 'none'", + "connect-src 'none'", + "default-src 'none'", + "font-src 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + "frame-src 'none'", + "img-src 'none'", + "manifest-src 'none'", + "media-src 'none'", + "object-src 'none'", + "prefetch-src 'none'", + "script-src 'none'", + "style-src 'none'", + "worker-src 'none'", + ] @override_settings( diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 5d16e165ba..0c36a39034 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -18,9 +18,12 @@ import sentry_sdk from configurations import Configuration, values +from csp.constants import NONE from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger +# pylint: disable=too-many-lines + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DATA_DIR = os.getenv("DATA_DIR", os.path.join("/", "data")) @@ -285,6 +288,7 @@ class Base(Configuration): "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "dockerflow.django.middleware.DockerflowMiddleware", + "csp.middleware.CSPMiddleware", ] AUTHENTICATION_BACKENDS = [ @@ -318,6 +322,7 @@ class Base(Configuration): # OIDC third party "mozilla_django_oidc", "lasuite.malware_detection", + "csp", ] # Cache @@ -717,6 +722,38 @@ class Base(Configuration): environ_prefix=None, ) + # Content Security Policy + # See https://content-security-policy.com/ for more information. + CONTENT_SECURITY_POLICY = { + "EXCLUDE_URL_PREFIXES": values.ListValue( + [], + environ_name="CONTENT_SECURITY_POLICY_EXCLUDE_URL_PREFIXES", + environ_prefix=None, + ), + "DIRECTIVES": values.DictValue( + default={ + "default-src": [NONE], + "script-src": [NONE], + "style-src": [NONE], + "img-src": [NONE], + "connect-src": [NONE], + "font-src": [NONE], + "object-src": [NONE], + "media-src": [NONE], + "frame-src": [NONE], + "child-src": [NONE], + "form-action": [NONE], + "frame-ancestors": [NONE], + "base-uri": [NONE], + "worker-src": [NONE], + "manifest-src": [NONE], + "prefetch-src": [NONE], + }, + environ_name="CONTENT_SECURITY_POLICY_DIRECTIVES", + environ_prefix=None, + ), + } + # pylint: disable=invalid-name @property def ENVIRONMENT(self): diff --git a/src/backend/pyproject.toml b/src/backend/pyproject.toml index a5fd08c877..1aeb2e28a1 100644 --- a/src/backend/pyproject.toml +++ b/src/backend/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "django-configurations==2.5.1", "django-cors-headers==4.7.0", "django-countries==7.6.1", + "django-csp==4.0", "django-filter==25.1", "django-lasuite[all]==0.0.9", "django-parler==2.3",