diff --git a/privaterelay/settings.py b/privaterelay/settings.py index 82c95df419..d46ad9bd93 100644 --- a/privaterelay/settings.py +++ b/privaterelay/settings.py @@ -26,11 +26,12 @@ import django_stubs_ext import markus import sentry_sdk +from csp.constants import NONE, SELF, UNSAFE_INLINE from decouple import Choices, Csv, config from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.logging import ignore_logger -from .types import RELAY_CHANNEL_NAME +from .types import CONTENT_SECURITY_POLICY_T, RELAY_CHANNEL_NAME if TYPE_CHECKING: import wsgiref.headers @@ -183,36 +184,42 @@ empty_hash = base64.b64encode(sha256().digest()).decode() _CSP_STYLE_HASHES.append(f"'sha256-{empty_hash}'") -CSP_DEFAULT_SRC = ["'self'"] -CSP_CONNECT_SRC = [ - "'self'", - "https://www.google-analytics.com/", - "https://www.googletagmanager.com/", - "https://location.services.mozilla.com", - "https://api.stripe.com", - BASKET_ORIGIN, -] + _ACCOUNT_CONNECT_SRC -CSP_FONT_SRC = ["'self'"] + _API_DOCS_CSP_FONT_SRC + ["https://relay.firefox.com/"] -CSP_IMG_SRC = ["'self'"] + _AVATAR_IMG_SRC + _API_DOCS_CSP_IMG_SRC -CSP_SCRIPT_SRC = ( - ["'self'"] - + (["'unsafe-inline'"] if _CSP_SCRIPT_INLINE else []) - + [ - "https://www.google-analytics.com/", - "https://www.googletagmanager.com/", - "https://js.stripe.com/", - ] -) -CSP_WORKER_SRC = _API_DOCS_CSP_WORKER_SRC or None -CSP_OBJECT_SRC = ["'none'"] -CSP_FRAME_SRC = ["https://js.stripe.com", "https://hooks.stripe.com"] -CSP_STYLE_SRC = ( - ["'self'"] - + (["'unsafe-inline'"] if _CSP_STYLE_INLINE else []) - + _API_DOCS_CSP_STYLE_SRC - + _CSP_STYLE_HASHES -) -CSP_REPORT_URI = config("CSP_REPORT_URI", "") +CONTENT_SECURITY_POLICY: CONTENT_SECURITY_POLICY_T = { + "DIRECTIVES": { + "default-src": [SELF], + "connect-src": [ + SELF, + "https://www.google-analytics.com/", + "https://www.googletagmanager.com/", + "https://location.services.mozilla.com", + "https://api.stripe.com", + BASKET_ORIGIN, + ], + "font-src": [SELF, "https://relay.firefox.com/"], + "frame-src": ["https://js.stripe.com", "https://hooks.stripe.com"], + "img-src": [SELF], + "object-src": [NONE], + "script-src": [ + SELF, + "https://www.google-analytics.com/", + "https://www.googletagmanager.com/", + "https://js.stripe.com/", + ], + "style-src": [SELF], + } +} +CONTENT_SECURITY_POLICY["DIRECTIVES"]["connect-src"].extend(_ACCOUNT_CONNECT_SRC) +CONTENT_SECURITY_POLICY["DIRECTIVES"]["font-src"].extend(_API_DOCS_CSP_FONT_SRC) +CONTENT_SECURITY_POLICY["DIRECTIVES"]["img-src"].extend(_AVATAR_IMG_SRC) +CONTENT_SECURITY_POLICY["DIRECTIVES"]["img-src"].extend(_API_DOCS_CSP_IMG_SRC) +CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(_API_DOCS_CSP_STYLE_SRC) +CONTENT_SECURITY_POLICY["DIRECTIVES"]["style-src"].extend(_CSP_STYLE_HASHES) +if _CSP_STYLE_INLINE: + CONTENT_SECURITY_POLICY["DIRECTIVES"]["script-src"].append(UNSAFE_INLINE) +if _API_DOCS_CSP_WORKER_SRC: + CONTENT_SECURITY_POLICY["DIRECTIVES"]["worker-src"] = _API_DOCS_CSP_WORKER_SRC +if _CSP_REPORT_URI := config("CSP_REPORT_URI", ""): + CONTENT_SECURITY_POLICY["DIRECTIVES"]["report-uri"] = _CSP_REPORT_URI REFERRER_POLICY = "strict-origin-when-cross-origin" @@ -317,6 +324,7 @@ "rest_framework", "rest_framework.authtoken", "corsheaders", + "csp", "waffle", "privaterelay.apps.PrivateRelayConfig", "api.apps.ApiConfig", diff --git a/privaterelay/types.py b/privaterelay/types.py index 9a1f5fb310..613be287db 100644 --- a/privaterelay/types.py +++ b/privaterelay/types.py @@ -1,5 +1,69 @@ """Types for the privaterelay app""" -from typing import Literal +from typing import Literal, TypedDict RELAY_CHANNEL_NAME = Literal["local", "dev", "stage", "prod"] + +# django-csp 4.0: types for CONTENT_SECURITY_POLICY in settings.py + +# Note: this will need adjustments to uplift to django-csp +# For example, the django-csp docs say 'sequence' rather than 'list', +# and appear more flexible about sending strings or lists. +_SERIALIZED_SOURCE_LIST = list[str] +CSP_DIRECTIVES_T = TypedDict( + "CSP_DIRECTIVES_T", + { + # CSP Level 3 Working Draft, Directives (section 6) + # https://www.w3.org/TR/CSP/#csp-directives + # 6.1 Fetch Directives + "child-src": _SERIALIZED_SOURCE_LIST, + "connect-src": _SERIALIZED_SOURCE_LIST, + "default-src": _SERIALIZED_SOURCE_LIST, + "font-src": _SERIALIZED_SOURCE_LIST, + "frame-src": _SERIALIZED_SOURCE_LIST, + "img-src": _SERIALIZED_SOURCE_LIST, + "manifest-src": _SERIALIZED_SOURCE_LIST, + "media-src": _SERIALIZED_SOURCE_LIST, + "object-src": _SERIALIZED_SOURCE_LIST, + "script-src": _SERIALIZED_SOURCE_LIST, + "script-src-elem": _SERIALIZED_SOURCE_LIST, + "script-src-attr": _SERIALIZED_SOURCE_LIST, + "style-src": _SERIALIZED_SOURCE_LIST, + "style-src-elem": _SERIALIZED_SOURCE_LIST, + "style-src-attr": _SERIALIZED_SOURCE_LIST, + # 6.2 Other Directives + "webrtc": Literal["'allow'", "'block'"], + "worker-src": _SERIALIZED_SOURCE_LIST, + # 6.3 Document Directives + "base-uri": _SERIALIZED_SOURCE_LIST, + "sandbox": str | list[str], # sequence of tokens in CSP 3 + # 6.4 Navigation Directives + "form-action": _SERIALIZED_SOURCE_LIST, + "frame-ancestors": _SERIALIZED_SOURCE_LIST, + "navigate-to": _SERIALIZED_SOURCE_LIST, + # 6.5 Reporting Directives + "report-uri": str | list[str], # sequence of uri-references in CSP 3 + "report-to": str, + # "require-sri-for": _SERIALIZED_SOURCE_LIST, + # 6.6 Directives Defined in Other Documents + "block-all-mixed-content": bool, # Deprecated. + "upgrade-insecure-requests": bool, + # CSP2 items removed in CSP3 + # https://www.w3.org/TR/CSP2/#directives + "plugin-types": _SERIALIZED_SOURCE_LIST, + # Deprecated, from MDN + "prefetch-src": _SERIALIZED_SOURCE_LIST, + "referrer": str, + # Experimental items, from MDN + "fenced-frame-src": _SERIALIZED_SOURCE_LIST, + "require-trusted-types-for": str, + "trusted-types": str, + }, + total=False, +) + + +class CONTENT_SECURITY_POLICY_T(TypedDict, total=False): + EXCLUDE_URL_PREFIXES: list[str] + DIRECTIVES: CSP_DIRECTIVES_T + REPORT_PERCENTAGE: int diff --git a/pyproject.toml b/pyproject.toml index 548a5546b5..7ce94a235c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ module = [ "botocore.config", "botocore.exceptions", "codetiming", + "csp.constants", "debug_toolbar", "dj_database_url", "django_filters.*", diff --git a/requirements.txt b/requirements.txt index 0861e08256..0eb9300d38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ Django==4.2.13 dj-database-url==2.2.0 django-allauth[socialaccount]==0.63.3 django-cors-headers==4.4.0 -django-csp==3.8 +django-csp==4.0b1 django-debug-toolbar==4.4.2 django-filter==24.2 django-ipware==7.0.1