From e19908571c2892f6855777922b0ced1fe4ee4f4f Mon Sep 17 00:00:00 2001 From: Richard Schreiber Date: Tue, 24 Mar 2026 09:03:11 +0100 Subject: [PATCH] Fix delete_cookie for partitioned legacy CSRF cookie --- src/pretix/helpers/cookies.py | 39 +++++++++- src/pretix/multidomain/middlewares.py | 105 ++++++++++++-------------- 2 files changed, 87 insertions(+), 57 deletions(-) diff --git a/src/pretix/helpers/cookies.py b/src/pretix/helpers/cookies.py index aea7e0e37..4288ce71a 100644 --- a/src/pretix/helpers/cookies.py +++ b/src/pretix/helpers/cookies.py @@ -19,8 +19,10 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import itertools import re - +from http.cookies import Morsel +import logging from django.conf import settings @@ -48,6 +50,41 @@ def set_cookie_without_samesite(request, response, key, *args, **kwargs): response.cookies[key]['Partitioned'] = True +def thoroughly_delete_cookie(response, cookie_name, **kwargs): + """ Deletes different possible versions of a cookie (SameSite, Partitioned) """ + properties = {"SameSite": ["", 'None'], "Partitioned": ["", True]} + for i, values in enumerate(itertools.product(*properties.values())): + m = Morsel() + m.set(cookie_name, '', '') + m.update(kwargs) + m.update(zip(properties.keys(), values)) + m['expires'] = "Thu, 01 Jan 1970 00:00:00 GMT" + + response.cookies[f'___DELETECOOKIE__{i}___{cookie_name}'] = m + + # Make sure settings a cookie afterwards will add a new item in the dictionary, placing + # it below our deletion headers. + response.cookies.pop(cookie_name, None) + + +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 + + # Based on https://www.chromium.org/updates/same-site/incompatible-clients # Copyright 2019 Google LLC. # SPDX-License-Identifier: Apache-2.0 diff --git a/src/pretix/multidomain/middlewares.py b/src/pretix/multidomain/middlewares.py index 9aeacd87f..80e080d5e 100644 --- a/src/pretix/multidomain/middlewares.py +++ b/src/pretix/multidomain/middlewares.py @@ -34,8 +34,6 @@ import logging import time -from datetime import datetime -from http.cookies import Morsel from urllib.parse import urlparse from django.conf import settings @@ -58,7 +56,10 @@ 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.helpers.cookies import ( + get_all_values_of_cookie, set_cookie_without_samesite, + thoroughly_delete_cookie, +) from pretix.multidomain.models import KnownDomain logger = logging.getLogger(__name__) @@ -181,9 +182,23 @@ class SessionMiddleware(BaseSessionMiddleware): # 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) + # response.delete_cookie does not work as we might have set a partitioned cookie + thoroughly_delete_cookie( + response, + '__Host-' + settings.SESSION_COOKIE_NAME, + path=settings.SESSION_COOKIE_PATH, + secure=is_secure, + httponly=settings.SESSION_COOKIE_HTTPONLY or None + ) elif settings.SESSION_COOKIE_NAME in request.COOKIES and empty: - response.delete_cookie(settings.SESSION_COOKIE_NAME) + # response.delete_cookie does not work as we might have set a partitioned cookie + thoroughly_delete_cookie( + response, + settings.SESSION_COOKIE_NAME, + path=settings.SESSION_COOKIE_PATH, + secure=is_secure, + httponly=settings.SESSION_COOKIE_HTTPONLY or None + ) else: if accessed: patch_vary_headers(response, ('Cookie',)) @@ -200,15 +215,20 @@ class SessionMiddleware(BaseSessionMiddleware): 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") + # response.delete_cookie does not work as we might have set a partitioned cookie + thoroughly_delete_cookie(response, + settings.SESSION_COOKIE_NAME, + path=settings.SESSION_COOKIE_PATH, + secure=is_secure, + httponly=settings.SESSION_COOKIE_HTTPONLY or 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', + secure=is_secure, httponly=settings.SESSION_COOKIE_HTTPONLY or None ) return response @@ -253,22 +273,27 @@ class CsrfViewMiddleware(BaseCsrfMiddleware): 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") + + # remove legacy cookie + if request.is_secure() and settings.CSRF_COOKIE_NAME in request.COOKIES: + thoroughly_delete_cookie(response, + settings.CSRF_COOKIE_NAME, + path=settings.CSRF_COOKIE_PATH, + secure=request.is_secure(), + httponly=settings.CSRF_COOKIE_HTTPONLY + ) handle_duplicated_csrftoken(request, response) set_cookie_without_samesite( request, response, - '__Host-' + settings.CSRF_COOKIE_NAME if is_secure else settings.CSRF_COOKIE_NAME, + '__Host-' + settings.CSRF_COOKIE_NAME if request.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, + secure=request.is_secure(), httponly=settings.CSRF_COOKIE_HTTPONLY ) # Content varies with the CSRF cookie, so set the Vary header. @@ -276,52 +301,20 @@ class CsrfViewMiddleware(BaseCsrfMiddleware): 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 + # Due to a Safari bug, in some browser, two csrftoken cookies can 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: + if request.is_secure() 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 + thoroughly_delete_cookie( + response, + cookie_name, + secure=request.is_secure(), + path=settings.CSRF_COOKIE_PATH, + httponly=settings.CSRF_COOKIE_HTTPONLY + ) \ No newline at end of file