Compare commits

..

1 Commits

Author SHA1 Message Date
Raphael Michel
902acda340 Bank transfer: More restrictive approach to permissions (Z#23235727) 2026-06-09 11:12:12 +02:00
9 changed files with 115 additions and 59 deletions

View File

@@ -57,8 +57,6 @@ logger = logging.getLogger('pretix.base.email')
T = TypeVar("T", bound=EmailBackend)
_cgnat_net = ipaddress.ip_network('100.64.0.0/10')
def test_custom_smtp_backend(backend: T, from_addr: str) -> None:
try:
@@ -255,15 +253,12 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise socket.error(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise socket.error(f"Request to private address {sa[0]} blocked")
if check_ip4 in _cgnat_net:
raise socket.error(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None
try:

View File

@@ -148,14 +148,13 @@ def monkeypatch_urllib3_ssrf_protection():
if not getattr(settings, "ALLOW_HTTP_TO_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
check_ip4 = ip_addr.ipv4_mapped if getattr(ip_addr, "ipv4_mapped", None) else ip_addr
if ip_addr.is_multicast:
raise HTTPError(f"Request to multicast address {sa[0]} blocked")
if ip_addr.is_loopback or ip_addr.is_link_local:
raise HTTPError(f"Request to local address {sa[0]} blocked")
if ip_addr.is_private:
raise HTTPError(f"Request to private address {sa[0]} blocked")
if check_ip4 in _cgnat_net:
if ip_addr in _cgnat_net:
raise HTTPError(f"Request to RFC 6598 address {sa[0]} blocked")
sock = None

View File

@@ -19,6 +19,7 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.template.loader import get_template
from django.urls import resolve, reverse
@@ -76,10 +77,18 @@ def control_nav_import(sender, request=None, **kwargs):
@receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav")
def control_nav_orga_import(sender, request=None, **kwargs):
url = resolve(request.path_info)
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request
).filter(organizer=request.organizer).exists()
if not has_any_event_perm:
events_without_permission = request.organizer.events.filter(
~Exists(
request.user.teams.with_event_permission(
"event.orders:read"
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
if events_without_permission:
return []
return [
{

View File

@@ -10,7 +10,7 @@
Therefore, you won't be able to mark any order as paid here.
{% endblocktrans %}
</div>
{% else %}
{% elif can_write %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Upload a new file" %}</h3>
@@ -93,7 +93,7 @@
</div>
</form>
<div class="col-md-2">
{% if not filter_form.is_valid %}
{% if not filter_form.is_valid and can_write %}
<form action="" method="post" class="helper-display-inline pull-right flip">
{% csrf_token %}
<button class="btn btn-danger" type="submit" name="discard" value="all">

View File

@@ -25,7 +25,7 @@
</div>
{% endif %}
{% if num_new > 0 %}
{% if num_new > 0 and can_write %}
<form action="" method="post">
{% csrf_token %}
<button class="btn btn-primary">

View File

@@ -20,24 +20,24 @@
{% for trans in list %}
<tr data-id="{{ trans.id }}">
<td class="actions">
{% if trans.order and trans.state == 'invalid' %}
{% if trans.order and trans.state == 'invalid' and can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="accept"
data-toggle="tooltip" title="{% trans "Accept anyway" %}" data-placement="right">
<span class="fa fa-check"></span>
</button>
{% elif trans.state == 'nomatch' %}
{% elif trans.state == 'nomatch' and can_write %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}"
value="assign" data-toggle="tooltip" title="{% trans "Assign to order" %}"
data-placement="right">
<span class="fa fa-check"></span>
</button>
{% elif trans.state == 'error' %}
{% elif trans.state == 'error' and can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="retry"
data-toggle="tooltip" title="{% trans "Retry" %}" data-placement="right">
<span class="fa fa-refresh"></span>
</button>
{% elif trans.state == 'already' %}
{% elif trans.state == 'already' and can_write %}
<input type="text" class="form-control" placeholder="{% trans "Order code" %}">
<div class="btn-group" role="group">
<button class="btn btn-default" type="button" name="action_{{ trans.id }}"
@@ -76,13 +76,15 @@
{% endif %}
</div>
{{ trans.reference }}
<div class="comment-box" data-plain="{{ trans.comment }}">
<strong>{% trans "Comment:" %}</strong>
<span class="comment">{{ trans.comment|rich_text }}</span>
<a href="#" class="comment-modify btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</div>
{% if can_write %}
<div class="comment-box" data-plain="{{ trans.comment }}">
<strong>{% trans "Comment:" %}</strong>
<span class="comment">{{ trans.comment|rich_text }}</span>
<a href="#" class="comment-modify btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
</div>
{% endif %}
</td>
<td>
{% if trans.currency %}
@@ -119,10 +121,12 @@
{% endif %}
</td>
<td class="discard">
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard"
data-toggle="tooltip" title="{% trans "Discard" %}">
<span class="fa fa-trash"></span>
</button>
{% if can_write %}
<button type="button" class="btn btn-default" name="action_{{ trans.id }}" value="discard"
data-toggle="tooltip" title="{% trans "Discard" %}">
<span class="fa fa-trash"></span>
</button>
{% endif %}
</td>
</tr>
{% endfor %}

View File

@@ -46,7 +46,7 @@ from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Count, Q, QuerySet
from django.db.models import Count, Exists, OuterRef, Q, QuerySet
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
@@ -586,6 +586,7 @@ class ImportView(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['job_running'] = self.job_running
ctx['can_write'] = self.can_write
ctx['no_more_payments'] = False
ctx['filter_form'] = BankTransactionFilterForm(self.request.GET or None)
@@ -623,45 +624,94 @@ class ImportView(ListView):
return ctx
class OrganizerBanktransferView:
class EventPermissionOnAllEventsRequiredMixin:
@cached_property
def can_write(self):
perm_name = self.event_permission
if hasattr(self, 'write_event_permission'):
perm_name = self.write_event_permission
events_without_permission = self.request.organizer.events.filter(
~Exists(
self.request.user.teams.with_event_permission(
perm_name
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
return not events_without_permission
def dispatch(self, request, *args, **kwargs):
has_any_event_perm = request.user.get_events_with_permission(
"event.orders:write", request=request
).filter(organizer=request.organizer).exists()
if not has_any_event_perm:
perm_name = self.event_permission
if request.method not in ("GET", "HEAD") and hasattr(self, 'write_event_permission'):
perm_name = self.write_event_permission
events_without_permission = self.request.organizer.events.filter(
~Exists(
self.request.user.teams.with_event_permission(
perm_name
).filter(
Q(all_events=True) | Q(limit_events=OuterRef("pk")),
organizer_id=OuterRef("organizer_id"),
)
)
).exists()
if events_without_permission:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
class EventImportView(EventPermissionRequiredMixin, ImportView):
class PostEventPermissionRequiredMixin(EventPermissionRequiredMixin):
@cached_property
def can_write(self):
return self.request.user.has_event_permission(
self.request.organizer, self.request.event, self.write_permission, request=self.request
)
def dispatch(self, request, *args, **kwargs):
if request.method not in ("GET", "HEAD"):
if not self.can_write:
raise PermissionDenied()
return super().dispatch(request, *args, **kwargs)
class EventImportView(PostEventPermissionRequiredMixin, ImportView):
permission = 'event.orders:write'
write_permission = 'event.orders:write'
class OrganizerImportView(OrganizerBanktransferView, OrganizerDetailViewMixin,
class OrganizerImportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
ImportView):
pass
event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
class EventJobDetailView(EventPermissionRequiredMixin, JobDetailView):
permission = 'event.orders:write'
permission = 'event.orders:read'
class OrganizerJobDetailView(OrganizerBanktransferView, OrganizerDetailViewMixin,
class OrganizerJobDetailView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
JobDetailView):
pass
event_permission = 'event.orders:read'
class EventActionView(EventPermissionRequiredMixin, ActionView):
permission = 'event.orders:write'
class EventActionView(PostEventPermissionRequiredMixin, ActionView):
permission = 'event.orders:read'
write_permission = 'event.orders:write'
class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin,
class OrganizerActionView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin,
ActionView):
event_permission = "event.orders:read"
write_event_permission = "event.orders:write"
def order_qs(self):
# The filters here are basically pointless with EventPermissionOnAllEventsRequiredMixin
# but let's keep them for safety with future refactorings
all = self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
all_events=True,
organizer=self.request.organizer,
).exists()
@@ -671,7 +721,6 @@ class OrganizerActionView(OrganizerBanktransferView, OrganizerDetailViewMixin,
return Order.objects.filter(
event_id__in=self.request.user.teams.filter(
TeamQuerySet.event_permission_q("event.orders:read"),
TeamQuerySet.event_permission_q("event.orders:write"),
organizer=self.request.organizer,
).values_list('limit_events__id', flat=True)
)
@@ -712,6 +761,7 @@ class RefundExportListView(ListView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['num_new'] = self.get_unexported().count()
ctx['can_write'] = self.can_write
ctx['basetpl'] = "pretixcontrol/event/base.html"
if not hasattr(self.request, 'event'):
ctx['basetpl'] = "pretixcontrol/organizers/base.html"
@@ -764,8 +814,9 @@ class RefundExportListView(ListView):
return redirect(self.get_success_url())
class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListView):
permission = 'event.orders:write'
class EventRefundExportListView(PostEventPermissionRequiredMixin, RefundExportListView):
permission = 'event.orders:read'
write_permission = 'event.orders:write'
def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -787,7 +838,9 @@ class EventRefundExportListView(EventPermissionRequiredMixin, RefundExportListVi
)
class OrganizerRefundExportListView(OrganizerBanktransferView, RefundExportListView):
class OrganizerRefundExportListView(EventPermissionOnAllEventsRequiredMixin, RefundExportListView):
event_permission = 'event.orders:read'
write_event_permission = 'event.orders:write'
def get_success_url(self):
return reverse('plugins:banktransfer:refunds.list', kwargs={
@@ -820,7 +873,7 @@ class DownloadRefundExportView(DetailView):
class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefundExportView):
permission = 'event.orders:write'
permission = 'event.orders:read'
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -830,7 +883,8 @@ class EventDownloadRefundExportView(EventPermissionRequiredMixin, DownloadRefund
)
class OrganizerDownloadRefundExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, DownloadRefundExportView):
class OrganizerDownloadRefundExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, DownloadRefundExportView):
event_permission = 'event.orders:read'
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -877,7 +931,7 @@ class SepaXMLExportView(SingleObjectMixin, FormView):
class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
permission = 'event.orders:write'
permission = 'event.orders:read'
def get_object(self, *args, **kwargs):
return get_object_or_404(
@@ -892,7 +946,8 @@ class EventSepaXMLExportView(EventPermissionRequiredMixin, SepaXMLExportView):
return form
class OrganizerSepaXMLExportView(OrganizerBanktransferView, OrganizerDetailViewMixin, SepaXMLExportView):
class OrganizerSepaXMLExportView(EventPermissionOnAllEventsRequiredMixin, OrganizerDetailViewMixin, SepaXMLExportView):
permission = 'event.orders:read'
def get_object(self, *args, **kwargs):
return get_object_or_404(

View File

@@ -602,13 +602,10 @@ PRIVATE_IPS_RES = [
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('127.1.1.1', 443))],
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('192.168.5.3', 443))],
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('224.0.0.1', 443))],
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('100.64.0.1', 443))],
[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('100.100.100.100', 443))],
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::1', 443, 0, 0))],
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
[(socket.AF_INET6, socket.SOCK_STREAM, 6, '', ('::ffff:100.64.0.1', 443, 0, 0))],
]

View File

@@ -43,8 +43,6 @@ def test_private_ip_blocked():
requests.get("https://10.0.0.1", timeout=0.1)
with pytest.raises(HTTPError, match="Request to RFC 6598 address.*"):
requests.get("https://100.100.100.100", timeout=0.1)
with pytest.raises(HTTPError, match="Request to RFC 6598 address.*"):
requests.get("https://[::ffff:100.64.0.1]", timeout=0.1)
@pytest.mark.django_db
@@ -60,7 +58,6 @@ def test_private_ip_blocked():
[(AF_INET6, SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))],
[(AF_INET6, SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))],
[(AF_INET6, SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))],
[(AF_INET6, SOCK_STREAM, 6, "", ("::ffff:100.64.0.1", 443, 0, 0))],
])
def test_dns_resolving_to_local_blocked(res):
with mock.patch('socket.getaddrinfo') as mock_addr: