# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-2021 rami.io GmbH and contributors # # This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General # Public License as published by the Free Software Foundation in version 3 of the License. # # ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are # applicable granting you additional permissions and placing additional restrictions on your usage of this software. # Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive # this file, see . # # This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied # warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more # details. # # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # from collections import OrderedDict from urllib.parse import urlparse, urlsplit from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from django.conf import settings from django.http import Http404, HttpRequest, HttpResponse from django.middleware.common import CommonMiddleware from django.urls import get_script_prefix, resolve from django.utils import timezone, translation from django.utils.cache import patch_vary_headers from django.utils.deprecation import MiddlewareMixin from django.utils.translation.trans_real import ( check_for_language, get_supported_language_variant, language_code_re, parse_accept_lang_header, ) from pretix.base.i18n import get_language_without_region from pretix.base.settings import global_settings_object from pretix.multidomain.urlreverse import ( get_event_domain, get_organizer_domain, ) from pretix.presale.style import get_fonts _supported = None def get_supported_language(requested_language, allowed_languages, default_language): language = requested_language if language not in allowed_languages: firstpart = language.split('-')[0] if firstpart in allowed_languages: language = firstpart else: language = default_language for lang in allowed_languages: if lang.startswith(firstpart + '-'): language = lang break if language not in allowed_languages: # This seems redundant, but can happen in the rare edge case that settings.locale is (wrongfully) # not part of settings.locales language = allowed_languages[0] return language class LocaleMiddleware(MiddlewareMixin): """ This middleware sets the correct locale and timezone for a request. """ def process_request(self, request: HttpRequest): language = get_language_from_request(request) # Normally, this middleware runs *before* the event is set. However, on event frontend pages it # might be run a second time by pretix.presale.EventMiddleware and in this case the event is already # set and can be taken into account for the decision. if not request.path.startswith(get_script_prefix() + 'control'): if hasattr(request, 'event'): settings_holder = request.event elif hasattr(request, 'organizer'): settings_holder = request.organizer else: settings_holder = None if settings_holder: language = get_supported_language( language, settings_holder.settings.locales, settings_holder.settings.locale, ) if '-' not in language and settings_holder.settings.region: language += '-' + settings_holder.settings.region else: gs = global_settings_object(request) if '-' not in language and gs.settings.region: language += '-' + gs.settings.region translation.activate(language) request.LANGUAGE_CODE = get_language_without_region() tzname = None if hasattr(request, 'event'): tzname = request.event.settings.timezone elif hasattr(request, 'organizer') and 'timezone' in request.organizer.settings._cache(): tzname = request.organizer.settings.timezone elif request.user.is_authenticated: tzname = request.user.timezone if tzname: try: timezone.activate(ZoneInfo(tzname)) request.timezone = tzname except ZoneInfoNotFoundError: pass else: timezone.deactivate() def process_response(self, request: HttpRequest, response: HttpResponse): language = translation.get_language() patch_vary_headers(response, ('Accept-Language',)) if 'Content-Language' not in response: response['Content-Language'] = language return response def get_language_from_customer_settings(request: HttpRequest) -> str: if getattr(request, 'customer', None): lang_code = request.customer.locale if lang_code in _supported and lang_code is not None and check_for_language(lang_code): return lang_code def get_language_from_user_settings(request: HttpRequest) -> str: if request.user.is_authenticated: lang_code = request.user.locale if lang_code in _supported and lang_code is not None and check_for_language(lang_code): return lang_code def get_language_from_cookie(request: HttpRequest) -> str: lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) try: return get_supported_language_variant(lang_code) except LookupError: pass def get_language_from_event(request: HttpRequest) -> str: if hasattr(request, 'event'): lang_code = request.event.settings.locale try: return get_supported_language_variant(lang_code) except LookupError: pass def get_language_from_browser(request: HttpRequest) -> str: accept = request.headers.get('Accept-Language', '') for accept_lang, unused in parse_accept_lang_header(accept): if accept_lang == '*': break if not language_code_re.search(accept_lang): continue try: return get_supported_language_variant(accept_lang) except LookupError: continue def get_default_language(): try: return get_supported_language_variant(settings.LANGUAGE_CODE) except LookupError: # NOQA return settings.LANGUAGE_CODE def get_language_from_request(request: HttpRequest) -> str: """ Analyzes the request to find what language the user wants the system to show. Only languages listed in settings.LANGUAGES are taken into account. If the user requests a sublanguage where we have a main language, we send out the main language. """ global _supported if _supported is None: _supported = OrderedDict(settings.LANGUAGES) if request.path.startswith(get_script_prefix() + 'control'): return ( get_language_from_user_settings(request) or get_language_from_customer_settings(request) or get_language_from_cookie(request) or get_language_from_browser(request) or get_language_from_event(request) or get_default_language() ) else: return ( get_language_from_cookie(request) or get_language_from_customer_settings(request) or get_language_from_user_settings(request) or get_language_from_browser(request) or get_language_from_event(request) or get_default_language() ) def _parse_csp(header): h = {} for part in header.split(';'): k, v = part.strip().split(' ', 1) h[k.strip()] = v.split(' ') return h def _render_csp(h): return "; ".join(k + ' ' + ' '.join(v) for k, v in h.items() if v) def _merge_csp(a, b): for k, v in a.items(): if k in b: a[k] += [i for i in b[k] if i not in a[k]] for k, v in b.items(): if k not in a: a[k] = b[k] 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-")] class SecurityMiddleware(MiddlewareMixin): CSP_EXEMPT = ( '/api/v1/docs/', ) def process_response(self, request, resp): def nested_dict_values(d): for v in d.values(): if isinstance(v, dict): yield from nested_dict_values(v) else: if isinstance(v, str): yield v url = resolve(request.path_info) if settings.DEBUG and resp.status_code >= 400: # Don't use CSP on debug error page as it breaks of Django's fancy error # pages return resp # We just need to have a P3P, not matter whats in there # https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/ # https://github.com/pretix/pretix/issues/765 resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"' img_src = [] gs = global_settings_object(request) if gs.settings.leaflet_tiles: img_src.append(gs.settings.leaflet_tiles[:gs.settings.leaflet_tiles.index("/", 10)].replace("{s}", "*")) font_src = set() if hasattr(request, 'event'): for font in get_fonts(request.event, pdf_support_required=False).values(): for path in list(nested_dict_values(font)): font_location = urlparse(path) if font_location.scheme and font_location.netloc: font_src.add('{}://{}'.format(font_location.scheme, font_location.netloc)) h = { 'default-src': ["{static}"], 'script-src': ['{static}'], 'object-src': ["'none'"], 'frame-src': ['{static}'], 'style-src': ["{static}", "{media}"], 'connect-src': ["{dynamic}", "{media}"], 'img-src': ["{static}", "{media}", "data:"] + img_src, 'font-src': ["{static}"] + list(font_src), 'media-src': ["{static}", "data:"], # form-action is not only used to match on form actions, but also on URLs # form-actions redirect to. In the context of e.g. payment providers or # single-sign-on this can be nearly anything, so we cannot really restrict # this. However, we'll restrict it to HTTPS. 'form-action': ["{dynamic}", "https:"] + (['http:'] if settings.SITE_URL.startswith('http://') else []), } # Only include pay.google.com for wallet detection purposes on the Payment selection page if ( url.url_name == "event.order.pay.change" or (url.url_name == "event.checkout" and url.kwargs['step'] == "payment") ): h['script-src'].append('https://pay.google.com') h['frame-src'].append('https://pay.google.com') h['connect-src'].append('https://google.com/pay') if settings.LOG_CSP: h['report-uri'] = ["/csp_report/"] if 'Content-Security-Policy' in resp: _merge_csp(h, _parse_csp(resp['Content-Security-Policy'])) if settings.CSP_ADDITIONAL_HEADER: _merge_csp(h, _parse_csp(settings.CSP_ADDITIONAL_HEADER)) staticdomain = "'self'" dynamicdomain = "'self'" mediadomain = "'self'" if settings.MEDIA_URL.startswith('http'): mediadomain += " " + settings.MEDIA_URL[:settings.MEDIA_URL.find('/', 9)] if settings.STATIC_URL.startswith('http'): staticdomain += " " + settings.STATIC_URL[:settings.STATIC_URL.find('/', 9)] if settings.SITE_URL.startswith('http'): if settings.SITE_URL.find('/', 9) > 0: staticdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)] dynamicdomain += " " + settings.SITE_URL[:settings.SITE_URL.find('/', 9)] else: staticdomain += " " + settings.SITE_URL dynamicdomain += " " + settings.SITE_URL if hasattr(request, 'organizer') and request.organizer: if hasattr(request, 'event') and request.event: domain = get_event_domain(request.event, fallback=True) else: domain = get_organizer_domain(request.organizer) if domain: siteurlsplit = urlsplit(settings.SITE_URL) if siteurlsplit.port and siteurlsplit.port not in (80, 443): domain = '%s:%d' % (domain, siteurlsplit.port) dynamicdomain += " " + domain if request.path not in self.CSP_EXEMPT and not getattr(resp, '_csp_ignore', False): resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain) for k, v in h.items(): h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' '))) resp['Content-Security-Policy'] = _render_csp(h) elif 'Content-Security-Policy' in resp: del resp['Content-Security-Policy'] return resp class CustomCommonMiddleware(CommonMiddleware): def get_full_path_with_slash(self, request): """ Raise an error regardless of DEBUG mode when in POST, PUT, or PATCH. """ new_path = super().get_full_path_with_slash(request) if request.method in ('POST', 'PUT', 'PATCH'): raise Http404('Please append a / at the end of the URL') return new_path