[SECURITY] Fix customer session fixation on cross-domain login (CVE-2026-101268, Z#23247268)

This commit is contained in:
Raphael Michel
2026-09-29 13:42:44 +02:00
parent afe87dcda1
commit 1b69eebbaa
7 changed files with 114 additions and 12 deletions
@@ -14,10 +14,23 @@
{% trans "Log out" %}
</a>
{% else %}
<a href="{% abseventurl request.organizer "presale:organizer.customer.login" %}{% if request.resolver_match.url_name != "organizer.customer.login" %}?next={% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}{% endif %}{% if request.event_domain %}&request_cross_domain_customer_auth=true{% endif %}">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Log in" %}</a>
<form
{% if request.event_domain %}
action="{% abseventurl request.event "presale:event.customer.loginstart" %}" method="post"
{% else %}
action="{% abseventurl request.organizer "presale:organizer.customer.login" %}" method="get"
{% endif %}
class="helper-display-inline">
{% if request.event_domain %}
{% csrf_token %}
{% endif %}
{% if request.resolver_match.url_name != "organizer.customer.login" %}
<input type="hidden" name="next" value="{% if request.event_domain %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ request.path }}?{{ request.META.QUERY_STRING }}">
{% endif %}
<button class="btn btn-link" type="submit">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Log in" %}</button>
</form>
{% endif %}
</nav>
{% endif %}
+1
View File
@@ -98,6 +98,7 @@ event_patterns = [
re_path(r'unlock/(?P<hash>[a-z0-9]{64})/$', pretix.presale.views.user.UnlockHashView.as_view(),
name='event.payment.unlock'),
re_path(r'resend/$', pretix.presale.views.user.ResendLinkView.as_view(), name='event.resend_link'),
re_path(r'^account/loginstart$', pretix.presale.views.customer.LoginStartView.as_view(), name='event.customer.loginstart'),
re_path(r'^favicon.ico/?$',
pretix.presale.views.organizer.OrganizerFavicon.as_view(),
+3 -1
View File
@@ -151,7 +151,9 @@ def add_customer_to_request(request):
else:
parent_session_key = otpstore.get(f'customer_cross_domain_auth_{request.organizer.pk}')
if parent_session_key: # not already invalidated, expired, …
expected_nonce = request.session.pop('cross_domain_customer_auth_nonce', None)
found_nonce = request.GET.get("cross_domain_customer_auth_nonce")
if parent_session_key and expected_nonce and expected_nonce == found_nonce: # not already invalidated, expired, …
# Make sure the OTP can't be used again
otpstore.delete()
+24
View File
@@ -146,6 +146,7 @@ class LoginView(RedirectBackMixin, FormView):
u = urlparse(url)
qsl = parse_qs(u.query)
qsl['cross_domain_customer_auth'] = otp
qsl['cross_domain_customer_auth_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce", "")
url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
return url
@@ -703,6 +704,7 @@ class SSOLoginView(RedirectBackMixin, View):
request.session[f'pretix_customerauth_{self.provider.pk}_nonce'] = nonce
request.session[f'pretix_customerauth_{self.provider.pk}_popup_origin'] = popup_origin
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_requested'] = self.request.GET.get("request_cross_domain_customer_auth") == "true"
request.session[f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce'] = self.request.GET.get("request_cross_domain_customer_auth_nonce")
redirect_uri = build_absolute_uri(self.request.organizer, 'presale:organizer.customer.login.return', kwargs={
'provider': self.provider.pk
})
@@ -953,6 +955,28 @@ class SSOLoginReturnView(RedirectBackMixin, View):
u = urlparse(url)
qsl = parse_qs(u.query)
qsl['cross_domain_customer_auth'] = otp
qsl['cross_domain_customer_auth_nonce'] = self.request.session.get(f'pretix_customerauth_{self.provider.pk}_cross_domain_nonce', '')
url = urlunparse((u.scheme, u.netloc, u.path, u.params, urlencode(qsl, doseq=True), u.fragment))
return url
class LoginStartView(View):
# When a login is initiated on a event-domain-level view, we need to carry the user to the organizer domain through
# this POST request to be able to set a nonce on their current session. We can't just use a link, since then we'd
# need to create sessions for every anonymous user of the ticketshop, which is too expensive.
def post(self, request, *args, **kwargs):
if getattr(self.request, 'domain_mode', 'system') not in (KnownDomain.MODE_ORG_ALT_DOMAIN, KnownDomain.MODE_EVENT_DOMAIN):
raise Http404("Only active on event-level domains")
nonce = get_random_string(32)
request.session['cross_domain_customer_auth_nonce'] = nonce
query = {
"next": request.POST.get("next", ""),
"request_cross_domain_customer_auth_nonce": nonce,
"request_cross_domain_customer_auth": "true",
}
return redirect_to_url(
build_absolute_uri(self.request.organizer, "presale:organizer.customer.login") + "?" + urlencode(query)
)
+10 -3
View File
@@ -42,7 +42,7 @@ import os
import re
from collections import Counter, OrderedDict, defaultdict
from decimal import Decimal
from urllib.parse import quote
from urllib.parse import quote, urlencode
from django import forms
from django.conf import settings
@@ -55,6 +55,7 @@ from django.http import (
FileResponse, Http404, HttpResponseRedirect, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.crypto import get_random_string
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -117,8 +118,14 @@ class OrderDetailMixin(NoSearchIndexViewMixin):
login_url = eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
if hasattr(self.request, "event_domain") and self.request.event_domain:
next_url = quote(self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path())
return redirect_to_url(f'{login_url}?next={next_url}&request_cross_domain_customer_auth=true')
nonce = get_random_string(32)
self.request.session['cross_domain_customer_auth_nonce'] = nonce
query = {
"next": self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path(),
"request_cross_domain_customer_auth_nonce": nonce,
"request_cross_domain_customer_auth": "true",
}
return redirect_to_url(f'{login_url}?{urlencode(query)}')
else:
next_url = quote(self.request.get_full_path())
@@ -221,6 +221,11 @@ footer nav .btn-link {
/*border-bottom: 2px solid $brand-primary;*/
font-weight: bold;
}
.btn-link {
padding: 0;
margin-left: 5px;
vertical-align: top;
}
img {
vertical-align: baseline;
}
@@ -653,6 +658,35 @@ h2 .label {
}
.helper-position-relative {
position: relative;
}
.helper-display-block {
display: block !important;
}
.helper-display-inline {
display: inline !important;
}
.helper-display-inline-block {
display: inline-block !important;
}
.helper-display-none-soft {
display: none;
}
.helper-display-none {
display: none !important;
}
.helper-width-auto {
width: auto;
}
.helper-width-100 {
width: 100%;
}
.helper-space-below {
margin-bottom: 10px;
}
@import "_iframe.scss";
@import "_a11y.scss";
@import "_print.scss";
+25 -4
View File
@@ -721,9 +721,21 @@ def _cross_domain_login(env, client, client2, org_alt=False):
else:
KnownDomain.objects.create(domainname='event.test', organizer=env[0], event=env[1])
# Log in on org domain
# Start session on event domain
path = '/conf/' if org_alt else '/'
r = client.post(f'/account/login?next=https://event.test{path}redeem&request_cross_domain_customer_auth=true', {
r = client2.post(f'{path}account/loginstart', {
'next': f'https://event.test{path}redeem',
}, HTTP_HOST='event.test')
assert r.status_code == 302
u = urlparse(r.headers['Location'])
assert u.netloc == 'org.test'
assert u.path == '/account/login'
assert 'request_cross_domain_customer_auth=' in u.query
assert 'request_cross_domain_customer_auth_nonce=' in u.query
assert 'next=' in u.query
# Log in on org domain
r = client.post(f'{u.path}?{u.query}', {
'email': 'john@example.org',
'password': 'foo',
}, HTTP_HOST='org.test')
@@ -734,6 +746,7 @@ def _cross_domain_login(env, client, client2, org_alt=False):
assert u.path == path + 'redeem'
q = parse_qs(u.query)
assert 'cross_domain_customer_auth' in q
assert 'cross_domain_customer_auth_nonce' in q
# Take session over to event domain
r = client2.get(f'{path}?{u.query}', HTTP_HOST='event.test')
@@ -745,7 +758,7 @@ def _cross_domain_login(env, client, client2, org_alt=False):
def test_cross_domain_login(env, client, client2):
_cross_domain_login(env, client, client2)
# Logged in on evnet domain
# Logged in on event domain
r = client.get('/', HTTP_HOST='event.test')
assert r.status_code == 200
assert b'john@example.org' in r.content
@@ -896,7 +909,14 @@ def test_cross_domain_login_with_sso(env, client, client2, provider):
},
)
url = f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true'
r = client2.post('/account/loginstart', {"next": "https://event.test/redeem"}, follow=False, HTTP_HOST='event.test')
assert r.status_code == 302
assert "/account/login" in r['Location']
u = urlparse(r.headers['Location'])
nonce = parse_qs(u.query)['request_cross_domain_customer_auth_nonce'][0]
url = (f'/account/login/{provider.pk}/?next=https://event.test/redeem&request_cross_domain_customer_auth=true&'
f'request_cross_domain_customer_auth_nonce={nonce}')
r = client.get(url, follow=False, HTTP_HOST='org.test')
assert r.status_code == 302
assert "/authorize" in r['Location']
@@ -910,6 +930,7 @@ def test_cross_domain_login_with_sso(env, client, client2, provider):
assert u.path == '/redeem'
q = parse_qs(u.query)
assert 'cross_domain_customer_auth' in q
assert 'cross_domain_customer_auth_nonce' in q
# Take session over to event domain
r = client2.get(f'/?{u.query}', HTTP_HOST='event.test')