diff --git a/src/pretix/helpers/cookies.py b/src/pretix/helpers/cookies.py new file mode 100644 index 0000000000..cfb1aecd99 --- /dev/null +++ b/src/pretix/helpers/cookies.py @@ -0,0 +1,105 @@ +import re + +from django.conf import settings + + +def set_cookie_without_samesite(request, response, key, *args, **kwargs): + assert 'samesite' not in kwargs + response.set_cookie(key, *args, **kwargs) + if should_send_same_site_none(request.headers.get('User-Agent', '')): + # Chromium is rolling out SameSite=Lax as a default + # https://www.chromestatus.com/feature/5088147346030592 + # This however breaks all pretix-in-an-iframe things, such as the pretix Widget. + # Sadly, this means we need to forcefully set SameSite=None and rely on our other + # CSRF protections to be working. + response.cookies[key]['samesite'] = 'None' + # This will only work on secure cookies as well + # https://www.chromestatus.com/feature/5633521622188032 + response.cookies[key]['secure'] = ( + kwargs.get('secure', False) or request.scheme == 'https' or + settings.SITE_URL.startswith('https://') + ) + + +# Based on https://www.chromium.org/updates/same-site/incompatible-clients +# Copyright 2019 Google LLC. +# SPDX-License-Identifier: Apache-2.0 + + +def should_send_same_site_none(useragent): + # Don’t send `SameSite=None` to known incompatible clients. + return not has_web_kit_same_site_bug(useragent) and not drops_unrecognized_same_site_cookies(useragent) + + +def has_web_kit_same_site_bug(useragent): + return is_ios_version(12, useragent) or ( + is_macosx_version(10, 14, useragent) and (is_safari(useragent) or is_mac_embedded_browser(useragent)) + ) + + +def drops_unrecognized_same_site_cookies(useragent): + if is_uc_browser(useragent): + return not is_uc_browser_version_at_least(12, 13, 2, useragent) + return ( + is_chromium_based(useragent) and is_chromium_version_at_least(51, useragent) and + not is_chromium_version_at_least(67, useragent) + ) + + +# Regex parsing of User-Agent string. (See note above!) +RE_CHROMIUM = re.compile(r"Chrom(e|ium)") +RE_CHROMIUM_VERSION = re.compile(r"Chrom[^ /]+/([0-9]+)[.0-9]* ") +RE_UC_VERSION = re.compile(r"UCBrowser/([0-9]+)\.([0-9]+)\.([0-9]+)[.0-9]* ") +RE_IOS_VERSION = re.compile(r"\(iP.+; CPU .*OS ([0-9]+)[_0-9]*.*\) AppleWebKit/") +RE_MAC_VERSION = re.compile(r"\(Macintosh;.*Mac OS X ([0-9]+)_([0-9]+)[_0-9]*.*\) AppleWebKit/") +RE_SAFARI = re.compile(r"Version/.* Safari/") +RE_MAC_EMBEDDED = re.compile(r"^Mozilla/[.0-9]+ \(Macintosh;.*Mac OS X [_0-9]+\) AppleWebKit/[.0-9]+ \(KHTML, " + r"like Gecko\)$") + + +def is_ios_version(major, useragent): + m = RE_IOS_VERSION.search(useragent) + if not m: + return False + return m.group(1) == str(major) + + +def is_macosx_version(major, minor, useragent): + m = RE_MAC_VERSION.search(useragent) + if not m: + return False + + return m.group(1) == str(major) and m.group(2) == str(minor) + + +def is_safari(useragent): + return RE_SAFARI.search(useragent) and not is_chromium_based(useragent) + + +def is_mac_embedded_browser(useragent): + return RE_MAC_EMBEDDED.search(useragent) + + +def is_chromium_based(useragent): + return RE_CHROMIUM.search(useragent) + + +def is_chromium_version_at_least(major, useragent): + # Extract digits from first capturing group. + version = int(RE_CHROMIUM_VERSION.search(useragent).group(1)) + return version >= major + + +def is_uc_browser(useragent): + return 'UCBrowser/' in useragent + + +def is_uc_browser_version_at_least(major, minor, build, useragent): + major_version = int(RE_UC_VERSION.search(useragent).group(1)) + minor_version = int(RE_UC_VERSION.search(useragent).group(2)) + build_version = int(RE_UC_VERSION.search(useragent).group(3)) + if major_version != major: + return major_version > major + if minor_version != minor: + return minor_version > minor + return build_version >= build diff --git a/src/pretix/multidomain/middlewares.py b/src/pretix/multidomain/middlewares.py index fa366e5c45..fc729f50af 100644 --- a/src/pretix/multidomain/middlewares.py +++ b/src/pretix/multidomain/middlewares.py @@ -15,6 +15,7 @@ from django.utils.deprecation import MiddlewareMixin from django.utils.http import http_date from pretix.base.models import Organizer +from pretix.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.models import KnownDomain LOCAL_HOST_NAMES = ('testserver', 'localhost') @@ -106,13 +107,16 @@ class SessionMiddleware(BaseSessionMiddleware): # Skip session save for 500 responses, refs #3881. if response.status_code != 500: request.session.save() - response.set_cookie(settings.SESSION_COOKIE_NAME, - request.session.session_key, max_age=max_age, - expires=expires, - domain=get_cookie_domain(request), - path=settings.SESSION_COOKIE_PATH, - secure=request.scheme == 'https', - httponly=settings.SESSION_COOKIE_HTTPONLY or None) + set_cookie_without_samesite( + request, response, + settings.SESSION_COOKIE_NAME, + request.session.session_key, max_age=max_age, + expires=expires, + domain=get_cookie_domain(request), + path=settings.SESSION_COOKIE_PATH, + secure=request.scheme == 'https', + httponly=settings.SESSION_COOKIE_HTTPONLY or None + ) return response @@ -138,14 +142,16 @@ class CsrfViewMiddleware(BaseCsrfMiddleware): # Set the CSRF cookie even if it's already set, so we renew # the expiry timer. - response.set_cookie(settings.CSRF_COOKIE_NAME, - request.META["CSRF_COOKIE"], - max_age=settings.CSRF_COOKIE_AGE, - domain=get_cookie_domain(request), - path=settings.CSRF_COOKIE_PATH, - secure=request.scheme == 'https', - httponly=settings.CSRF_COOKIE_HTTPONLY - ) + set_cookie_without_samesite( + request, response, + settings.CSRF_COOKIE_NAME, + request.META["CSRF_COOKIE"], + max_age=settings.CSRF_COOKIE_AGE, + domain=get_cookie_domain(request), + path=settings.CSRF_COOKIE_PATH, + secure=request.scheme == 'https', + httponly=settings.CSRF_COOKIE_HTTPONLY + ) # Content varies with the CSRF cookie, so set the Vary header. patch_vary_headers(response, ('Cookie',)) response.csrf_processing_done = True diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 1a6a54985e..fa3143fdb7 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -16,6 +16,7 @@ from pretix.base.models import ( CartPosition, InvoiceAddress, OrderPosition, QuestionAnswer, ) from pretix.base.services.cart import get_fees +from pretix.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.urlreverse import eventreverse from pretix.presale.signals import question_form_fields @@ -305,9 +306,15 @@ def iframe_entry_view_wrapper(view_func): with language(locale): resp = view_func(request, *args, **kwargs) max_age = 10 * 365 * 24 * 60 * 60 - resp.set_cookie(settings.LANGUAGE_COOKIE_NAME, locale, max_age=max_age, - expires=(datetime.utcnow() + timedelta(seconds=max_age)).strftime('%a, %d-%b-%Y %H:%M:%S GMT'), - domain=settings.SESSION_COOKIE_DOMAIN) + set_cookie_without_samesite( + request, + resp, + settings.LANGUAGE_COOKIE_NAME, + locale, + max_age=max_age, + expires=(datetime.utcnow() + timedelta(seconds=max_age)).strftime('%a, %d-%b-%Y %H:%M:%S GMT'), + domain=settings.SESSION_COOKIE_DOMAIN + ) return resp resp = view_func(request, *args, **kwargs) diff --git a/src/pretix/presale/views/locale.py b/src/pretix/presale/views/locale.py index 7b7f889d8c..c1fa5d2d3c 100644 --- a/src/pretix/presale/views/locale.py +++ b/src/pretix/presale/views/locale.py @@ -5,6 +5,8 @@ from django.http import HttpResponseRedirect from django.utils.http import is_safe_url from django.views.generic import View +from pretix.helpers.cookies import set_cookie_without_samesite + from .robots import NoSearchIndexViewMixin @@ -19,9 +21,14 @@ class LocaleSet(NoSearchIndexViewMixin, View): if locale in [lc for lc, ll in settings.LANGUAGES]: max_age = 10 * 365 * 24 * 60 * 60 - resp.set_cookie(settings.LANGUAGE_COOKIE_NAME, locale, max_age=max_age, - expires=(datetime.utcnow() + timedelta(seconds=max_age)).strftime( - '%a, %d-%b-%Y %H:%M:%S GMT'), - domain=settings.SESSION_COOKIE_DOMAIN) + set_cookie_without_samesite( + request, resp, + settings.LANGUAGE_COOKIE_NAME, + locale, + max_age=max_age, + expires=(datetime.utcnow() + timedelta(seconds=max_age)).strftime( + '%a, %d-%b-%Y %H:%M:%S GMT'), + domain=settings.SESSION_COOKIE_DOMAIN + ) return resp diff --git a/src/tests/multidomain/test_middlewares.py b/src/tests/multidomain/test_middlewares.py index b77b7204c4..bc2ddbea40 100644 --- a/src/tests/multidomain/test_middlewares.py +++ b/src/tests/multidomain/test_middlewares.py @@ -105,3 +105,40 @@ def test_with_forwarded_host(env, client): r = client.get('/2015/', HTTP_X_FORWARDED_HOST='foobar') assert r.status_code == 200 settings.USE_X_FORWARDED_HOST = False + + +@pytest.mark.django_db +@pytest.mark.parametrize("agent", [ + 'Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 ' + 'Chrome/79.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) ' + 'CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_0 like Mac OS X) AppleWebKit/603.1.23 (KHTML, like Gecko) Version/10.0 ' + 'Mobile/14E5239e Safari/602.1', + 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_6_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 ' + 'Safari/534.59.10', +]) +def test_cookie_samesite_none(env, client, agent): + client.post('/mrmcd/2015/cart/add', HTTP_HOST='example.com', HTTP_USER_AGENT=agent) + r = client.get('/mrmcd/2015/', HTTP_HOST='example.com', HTTP_USER_AGENT=agent) + assert r.client.cookies['pretix_csrftoken']['samesite'] == 'None' + assert r.client.cookies['pretix_session']['samesite'] == 'None' + + +@pytest.mark.django_db +@pytest.mark.parametrize("agent", [ + 'Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/_BuildID_) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 ' + 'Chrome/52.0.0.0 Mobile Safari/537.36', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_3 like Mac OS X) AppleWebKit/602.1.50 (KHTML, like Gecko) ' + 'CriOS/56.0.2924.75 Mobile/14E5239e Safari/602.1', + 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/603.1.23 (KHTML, like Gecko) Version/10.0 ' + 'Mobile/14E5239e Safari/602.1', + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_8) AppleWebKit/534.59.10 (KHTML, like Gecko) Version/5.1.9 ' + 'Safari/534.59.10', +]) +def test_cookie_samesite_none_only_on_compatible_browsers(env, client, agent): + client.post('/mrmcd/2015/cart/add', HTTP_HOST='example.com', HTTP_USER_AGENT=agent) + r = client.get('/mrmcd/2015/', HTTP_HOST='example.com', HTTP_USER_AGENT=agent) + assert not r.client.cookies['pretix_csrftoken'].get('samesite')