forked from CGM_Public/pretix_original
[SECURITY] Prevent access to arbitrary cached files by UUID (CVE-2025-14881)
This commit is contained in:
@@ -74,6 +74,11 @@ class ExportersMixin:
|
|||||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||||
def download(self, *args, **kwargs):
|
def download(self, *args, **kwargs):
|
||||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||||
|
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||||
|
return Response(
|
||||||
|
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||||
|
status=status.HTTP_410_GONE
|
||||||
|
)
|
||||||
if cf.file:
|
if cf.file:
|
||||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||||
@@ -109,7 +114,8 @@ class ExportersMixin:
|
|||||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
cf = CachedFile(web_download=False)
|
cf = CachedFile(web_download=True)
|
||||||
|
cf.bind_to_session(self.request, "exporters-api")
|
||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.expires = now() + timedelta(hours=24)
|
cf.expires = now() + timedelta(hours=24)
|
||||||
cf.save()
|
cf.save()
|
||||||
|
|||||||
@@ -59,6 +59,37 @@ class CachedFile(models.Model):
|
|||||||
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||||
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||||
|
|
||||||
|
def session_key_for_request(self, request, salt=None):
|
||||||
|
from ...api.models import OAuthAccessToken, OAuthApplication
|
||||||
|
from .devices import Device
|
||||||
|
from .organizer import TeamAPIToken
|
||||||
|
|
||||||
|
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
|
||||||
|
k = f'app:{request.auth.application.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
|
||||||
|
k = f'app:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
|
||||||
|
k = f'token:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, Device):
|
||||||
|
k = f'device:{request.auth.pk}'
|
||||||
|
elif request.session.session_key:
|
||||||
|
k = request.session.session_key
|
||||||
|
else:
|
||||||
|
raise ValueError("No auth method found to bind to")
|
||||||
|
|
||||||
|
if salt:
|
||||||
|
k = f"{k}!{salt}"
|
||||||
|
return k
|
||||||
|
|
||||||
|
def allowed_for_session(self, request, salt=None):
|
||||||
|
return (
|
||||||
|
not self.session_key or
|
||||||
|
self.session_key_for_request(request, salt) == self.session_key
|
||||||
|
)
|
||||||
|
|
||||||
|
def bind_to_session(self, request, salt=None):
|
||||||
|
self.session_key = self.session_key_for_request(request, salt)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=CachedFile)
|
@receiver(post_delete, sender=CachedFile)
|
||||||
def cached_file_delete(sender, instance, **kwargs):
|
def cached_file_delete(sender, instance, **kwargs):
|
||||||
|
|||||||
@@ -36,9 +36,8 @@ class DownloadView(TemplateView):
|
|||||||
def object(self) -> CachedFile:
|
def object(self) -> CachedFile:
|
||||||
try:
|
try:
|
||||||
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
|
||||||
if o.session_key:
|
if not o.allowed_for_session(self.request):
|
||||||
if o.session_key != self.request.session.session_key:
|
raise Http404()
|
||||||
raise Http404()
|
|
||||||
return o
|
return o
|
||||||
except (ValueError, ValidationError): # Invalid URLs
|
except (ValueError, ValidationError): # Invalid URLs
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
@@ -85,6 +86,7 @@ class BaseImportView(TemplateView):
|
|||||||
filename='import.csv',
|
filename='import.csv',
|
||||||
type='text/csv',
|
type='text/csv',
|
||||||
)
|
)
|
||||||
|
cf.bind_to_session(request, "modelimport")
|
||||||
cf.file.save('import.csv', request.FILES['file'])
|
cf.file.save('import.csv', request.FILES['file'])
|
||||||
|
|
||||||
if self.request.POST.get("charset") in ENCODINGS:
|
if self.request.POST.get("charset") in ENCODINGS:
|
||||||
@@ -137,7 +139,10 @@ class BaseProcessView(AsyncAction, FormView):
|
|||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def file(self):
|
def file(self):
|
||||||
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
cf = get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
|
||||||
|
if not cf.allowed_for_session(self.request, "modelimport"):
|
||||||
|
raise Http404()
|
||||||
|
return cf
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def parsed(self):
|
def parsed(self):
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
|
|||||||
cf = None
|
cf = None
|
||||||
if request.POST.get("background", "").strip():
|
if request.POST.get("background", "").strip():
|
||||||
try:
|
try:
|
||||||
cf = CachedFile.objects.get(id=request.POST.get("background"))
|
cf = CachedFile.objects.get(id=request.POST.get("background"), web_download=True)
|
||||||
except CachedFile.DoesNotExist:
|
except CachedFile.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ from collections import OrderedDict
|
|||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.http import Http404
|
||||||
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import get_language, gettext_lazy as _
|
from django.utils.translation import get_language, gettext_lazy as _
|
||||||
@@ -94,6 +95,8 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
|||||||
cf = CachedFile.objects.get(pk=kwargs['file'])
|
cf = CachedFile.objects.get(pk=kwargs['file'])
|
||||||
except CachedFile.DoesNotExist:
|
except CachedFile.DoesNotExist:
|
||||||
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
|
||||||
|
if not cf.allowed_for_session(self.request):
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
with ZipFile(cf.file.file, 'r') as zipfile:
|
with ZipFile(cf.file.file, 'r') as zipfile:
|
||||||
indexdata = json.loads(zipfile.read('index.json').decode())
|
indexdata = json.loads(zipfile.read('index.json').decode())
|
||||||
@@ -111,7 +114,7 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
|
|||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['shredders'] = self.shredders
|
ctx['shredders'] = self.shredders
|
||||||
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
|
||||||
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
|
ctx['file'] = cf
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user