VYPR
Low severityGHSA Advisory· Published Dec 19, 2025· Updated Apr 15, 2026

CVE-2025-14881

CVE-2025-14881

Description

Multiple API endpoints allowed access to sensitive files from other users by knowing the UUID of the file that were not intended to be accessible by UUID only.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
pretixPyPI
>= 2025.10.0, < 2025.10.12025.10.1
pretixPyPI
>= 2025.9.0, < 2025.9.32025.9.3
pretixPyPI
< 2025.8.32025.8.3

Affected products

1

Patches

1
4b5651862c57

[SECURITY] Prevent access to arbitrary cached files by UUID (CVE-2025-14881)

https://github.com/pretix/pretixRaphael MichelDec 18, 2025via ghsa
6 files changed · +52 8
  • src/pretix/api/views/exporters.py+7 1 modified
    @@ -74,6 +74,11 @@ def retrieve(self, request, *args, **kwargs):
         @action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
         def download(self, *args, **kwargs):
             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:
                 resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
                 resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
    @@ -109,7 +114,8 @@ def run(self, *args, **kwargs):
             serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
             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.expires = now() + timedelta(hours=24)
             cf.save()
    
  • src/pretix/base/models/base.py+31 0 modified
    @@ -58,6 +58,37 @@ class CachedFile(models.Model):
         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
     
    +    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)
     def cached_file_delete(sender, instance, **kwargs):
    
  • src/pretix/base/views/cachedfiles.py+2 3 modified
    @@ -36,9 +36,8 @@ class DownloadView(TemplateView):
         def object(self) -> CachedFile:
             try:
                 o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
    -            if o.session_key:
    -                if o.session_key != self.request.session.session_key:
    -                    raise Http404()
    +            if not o.allowed_for_session(self.request):
    +                raise Http404()
                 return o
             except (ValueError, ValidationError):   # Invalid URLs
                 raise Http404()
    
  • src/pretix/control/views/modelimport.py+6 1 modified
    @@ -38,6 +38,7 @@
     
     from django.conf import settings
     from django.contrib import messages
    +from django.http import Http404
     from django.shortcuts import get_object_or_404, redirect
     from django.urls import reverse
     from django.utils.functional import cached_property
    @@ -85,6 +86,7 @@ def post(self, request, *args, **kwargs):
                 filename='import.csv',
                 type='text/csv',
             )
    +        cf.bind_to_session(request, "modelimport")
             cf.file.save('import.csv', request.FILES['file'])
     
             if self.request.POST.get("charset") in ENCODINGS:
    @@ -137,7 +139,10 @@ def form_valid(self, form):
     
         @cached_property
         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
         def parsed(self):
    
  • src/pretix/control/views/pdf.py+1 1 modified
    @@ -247,7 +247,7 @@ def post(self, request, *args, **kwargs):
             cf = None
             if request.POST.get("background", "").strip():
                 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:
                     pass
     
    
  • src/pretix/control/views/shredder.py+5 2 modified
    @@ -38,7 +38,8 @@
     from zipfile import ZipFile
     
     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.utils.functional import cached_property
     from django.utils.translation import get_language, gettext_lazy as _
    @@ -94,6 +95,8 @@ def get_context_data(self, **kwargs):
                 cf = CachedFile.objects.get(pk=kwargs['file'])
             except CachedFile.DoesNotExist:
                 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:
                 indexdata = json.loads(zipfile.read('index.json').decode())
    @@ -111,7 +114,7 @@ def get_context_data(self, **kwargs):
             ctx = super().get_context_data(**kwargs)
             ctx['shredders'] = self.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
     
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.