# # This file is part of pretix (Community Edition). # # Copyright (C) 2014-2020 Raphael Michel and contributors # Copyright (C) 2020-today pretix 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 # . # # This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of # the Apache License 2.0 can be obtained at . # # This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A # full history of changes and contributors is available at . # # This file contains Apache-licensed contributions copyrighted by: Tobias Kunze # # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. import logging import time from datetime import datetime from http.cookies import Morsel from urllib.parse import urlparse from django.conf import settings from django.contrib.sessions.middleware import ( SessionMiddleware as BaseSessionMiddleware, ) from django.core.cache import cache from django.core.exceptions import DisallowedHost, ImproperlyConfigured from django.http.request import split_domain_port from django.middleware.csrf import ( CSRF_SESSION_KEY, CSRF_TOKEN_LENGTH, CsrfViewMiddleware as BaseCsrfMiddleware, _check_token_format, _unmask_cipher_token, ) from django.shortcuts import render from django.urls import set_urlconf from django.utils.cache import patch_vary_headers from django.utils.deprecation import MiddlewareMixin from django.utils.http import http_date from django_scopes import scopes_disabled from pretix.base.models import Event, Organizer from pretix.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.models import KnownDomain logger = logging.getLogger(__name__) LOCAL_HOST_NAMES = ('testserver', 'localhost') class MultiDomainMiddleware(MiddlewareMixin): def process_request(self, request): # We try three options, in order of decreasing preference. if settings.USE_X_FORWARDED_HOST and ('X-Forwarded-Host' in request.headers): host = request.headers['X-Forwarded-Host'] elif 'Host' in request.headers: host = request.headers['Host'] else: # Reconstruct the host using the algorithm from PEP 333. host = request.META['SERVER_NAME'] server_port = str(request.META['SERVER_PORT']) if server_port != ('443' if request.is_secure() else '80'): host = '%s:%s' % (host, server_port) domain, port = split_domain_port(host) default_domain, default_port = split_domain_port(urlparse(settings.SITE_URL).netloc) request.port = int(port) if port else None request.host = domain if domain == default_domain: request.domain_mode = "system" request.urlconf = "pretix.multidomain.maindomain_urlconf" elif domain: cached = cache.get('pretix_multidomain_instances_{}'.format(domain)) if cached is None: try: kd = KnownDomain.objects.select_related('organizer', 'event').get(domainname=domain) # noqa orga = kd.organizer event = kd.event mode = kd.mode except KnownDomain.DoesNotExist: orga = False event = False mode = "system" cache.set( 'pretix_multidomain_instances_{}'.format(domain), (orga.pk if orga else None, event.pk if event else None, mode), 3600 ) else: orga, event, mode = cached if mode == KnownDomain.MODE_EVENT_DOMAIN: request.event_domain = True request.domain_mode = KnownDomain.MODE_EVENT_DOMAIN if isinstance(event, Event): request.organizer = orga request.event = event else: with scopes_disabled(): request.event = Event.objects.select_related('organizer').get(pk=event) request.organizer = request.event.organizer request.urlconf = "pretix.multidomain.event_domain_urlconf" elif mode == KnownDomain.MODE_ORG_ALT_DOMAIN: request.organizer_domain = True request.domain_mode = KnownDomain.MODE_ORG_ALT_DOMAIN request.organizer = orga if isinstance(orga, Organizer) else Organizer.objects.get(pk=orga) request.urlconf = "pretix.multidomain.organizer_alternative_domain_urlconf" elif mode == KnownDomain.MODE_ORG_DOMAIN: request.organizer_domain = True request.domain_mode = KnownDomain.MODE_ORG_DOMAIN request.organizer = orga if isinstance(orga, Organizer) else Organizer.objects.get(pk=orga) request.urlconf = "pretix.multidomain.organizer_domain_urlconf" elif settings.DEBUG or domain in LOCAL_HOST_NAMES: request.domain_mode = "system" request.urlconf = "pretix.multidomain.maindomain_urlconf" else: with scopes_disabled(): is_fresh_install = not Event.objects.exists() return render(request, '400_hostname.html', { 'header_host': domain, 'site_host': default_domain, 'settings': settings, 'xfh': request.headers.get('X-Forwarded-Host'), 'is_fresh_install': is_fresh_install, }, status=400) else: raise DisallowedHost("Invalid HTTP_HOST header: %r." % host) # We need to manually set the urlconf for the whole thread. Normally, Django's basic request handling # would do this for us, but we already need it in place for the other middlewares. set_urlconf(request.urlconf) def process_response(self, request, response): if getattr(request, "urlconf", None): patch_vary_headers(response, ('Host',)) return response class SessionMiddleware(BaseSessionMiddleware): """ We override the default implementation from django because we need to handle cookie domains differently depending on whether we are on the main domain or a custom domain. """ def process_request(self, request): session_key = request.COOKIES.get( '__Host-' + settings.SESSION_COOKIE_NAME, request.COOKIES.get(settings.SESSION_COOKIE_NAME) ) request.session = self.SessionStore(session_key) def process_response(self, request, response): try: accessed = request.session.accessed modified = request.session.modified empty = request.session.is_empty() except AttributeError: pass else: # First check if we need to delete this cookie. # The session should be deleted only if the session is entirely empty is_secure = request.scheme == 'https' if '__Host-' + settings.SESSION_COOKIE_NAME in request.COOKIES and empty: response.delete_cookie('__Host-' + settings.SESSION_COOKIE_NAME) elif settings.SESSION_COOKIE_NAME in request.COOKIES and empty: response.delete_cookie(settings.SESSION_COOKIE_NAME) else: if accessed: patch_vary_headers(response, ('Cookie',)) if modified or settings.SESSION_SAVE_EVERY_REQUEST: if request.session.get_expire_at_browser_close(): max_age = None expires = None else: max_age = request.session.get_expiry_age() expires_time = time.time() + max_age expires = http_date(expires_time) # Save the session data and refresh the client cookie. # Skip session save for 500 responses, refs #3881. if response.status_code != 500: request.session.save() if is_secure and settings.SESSION_COOKIE_NAME in request.COOKIES: # remove legacy cookie response.delete_cookie(settings.SESSION_COOKIE_NAME) response.delete_cookie(settings.SESSION_COOKIE_NAME, samesite="None") set_cookie_without_samesite( request, response, '__Host-' + settings.SESSION_COOKIE_NAME if is_secure else settings.SESSION_COOKIE_NAME, request.session.session_key, max_age=max_age, expires=expires, path=settings.SESSION_COOKIE_PATH, secure=request.scheme == 'https', httponly=settings.SESSION_COOKIE_HTTPONLY or None ) return response class CsrfViewMiddleware(BaseCsrfMiddleware): """ We override the default implementation from django because we need to handle cookie domains differently depending on whether we are on the main domain or a custom domain. """ def _get_secret(self, request): if settings.CSRF_USE_SESSIONS: try: csrf_secret = request.session.get(CSRF_SESSION_KEY) except AttributeError: raise ImproperlyConfigured( "CSRF_USE_SESSIONS is enabled, but request.session is not " "set. SessionMiddleware must appear before CsrfViewMiddleware " "in MIDDLEWARE." ) else: try: csrf_secret = request.COOKIES.get('__Host-' + settings.CSRF_COOKIE_NAME) if not csrf_secret: csrf_secret = request.COOKIES[settings.CSRF_COOKIE_NAME] except KeyError: csrf_secret = None else: # This can raise InvalidTokenFormat. _check_token_format(csrf_secret) if csrf_secret is None: return None # Django versions before 4.0 masked the secret before storing. if len(csrf_secret) == CSRF_TOKEN_LENGTH: csrf_secret = _unmask_cipher_token(csrf_secret) return csrf_secret def _set_csrf_cookie(self, request, response): if settings.CSRF_USE_SESSIONS: if request.session.get(CSRF_SESSION_KEY) != request.META["CSRF_COOKIE"]: request.session[CSRF_SESSION_KEY] = request.META["CSRF_COOKIE"] else: is_secure = request.scheme == 'https' # Set the CSRF cookie even if it's already set, so we renew # the expiry timer. if is_secure and settings.CSRF_COOKIE_NAME in request.COOKIES: # remove legacy cookie response.delete_cookie(settings.CSRF_COOKIE_NAME) response.delete_cookie(settings.CSRF_COOKIE_NAME, samesite="None") handle_duplicated_csrftoken(request, response) set_cookie_without_samesite( request, response, '__Host-' + settings.CSRF_COOKIE_NAME if is_secure else settings.CSRF_COOKIE_NAME, request.META["CSRF_COOKIE"], max_age=settings.CSRF_COOKIE_AGE, path=settings.CSRF_COOKIE_PATH, secure=is_secure, httponly=settings.CSRF_COOKIE_HTTPONLY ) # Content varies with the CSRF cookie, so set the Vary header. patch_vary_headers(response, ('Cookie',)) def handle_duplicated_csrftoken(request, response): # Due to a Safari bug, in some browser, two csrftoken cookies with different values # exist: one unpartitioned, one partitioned. This function generates an additional # Set-Cookie header to get rid of the unpartitioned one. cookie_name = '__Host-' + settings.CSRF_COOKIE_NAME if request.scheme == 'https' and cookie_name in request.COOKIES: values = get_all_values_of_cookie(request.headers.get('Cookie'), cookie_name) if len(values) > 1: logger.info('Trying to remove duplicated %s cookies: %r', cookie_name, values) # Make sure the set_cookie_without_samesite below will add a new item in the dictionary, placing # it below our deletion header. response.cookies.pop(cookie_name, None) # Add the deletion Set-Cookie header to the cookie dict under a wrong name, so it doesn't get # overwritten by the set_cookie_without_samesite call below. This works because the code in # django.core.handlers.wsgi/asgi, that generates the actual Set-Cookie headers, only iterates # over cookie.values(), ignoring the keys. response.cookies['___DELETECOOKIE___' + cookie_name] = make_delete_morsel(cookie_name) def get_all_values_of_cookie(cookie_header, cookie_name): # like django.http.cookie.parse_cookie, but returns all values of duplicated cookies instead of only the last values = list() if not cookie_header: return values for chunk in cookie_header.split(";"): if "=" in chunk: key, val = chunk.split("=", 1) else: # Assume an empty name per # https://bugzilla.mozilla.org/show_bug.cgi?id=169091 key, val = "", chunk key, val = key.strip(), val.strip() if key == cookie_name: values.append(val) return values def make_delete_morsel(name): m = Morsel() m.set(name, '', '') m['expires'] = datetime.utcfromtimestamp(0).strftime("%a, %d %b %Y %H:%M:%S GMT") m['samesite'] = 'None' m['secure'] = True m['path'] = settings.CSRF_COOKIE_PATH m['httponly'] = settings.CSRF_COOKIE_HTTPONLY return m