mirror of
https://github.com/pretix/pretix.git
synced 2026-10-01 19:14:43 +00:00
[SECURITY] Fix customer session fixation on cross-domain login (CVE-2026-101268, Z#23247268)
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user