diff --git a/src/pretix/base/middleware.py b/src/pretix/base/middleware.py index 54a7fb89b9..43fe146474 100644 --- a/src/pretix/base/middleware.py +++ b/src/pretix/base/middleware.py @@ -19,6 +19,8 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import base64 +import hashlib import logging import re from collections import OrderedDict @@ -312,7 +314,28 @@ def _merge_csp(a, b): for k, v in a.items(): if "'unsafe-inline'" in v: # If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise - a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")] + a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha256-")] + + +def add_to_response_csp(response, csp_to_merge): + if "Content-Security-Policy" in response: + csp = _parse_csp(response["Content-Security-Policy"]) + else: + csp = {} + + _merge_csp(csp, csp_to_merge) + + if csp: + response["Content-Security-Policy"] = _render_csp(csp) + + +def add_to_response_csp_via_request(request, csp_to_merge): + _merge_csp(request._csp_to_merge, csp_to_merge) + + +def calculate_csp_hash(data): + hash_str = base64.b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode("ascii") + return f"'sha256-{hash_str}'" class SecurityMiddleware(MiddlewareMixin): @@ -332,6 +355,9 @@ class SecurityMiddleware(MiddlewareMixin): # but at the moment it does not seem to be an issue to just send it. ) + def process_request(self, request): + request._csp_to_merge = {} + def process_response(self, request, resp): if settings.DEBUG and resp.status_code >= 400: # Don't use CSP on debug error page as it breaks of Django's fancy error @@ -343,18 +369,22 @@ class SecurityMiddleware(MiddlewareMixin): # https://github.com/pretix/pretix/issues/765 resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"' - if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES: - if 'Content-Security-Policy' in resp: - del resp['Content-Security-Policy'] - return resp - - if not getattr(resp, '_csp_ignore', False): + if self._needs_csp(request, resp): resp['Content-Security-Policy'] = _render_csp(self._build_csp(request, resp)) elif 'Content-Security-Policy' in resp: del resp['Content-Security-Policy'] return resp + def _needs_csp(self, request, resp): + if "Content-Type" in resp and resp["Content-Type"].split(";")[0] in self.SAFE_TYPES: + return False + + if getattr(resp, '_csp_ignore', False): + return False + + return True + def _build_csp(self, request, resp): url = resolve(request.path_info) @@ -387,13 +417,6 @@ class SecurityMiddleware(MiddlewareMixin): h['style-src'] += ["'unsafe-inline'"] h['connect-src'] += ["http://localhost:5173", "ws://localhost:5173"] - if hasattr(request, 'csp_nonce'): - nonce = f"'nonce-{request.csp_nonce}'" - h['script-src'].append(nonce) - if not settings.VITE_DEV_MODE: - # can't have 'unsafe-inline' and nonce at the same time - h['style-src'].append(nonce) - # Only include pay.google.com for wallet detection purposes on the Payment selection page if ( url.url_name == "event.order.pay.change" or @@ -406,6 +429,9 @@ class SecurityMiddleware(MiddlewareMixin): if settings.LOG_CSP: h['report-uri'] = ["/csp_report/"] + if request._csp_to_merge: + _merge_csp(h, request._csp_to_merge) + if 'Content-Security-Policy' in resp: _merge_csp(h, _parse_csp(resp['Content-Security-Policy'])) diff --git a/src/pretix/base/templatetags/vite.py b/src/pretix/base/templatetags/vite.py index 3c0303c9b3..3473889890 100644 --- a/src/pretix/base/templatetags/vite.py +++ b/src/pretix/base/templatetags/vite.py @@ -19,12 +19,10 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # - import json import logging import pathlib import re -import secrets from urllib.parse import urljoin from urllib.request import urlopen @@ -33,6 +31,10 @@ from django import template from django.conf import settings from django.utils.safestring import mark_safe +from pretix.base.middleware import ( + add_to_response_csp_via_request, calculate_csp_hash, +) + register = template.Library() LOGGER = logging.getLogger(__name__) _MANIFEST = {} @@ -234,10 +236,16 @@ def vite_importmap(context): if not imports: return "" - # Generate a nonce and store it on the request so the CSP middleware can allow it - nonce = secrets.token_urlsafe(16) + json_string = json.dumps({"imports": imports}) + + # Calculate hash and add it to the CSP info in the request, so that will be merged into the + # response header later on by the middleware request = context.get('request') if request: - request.csp_nonce = nonce + csp_hash = calculate_csp_hash(json_string) + add_to_response_csp_via_request(request, { + 'style-src': [csp_hash], + 'script-src': [csp_hash], + }) - return f'' + return f'' diff --git a/src/pretix/control/views/mail.py b/src/pretix/control/views/mail.py index d6e52cface..21dbd8c4bf 100644 --- a/src/pretix/control/views/mail.py +++ b/src/pretix/control/views/mail.py @@ -35,7 +35,7 @@ from django.utils.translation import ngettext from django.views import View from django.views.generic import DetailView, ListView -from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp +from pretix.base.middleware import add_to_response_csp from pretix.base.models import OutgoingMail from pretix.base.services.mail import mail_send_task from pretix.control.forms.filter import OutgoingMailFilterForm @@ -108,18 +108,12 @@ class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequir def dispatch(self, request, *args, **kwargs): response = super().dispatch(request, *args, **kwargs) - if 'Content-Security-Policy' in response: - h = _parse_csp(response['Content-Security-Policy']) - else: - h = {} - csps = { + add_to_response_csp(response, { 'frame-src': ['data:'], # Unfortuantely, we can't avoid unsafe-inline for style here. # See outgoingmail.js for the protection measures we take. 'style-src': ["'unsafe-inline'"], - } - _merge_csp(h, csps) - response['Content-Security-Policy'] = _render_csp(h) + }) return response def get_context_data(self, **kwargs): diff --git a/src/pretix/plugins/paypal2/signals.py b/src/pretix/plugins/paypal2/signals.py index 7965f8e0b0..00932625ce 100644 --- a/src/pretix/plugins/paypal2/signals.py +++ b/src/pretix/plugins/paypal2/signals.py @@ -32,7 +32,7 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy from pretix.base.forms import SecretKeySettingsField from pretix.base.logentrytypes import EventLogEntryType, log_entry_types -from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp +from pretix.base.middleware import add_to_response_csp from pretix.base.settings import settings_hierarkey from pretix.base.signals import ( register_global_settings, register_payment_providers, @@ -144,12 +144,7 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse (url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or (url.namespace == "plugins:paypal2" and url.url_name == "pay") ): - if 'Content-Security-Policy' in response: - h = _parse_csp(response['Content-Security-Policy']) - else: - h = {} - - csps = { + add_to_response_csp(response, { 'script-src': ['https://www.paypal.com', "'nonce-{}'".format(_nonce(request))], # When the stars align in an unpredictable manner and the temperature is just right, the PayPal SDK might @@ -164,12 +159,7 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse 'connect-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com'], # Or not - seems to only affect PayPal logging... 'img-src': ['https://t.paypal.com', 'https://www.paypalobjects.com'], 'style-src': ["'unsafe-inline'"] # PayPal does not comply with our nonce unfortunately, see Z#23113213 - } - - _merge_csp(h, csps) - - if h: - response['Content-Security-Policy'] = _render_csp(h) + }) return response diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index 0c7b328d09..460d7623d4 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -31,7 +31,7 @@ from django.utils.translation import gettext_lazy as _ from paypalhttp import HttpResponse from pretix.base.forms import SecretKeySettingsField -from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp +from pretix.base.middleware import add_to_response_csp from pretix.base.settings import settings_hierarkey from pretix.base.signals import ( logentry_display, register_global_settings, register_payment_providers, @@ -201,21 +201,10 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse (url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or (url.namespace == "plugins:stripe" and url.url_name in ["sca", "sca.return"]) ): - if 'Content-Security-Policy' in response: - h = _parse_csp(response['Content-Security-Policy']) - else: - h = {} - - # https://stripe.com/docs/security/guide#content-security-policy - csps = { + add_to_response_csp(response, { 'connect-src': ['https://api.stripe.com'], 'frame-src': ['https://js.stripe.com', 'https://hooks.stripe.com'], 'script-src': ['https://js.stripe.com'], - } - - _merge_csp(h, csps) - - if h: - response['Content-Security-Policy'] = _render_csp(h) + }) return response