CVE-2024-37306 (published 2024-06-13T15:15Z)

Computer Vision Annotation Tool (CVAT) is an interactive video and image annotation tool for computer vision. Starting in version 2.2.0 and prior to version 2.14.3, if an attacker can trick a logged-in CVAT user into visiting a malicious URL, they can initiate a dataset export or a backup from a project, task or job that the victim user has permission to export into a cloud storage that the victim user has access to. The name of the resulting file can be chosen by the attacker. This implies that the attacker can overwrite arbitrary files in any cloud storage that the victim can access and, if the attacker has read access to the cloud storage used in the attack, they can obtain media files, annotations, settings and other information from any projects, tasks or jobs that the victim has permission to export. Version 2.14.3 contains a fix for the issue. No known workarounds are available.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/cvat-ai/cvat
commit_details: repo_url: https://github.com/cvat-ai/cvat
author: Roman Donchenko
author_date: 2024-06-13 15:17:13+03:00
msg: Merge pull request from GHSA-jpf9-646h-4px7

* Mitigate a CSRF vulnerability in export and backup-related endpoints

While Django has built-in CSRF protection (which we use), it does not cover
GET requests, and AFAICS, there is no way to force it to do that.
Unfortunately, the many endpoints that initiate dataset exports and backups
do accept GET requests _and_ initiate side effects, making them susceptible.

The proper fix for this issue would be to redesign those endpoints to use
POST requests, but a) that's more complicated, and b) we should still keep
the old endpoints for backwards compatibility.

So apply a less proper fix, which is to disable session authentication for
the affected endpoints. It's a bit complex, because in some cases
(particularly when `action=download`) we _need_ session authentication to
work, because the UI redirects the user to such endpoints.

In addition, modify the handling logic for these endpoints in order to
ensure that when `action=download`, no side effects are triggered.
Previously, `action=download` would still queue an RQ job if none existed.

Even after this, `action=download` will still have two small side effects:

* An existing RQ job will be deleted if its results are out of date.
  I don't think this is a problem, because such a job cannot be used anyway.

* A completed RQ job will be deleted too. This is a problematic design,
  but I don't think an attacker can achieve anything by exploiting this. If
  an attacker maliciously redirects the user to an `action=download` URL,
  then they'll just download the export/backup as usual.

Some tests were making export requests incorrectly, so fix them.

* Add test for the CSRF workaround
repository: repo_url: https://github.com/cvat-ai/cvat
repo_name: cvat-ai/cvat
description: Annotate better with CVAT, the industry-leading data engine for machine learning. Used and trusted by teams at any scale, for data of any scale.
repo_language: TypeScript
owner: cvat-ai
file_changes: filename: 20240523_192101_roman_export_backup_csrf.md
change_type: ModificationType.ADD
diff: @@ -0,0 +1,4 @@
+### Security
+
+- Mitigated a CSRF vulnerability in backup and export-related endpoints
+  (<https://github.com/cvat-ai/cvat/security/advisories/GHSA-jpf9-646h-4px7>)

programming_language: Markdown
filename: test_rest_api_formats.py
change_type: ModificationType.MODIFY
diff: @@ -317,7 +317,7 @@ class _DbTestBase(ApiTestBase):
     def _download_file(self, url, data, user, file_name):
         response = self._get_request_with_data(url, data, user)
         self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
-        response = self._get_request_with_data(url, data, user)
+        response = self._get_request_with_data(url, {**data, "action": "download"}, user)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
         content = BytesIO(b"".join(response.streaming_content))
@@ -659,7 +659,6 @@ class TaskDumpUploadTest(_DbTestBase):
                     file_zip_name = osp.join(test_dir, f'{test_name}_{upload_type}.zip')
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self.assertEqual(osp.exists(file_zip_name), True)
@@ -700,7 +699,6 @@ class TaskDumpUploadTest(_DbTestBase):
 
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self.assertEqual(osp.exists(file_zip_name), True)
@@ -732,7 +730,6 @@ class TaskDumpUploadTest(_DbTestBase):
             file_zip_name = osp.join(test_dir, f'{test_name}.zip')
             data = {
                 "format": dump_format_name,
-                "action": "download",
             }
             self._download_file(url, data, self.admin, file_zip_name)
             self.assertEqual(osp.exists(file_zip_name), True)
@@ -756,7 +753,6 @@ class TaskDumpUploadTest(_DbTestBase):
 
             data = {
                 "format": dump_format_name,
-                "action": "download",
             }
             self._download_file(url, data, self.admin, file_zip_name)
             self.assertEqual(osp.exists(file_zip_name), True)
@@ -781,7 +777,6 @@ class TaskDumpUploadTest(_DbTestBase):
             file_zip_name = osp.join(test_dir, f'{test_name}.zip')
             data = {
                 "format": dump_format_name,
-                "action": "download",
             }
             self._download_file(url, data, self.admin, file_zip_name)
             self.assertEqual(osp.exists(file_zip_name), True)
@@ -817,7 +812,6 @@ class TaskDumpUploadTest(_DbTestBase):
                     file_zip_name = osp.join(test_dir, f'{test_name}.zip')
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self.assertEqual(osp.exists(file_zip_name), True)
@@ -905,7 +899,6 @@ class TaskDumpUploadTest(_DbTestBase):
                     file_zip_name = osp.join(test_dir, f'empty_{dump_format_name}.zip')
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self.assertEqual(osp.exists(file_zip_name), True)
@@ -983,7 +976,6 @@ class TaskDumpUploadTest(_DbTestBase):
                     file_zip_name = osp.join(test_dir, f'{test_name}_{dump_format_name}.zip')
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self.assertEqual(osp.exists(file_zip_name), True)
@@ -1027,7 +1019,6 @@ class TaskDumpUploadTest(_DbTestBase):
 
                     data = {
                         "format": dump_format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_zip_name)
                     self._check_downloaded_file(file_zip_name)
@@ -1100,7 +1091,6 @@ class TaskDumpUploadTest(_DbTestBase):
                         file_zip_name = osp.join(test_dir, f'{test_name}_{dump_format_name}.zip')
                         data = {
                             "format": dump_format_name,
-                            "action": "download",
                         }
                         self._download_file(url, data, self.admin, file_zip_name)
                         self._check_downloaded_file(file_zip_name)
@@ -1128,7 +1118,6 @@ class TaskDumpUploadTest(_DbTestBase):
         task_id = task["id"]
         data = {
             "format": "CVAT for video 1.1",
-            "action": "download",
         }
         annotation_name = "CVAT for video 1.1 polygon"
         self._create_annotations(task, annotation_name, "default")
@@ -1170,7 +1159,6 @@ class TaskDumpUploadTest(_DbTestBase):
                 url = self._generate_url_dump_tasks_annotations(task_id)
                 data = {
                     "format": dump_format_name,
-                    "action": "download",
                 }
                 with TestDir() as test_dir:
                     file_zip_name = osp.join(test_dir, f'{test_name}_{dump_format_name}.zip')
@@ -1207,7 +1195,6 @@ class TaskDumpUploadTest(_DbTestBase):
                 url = self._generate_url_dump_tasks_annotations(task_id)
                 data = {
                     "format": format_name,
-                    "action": "download",
                 }
                 with TestDir() as test_dir:
                     file_zip_name = osp.join(test_dir, f'{test_name}_{format_name}.zip')
@@ -1245,7 +1232,6 @@ class TaskDumpUploadTest(_DbTestBase):
                 url = self._generate_url_dump_tasks_annotations(task_id)
                 data = {
                     "format": dump_format_name,
-                    "action": "download",
                 }
                 with TestDir() as test_dir:
                     file_zip_name = osp.join(test_dir, f'{test_name}_{dump_format_name}.zip')

programming_language: Python
filename: backup.py
change_type: ModificationType.MODIFY
diff: @@ -952,14 +952,12 @@ def export(db_instance, request, queue_name):
         field_name=StorageType.TARGET
     )
 
+    last_instance_update_time = timezone.localtime(db_instance.updated_date)
+
     queue = django_rq.get_queue(queue_name)
     rq_id = f"export:{obj_type}.id{db_instance.pk}-by-{request.user}"
     rq_job = queue.fetch_job(rq_id)
 
-    last_instance_update_time = timezone.localtime(db_instance.updated_date)
-    timestamp = datetime.strftime(last_instance_update_time, "%Y_%m_%d_%H_%M_%S")
-    location = location_conf.get('location')
-
     if rq_job:
         rq_request = rq_job.meta.get('request', None)
         request_time = rq_request.get("timestamp", None) if rq_request else None
@@ -968,43 +966,54 @@ def export(db_instance, request, queue_name):
             # we have to enqueue dependent jobs after canceling one
             rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
             rq_job.delete()
-        else:
-            if rq_job.is_finished:
-                if location == Location.LOCAL:
-                    file_path = rq_job.return_value()
-
-                    if not file_path:
-                        return Response('A result for exporting job was not found for finished RQ job', status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
-                    elif not os.path.exists(file_path):
-                        return Response('The result file does not exist in export cache', status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-
-                    filename = filename or build_backup_file_name(
-                        class_name=obj_type,
-                        identifier=db_instance.name,
-                        timestamp=timestamp,
-                        extension=os.path.splitext(file_path)[1]
-                    )
-
-                    if action == "download":
-                        rq_job.delete()
-                        return sendfile(request, file_path, attachment=True,
-                            attachment_filename=filename)
-
-                    return Response(status=status.HTTP_201_CREATED)
-
-                elif location == Location.CLOUD_STORAGE:
-                    rq_job.delete()
-                    return Response(status=status.HTTP_200_OK)
-                else:
-                    raise NotImplementedError()
-            elif rq_job.is_failed:
-                exc_info = rq_job.meta.get('formatted_exception', str(rq_job.exc_info))
+            rq_job = None
+
+    timestamp = datetime.strftime(last_instance_update_time, "%Y_%m_%d_%H_%M_%S")
+    location = location_conf.get('location')
+
+    if action == "download":
+        if location != Location.LOCAL:
+            return Response('Action "download" is only supported for a local backup location', status=status.HTTP_400_BAD_REQUEST)
+
+        if not rq_job or not rq_job.is_finished:
+            return Response('Backup has not finished', status=status.HTTP_400_BAD_REQUEST)
+
+        file_path = rq_job.return_value()
+
+        if not file_path:
+            return Response('A result for exporting job was not found for finished RQ job', status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+        elif not os.path.exists(file_path):
+            return Response('The result file does not exist in export cache', status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+        filename = filename or build_backup_file_name(
+            class_name=obj_type,
+            identifier=db_instance.name,
+            timestamp=timestamp,
+            extension=os.path.splitext(file_path)[1]
+        )
+
+        rq_job.delete()
+        return sendfile(request, file_path, attachment=True,
+            attachment_filename=filename)
+
+    if rq_job:
+        if rq_job.is_finished:
+            if location == Location.LOCAL:
+                return Response(status=status.HTTP_201_CREATED)
+
+            elif location == Location.CLOUD_STORAGE:
                 rq_job.delete()
-                return Response(exc_info,
-                    status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+                return Response(status=status.HTTP_200_OK)
             else:
-                return Response(status=status.HTTP_202_ACCEPTED)
+                raise NotImplementedError()
+        elif rq_job.is_failed:
+            exc_info = rq_job.meta.get('formatted_exception', str(rq_job.exc_info))
+            rq_job.delete()
+            return Response(exc_info,
+                status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+        else:
+            return Response(status=status.HTTP_202_ACCEPTED)
 
     ttl = dm.views.PROJECT_CACHE_TTL.total_seconds()
     user_id = request.user.id

programming_language: Python
filename: mixins.py
change_type: ModificationType.MODIFY
diff: @@ -12,13 +12,15 @@ from dataclasses import asdict, dataclass
 from pathlib import Path
 from tempfile import NamedTemporaryFile
 from unittest import mock
-from typing import Optional, Callable, Dict, Any
+from typing import Optional, Callable, Dict, Any, Mapping
 
 import django_rq
 from attr.converters import to_bool
 from django.conf import settings
 from rest_framework import mixins, status
+from rest_framework.authentication import SessionAuthentication
 from rest_framework.response import Response
+from rest_framework.views import APIView
 
 from cvat.apps.engine.location import StorageType, get_location_configuration
 from cvat.apps.engine.log import ServerLogManager
@@ -498,3 +500,36 @@ class PartialUpdateModelMixin:
     def partial_update(self, request, *args, **kwargs):
         with mock.patch.object(self, 'update', new=self._update, create=True):
             return mixins.UpdateModelMixin.partial_update(self, request=request, *args, **kwargs)
+
+class CsrfWorkaroundMixin(APIView):
+    """
+    Disables session authentication for GET/HEAD requests
+    for which csrf_workaround_is_needed returns True.
+
+    csrf_workaround_is_needed is supposed to be overridden by each view.
+
+    This only exists to mitigate CSRF attacks on several known endpoints that
+    perform side effects in response to GET requests. Do not use this in
+    new code: instead, make sure that all endpoints with side effects use
+    a method other than GET/HEAD. Then Django's built-in CSRF protection
+    will cover them.
+    """
+
+    @staticmethod
+    def csrf_workaround_is_needed(query_params: Mapping[str, str]) -> bool:
+        return False
+
+    def get_authenticators(self):
+        authenticators = super().get_authenticators()
+
+        if (
+            self.request and
+            # Don't apply the workaround for requests from unit tests, since
+            # they can only use session authentication.
+            not getattr(self.request, "_dont_enforce_csrf_checks", False) and
+            self.request.method in ("GET", "HEAD") and
+            self.csrf_workaround_is_needed(self.request.GET)
+        ):
+            authenticators = [a for a in authenticators if not isinstance(a, SessionAuthentication)]
+
+        return authenticators

programming_language: Python
filename: test_rest_api_3D.py
change_type: ModificationType.MODIFY
diff: @@ -153,7 +153,7 @@ class _DbTestBase(ApiTestBase):
     def _download_file(self, url, data, user, file_name):
         response = self._get_request_with_data(url, data, user)
         self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
-        response = self._get_request_with_data(url, data, user)
+        response = self._get_request_with_data(url, {**data, "action": "download"}, user)
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
         content = BytesIO(b"".join(response.streaming_content))
@@ -583,7 +583,6 @@ class Task3DTest(_DbTestBase):
                     file_name = osp.join(test_dir, f"{format_name}.zip")
                     data = {
                         "format": format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_name)
                     self.assertTrue(osp.exists(file_name))
@@ -622,7 +621,6 @@ class Task3DTest(_DbTestBase):
                     file_name = osp.join(test_dir, f"{format_name}.zip")
                     data = {
                         "format": format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_name)
                     self.assertTrue(osp.exists(file_name))
@@ -663,7 +661,6 @@ class Task3DTest(_DbTestBase):
                     file_name = osp.join(test_dir, f"{format_name}.zip")
                     data = {
                         "format": format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_name)
 
@@ -687,7 +684,6 @@ class Task3DTest(_DbTestBase):
                     file_name = osp.join(test_dir, f"{format_name}.zip")
                     data = {
                         "format": format_name,
-                        "action": "download",
                     }
                     self._download_file(url, data, self.admin, file_name)
                     self.assertTrue(osp.exists(file_name))

programming_language: Python
filename: views.py
change_type: ModificationType.MODIFY
diff: @@ -7,7 +7,7 @@ import os
 import os.path as osp
 from PIL import Image
 from types import SimpleNamespace
-from typing import Optional, Any, Dict, List, cast, Callable
+from typing import Optional, Any, Dict, List, cast, Callable, Mapping
 import traceback
 import textwrap
 from copy import copy
@@ -76,7 +76,9 @@ from cvat.apps.engine.utils import (
     build_annotations_file_name,
 )
 from cvat.apps.engine import backup
-from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
+from cvat.apps.engine.mixins import (
+    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
+)
 from cvat.apps.engine.location import get_location_configuration, StorageType
 
 from . import models, task
@@ -206,6 +208,12 @@ class ServerViewSet(viewsets.ViewSet):
         }
         return Response(PluginsSerializer(data).data)
 
+def csrf_workaround_is_needed_for_backup(query_params: Mapping[str, str]) -> bool:
+    return query_params.get('action') != 'download'
+
+def csrf_workaround_is_needed_for_export(query_params: Mapping[str, str]) -> bool:
+    return 'format' in query_params and query_params.get('action') != 'download'
+
 @extend_schema(tags=['projects'])
 @extend_schema_view(
     list=extend_schema(
@@ -239,7 +247,7 @@ class ServerViewSet(viewsets.ViewSet):
 )
 class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
     mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
-    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
+    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
 ):
     queryset = models.Project.objects.select_related(
         'assignee', 'owner', 'target_storage', 'source_storage', 'annotation_guide',
@@ -347,7 +355,9 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '405': OpenApiResponse(description='Format is not available'),
         })
     @action(detail=True, methods=['GET', 'POST', 'OPTIONS'], serializer_class=None,
-        url_path=r'dataset/?$', parser_classes=_UPLOAD_PARSER_CLASSES)
+        url_path=r'dataset/?$', parser_classes=_UPLOAD_PARSER_CLASSES,
+        csrf_workaround_is_needed=lambda qp:
+            csrf_workaround_is_needed_for_export(qp) and qp.get("action") != "import_status")
     def dataset(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
 
@@ -479,7 +489,8 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '405': OpenApiResponse(description='Format is not available'),
         })
     @action(detail=True, methods=['GET'],
-        serializer_class=LabeledDataSerializer)
+        serializer_class=LabeledDataSerializer,
+        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
     def annotations(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
         return self.export_annotations(
@@ -511,7 +522,8 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '201': OpenApiResponse(description='Output backup file is ready for downloading'),
             '202': OpenApiResponse(description='Creating a backup file has been started'),
         })
-    @action(methods=['GET'], detail=True, url_path='backup')
+    @action(methods=['GET'], detail=True, url_path='backup',
+        csrf_workaround_is_needed=csrf_workaround_is_needed_for_backup)
     def export_backup(self, request, pk=None):
         return self.serialize(request, backup.export)
 
@@ -765,7 +777,7 @@ class JobDataGetter(DataChunkGetter):
 
 class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
     mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin,
-    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
+    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
 ):
     queryset = Task.objects.select_related(
         'data', 'assignee', 'owner',
@@ -880,7 +892,8 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '202': OpenApiResponse(description='Creating a backup file has been started'),
             '400': OpenApiResponse(description='Backup of a task without data is not allowed'),
         })
-    @action(methods=['GET'], detail=True, url_path='backup')
+    @action(methods=['GET'], detail=True, url_path='backup',
+        csrf_workaround_is_needed=csrf_workaround_is_needed_for_backup)
     def export_backup(self, request, pk=None):
         if self.get_object().data is None:
             return Response(
@@ -1330,7 +1343,8 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '204': OpenApiResponse(description='The annotation has been deleted'),
         })
     @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH', 'POST', 'OPTIONS'], url_path=r'annotations/?$',
-        serializer_class=None, parser_classes=_UPLOAD_PARSER_CLASSES)
+        serializer_class=None, parser_classes=_UPLOAD_PARSER_CLASSES,
+        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
     def annotations(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
         if request.method == 'GET':
@@ -1509,7 +1523,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '405': OpenApiResponse(description='Format is not available'),
         })
     @action(detail=True, methods=['GET'], serializer_class=None,
-        url_path='dataset')
+        url_path='dataset', csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
     def dataset_export(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
 
@@ -1584,7 +1598,7 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
 )
 class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateModelMixin,
     mixins.RetrieveModelMixin, PartialUpdateModelMixin, mixins.DestroyModelMixin,
-    UploadMixin, AnnotationMixin
+    UploadMixin, AnnotationMixin, CsrfWorkaroundMixin
 ):
     queryset = Job.objects.select_related('assignee', 'segment__task__data',
         'segment__task__project', 'segment__task__annotation_guide', 'segment__task__project__annotation_guide',
@@ -1776,7 +1790,8 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateMo
             '204': OpenApiResponse(description='The annotation has been deleted'),
         })
     @action(detail=True, methods=['GET', 'DELETE', 'PUT', 'PATCH', 'POST', 'OPTIONS'], url_path=r'annotations/?$',
-        serializer_class=LabeledDataSerializer, parser_classes=_UPLOAD_PARSER_CLASSES)
+        serializer_class=LabeledDataSerializer, parser_classes=_UPLOAD_PARSER_CLASSES,
+        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
     def annotations(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
         if request.method == 'GET':
@@ -1873,7 +1888,7 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateMo
             '405': OpenApiResponse(description='Format is not available'),
         })
     @action(detail=True, methods=['GET'], serializer_class=None,
-        url_path='dataset')
+        url_path='dataset', csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
     def dataset_export(self, request, pk):
         self._object = self.get_object() # force call of check_object_permissions()
 
@@ -2951,9 +2966,27 @@ def _export_annotations(
     elif not format_desc.ENABLED:
         return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED)
 
+    instance_update_time = timezone.localtime(db_instance.updated_date)
+    if isinstance(db_instance, Project):
+        tasks_update = list(map(lambda db_task: timezone.localtime(db_task.updated_date), db_instance.tasks.all()))
+        instance_update_time = max(tasks_update + [instance_update_time])
+
     queue = django_rq.get_queue(settings.CVAT_QUEUES.EXPORT_DATA.value)
     rq_job = queue.fetch_job(rq_id)
 
+    if rq_job:
+        rq_request = rq_job.meta.get('request', None)
+        request_time = rq_request.get('timestamp', None) if rq_request else None
+        if request_time is None or request_time < instance_update_time:
+            # The result is outdated, need to restart the export.
+            # Cancel the current job.
+            # The new attempt will be made after the last existing job.
+            # In the case the server is configured with ONE_RUNNING_JOB_IN_QUEUE_PER_USER
+            # we have to enqueue dependent jobs after canceling one.
+            rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
+            rq_job.delete()
+            rq_job = None
+
     location = location_conf.get('location')
     if location not in Location.list():
         raise serializers.ValidationError(
@@ -2961,100 +2994,102 @@ def _export_annotations(
         )
 
     cache_ttl = dm.views.get_export_cache_ttl(db_instance)
-    instance_update_time = timezone.localtime(db_instance.updated_date)
-    if isinstance(db_instance, Project):
-        tasks_update = list(map(lambda db_task: timezone.localtime(db_task.updated_date), db_instance.tasks.all()))
-        instance_update_time = max(tasks_update + [instance_update_time])
 
     instance_timestamp = datetime.strftime(instance_update_time, "%Y_%m_%d_%H_%M_%S")
     is_annotation_file = rq_id.startswith('export:annotations')
 
+    REQUEST_TIMEOUT = 60
+
+    if action == "download":
+        if location != Location.LOCAL:
+            return Response('Action "download" is only supported for a local export location',
+                status=status.HTTP_400_BAD_REQUEST)
+
+        if not rq_job or not rq_job.is_finished:
+            return Response('Export has not finished', status=status.HTTP_400_BAD_REQUEST)
+
+        file_path = rq_job.return_value()
+
+        if not file_path:
+            return Response(
+                'A result for exporting job was not found for finished RQ job',
+                status=status.HTTP_500_INTERNAL_SERVER_ERROR
+            )
+
+        with dm.util.get_export_cache_lock(file_path, ttl=REQUEST_TIMEOUT):
+            if not osp.exists(file_path):
+                return Response(
+                    "The exported file has expired, please retry exporting",
+                    status=status.HTTP_404_NOT_FOUND
+                )
+
+            filename = filename or \
+                build_annotations_file_name(
+                    class_name=db_instance.__class__.__name__,
+                    identifier=db_instance.name if isinstance(db_instance, (Task, Project)) else db_instance.id,
+                    timestamp=instance_timestamp,
+                    format_name=format_name,
+                    is_annotation_file=is_annotation_file,
+                    extension=osp.splitext(file_path)[1]
+                )
+
+            rq_job.delete()
+            return sendfile(request, file_path, attachment=True, attachment_filename=filename)
+
+
     if rq_job:
-        rq_request = rq_job.meta.get('request', None)
-        request_time = rq_request.get('timestamp', None) if rq_request else None
-        if request_time is None or request_time < instance_update_time:
-            # The result is outdated, need to restart the export.
-            # Cancel the current job.
+        if rq_job.is_finished:
+            if location == Location.CLOUD_STORAGE:
+                rq_job.delete()
+                return Response(status=status.HTTP_200_OK)
+
+            elif location == Location.LOCAL:
+                file_path = rq_job.return_value()
+
+                if not file_path:
+                    return Response(
+                        'A result for exporting job was not found for finished RQ job',
+                        status=status.HTTP_500_INTERNAL_SERVER_ERROR
+                    )
+
+                with dm.util.get_export_cache_lock(file_path, ttl=REQUEST_TIMEOUT):
+                    if osp.exists(file_path):
+                        # Update last update time to prolong the export lifetime
+                        # as the last access time is not available on every filesystem
+                        os.utime(file_path, None)
+
+                        return Response(status=status.HTTP_201_CREATED)
+                    else:
+                        # Cancel and reenqueue the job.
+                        # The new attempt will be made after the last existing job.
+                        # In the case the server is configured with ONE_RUNNING_JOB_IN_QUEUE_PER_USER
+                        # we have to enqueue dependent jobs after canceling one.
+                        rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
+                        rq_job.delete()
+            else:
+                raise NotImplementedError(f"Export to {location} location is not implemented yet")
+        elif rq_job.is_failed:
+            exc_info = rq_job.meta.get('formatted_exception', str(rq_job.exc_info))
+            rq_job.delete()
+            return Response(exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+        elif rq_job.is_deferred and rq_id not in queue.deferred_job_registry.get_job_ids():
+            # Sometimes jobs can depend on outdated jobs in the deferred jobs registry.
+            # They can be fetched by their specific ids, but are not listed by get_job_ids().
+            # Supposedly, this can happen because of the server restarts
+            # (potentially, because the redis used for the queue is inmemory).
+            # Another potential reason is canceling without enqueueing dependents.
+            # Such dependencies are never removed or finished,
+            # as there is no TTL for deferred jobs,
+            # so the current job can be blocked indefinitely.
+
+            # Cancel the current job and then reenqueue it, considering the current situation.
             # The new attempt will be made after the last existing job.
             # In the case the server is configured with ONE_RUNNING_JOB_IN_QUEUE_PER_USER
             # we have to enqueue dependent jobs after canceling one.
             rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
             rq_job.delete()
         else:
-            if rq_job.is_finished:
-                if location == Location.CLOUD_STORAGE:
-                    rq_job.delete()
-                    return Response(status=status.HTTP_200_OK)
-
-                elif location == Location.LOCAL:
-                    file_path = rq_job.return_value()
-
-                    if not file_path:
-                        return Response(
-                            'A result for exporting job was not found for finished RQ job',
-                            status=status.HTTP_500_INTERNAL_SERVER_ERROR
-                        )
-
-                    with dm.util.get_export_cache_lock(
-                        file_path, ttl=60, # request timeout
-                    ):
-                        if action == "download":
-                            if not osp.exists(file_path):
-                                return Response(
-                                    "The exported file has expired, please retry exporting",
-                                    status=status.HTTP_404_NOT_FOUND
-                                )
-
-                            filename = filename or \
-                                build_annotations_file_name(
-                                    class_name=db_instance.__class__.__name__,
-                                    identifier=db_instance.name if isinstance(db_instance, (Task, Project)) else db_instance.id,
-                                    timestamp=instance_timestamp,
-                                    format_name=format_name,
-                                    is_annotation_file=is_annotation_file,
-                                    extension=osp.splitext(file_path)[1]
-                                )
-
-                            rq_job.delete()
-                            return sendfile(request, file_path, attachment=True, attachment_filename=filename)
-                        else:
-                            if osp.exists(file_path):
-                                # Update last update time to prolong the export lifetime
-                                # as the last access time is not available on every filesystem
-                                os.utime(file_path, None)
-
-                                return Response(status=status.HTTP_201_CREATED)
-                            else:
-                                # Cancel and reenqueue the job.
-                                # The new attempt will be made after the last existing job.
-                                # In the case the server is configured with ONE_RUNNING_JOB_IN_QUEUE_PER_USER
-                                # we have to enqueue dependent jobs after canceling one.
-                                rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
-                                rq_job.delete()
-                else:
-                    raise NotImplementedError(f"Export to {location} location is not implemented yet")
-            elif rq_job.is_failed:
-                exc_info = rq_job.meta.get('formatted_exception', str(rq_job.exc_info))
-                rq_job.delete()
-                return Response(exc_info, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
-            elif rq_job.is_deferred and rq_id not in queue.deferred_job_registry.get_job_ids():
-                # Sometimes jobs can depend on outdated jobs in the deferred jobs registry.
-                # They can be fetched by their specific ids, but are not listed by get_job_ids().
-                # Supposedly, this can happen because of the server restarts
-                # (potentially, because the redis used for the queue is inmemory).
-                # Another potential reason is canceling without enqueueing dependents.
-                # Such dependencies are never removed or finished,
-                # as there is no TTL for deferred jobs,
-                # so the current job can be blocked indefinitely.
-
-                # Cancel the current job and then reenqueue it, considering the current situation.
-                # The new attempt will be made after the last existing job.
-                # In the case the server is configured with ONE_RUNNING_JOB_IN_QUEUE_PER_USER
-                # we have to enqueue dependent jobs after canceling one.
-                rq_job.cancel(enqueue_dependents=settings.ONE_RUNNING_JOB_IN_QUEUE_PER_USER)
-                rq_job.delete()
-            else:
-                return Response(status=status.HTTP_202_ACCEPTED)
+            return Response(status=status.HTTP_202_ACCEPTED)
     try:
         if request.scheme:
             server_address = request.scheme + '://'

programming_language: Python
filename: test_csrf_workaround.py
change_type: ModificationType.ADD
diff: @@ -0,0 +1,57 @@
+# Copyright (C) 2024 CVAT.ai Corporation
+#
+# SPDX-License-Identifier: MIT
+
+import pytest
+from cvat_sdk.api_client import ApiClient, Configuration, models
+from cvat_sdk.api_client.exceptions import UnauthorizedException
+
+from shared.utils.config import BASE_URL, USER_PASS
+
+
+class TestCsrfWorkaround:
+    """
+    Test that session authentication does not work with endpoints
+    that respond to GET requests and produce side effects.
+    """
+
+    @pytest.fixture(autouse=True)
+    def setup(self, admin_user):
+        self.client = ApiClient(Configuration(host=BASE_URL))
+
+        # Don't store the result - we only want the session cookie.
+        self.client.auth_api.create_login(
+            models.LoginSerializerExRequest(username=admin_user, password=USER_PASS)
+        )
+
+        # Test that session authentication works in general.
+        user, _ = self.client.users_api.retrieve_self()
+        assert user.username == admin_user
+
+    def test_project(self, projects):
+        actual_project, _ = self.client.projects_api.retrieve(next(iter(projects))["id"])
+
+        with pytest.raises(UnauthorizedException):
+            self.client.projects_api.retrieve_backup(actual_project.id)
+        with pytest.raises(UnauthorizedException):
+            self.client.projects_api.retrieve_annotations(id=actual_project.id, format="COCO 1.0")
+        with pytest.raises(UnauthorizedException):
+            self.client.projects_api.retrieve_dataset(id=actual_project.id, format="COCO 1.0")
+
+    def test_task(self, tasks):
+        actual_task, _ = self.client.tasks_api.retrieve(next(iter(tasks))["id"])
+
+        with pytest.raises(UnauthorizedException):
+            self.client.tasks_api.retrieve_backup(actual_task.id)
+        with pytest.raises(UnauthorizedException):
+            self.client.tasks_api.retrieve_annotations(id=actual_task.id, format="COCO 1.0")
+        with pytest.raises(UnauthorizedException):
+            self.client.tasks_api.retrieve_dataset(id=actual_task.id, format="COCO 1.0")
+
+    def test_job(self, jobs):
+        actual_job, _ = self.client.jobs_api.retrieve(next(iter(jobs))["id"])
+
+        with pytest.raises(UnauthorizedException):
+            self.client.jobs_api.retrieve_annotations(id=actual_job.id, format="COCO 1.0")
+        with pytest.raises(UnauthorizedException):
+            self.client.jobs_api.retrieve_dataset(id=actual_job.id, format="COCO 1.0")

programming_language: Python
filename: resource_import_export.py
change_type: ModificationType.MODIFY
diff: @@ -97,8 +97,8 @@ class _CloudStorageResourceTest(ABC):
         status = response.status_code
 
         while status != _expect_status:
-            assert status in (HTTPStatus.CREATED, HTTPStatus.ACCEPTED)
-            response = get_method(user, f"{obj}/{obj_id}/{resource}", action="download", **kwargs)
+            assert status == HTTPStatus.ACCEPTED
+            response = get_method(user, f"{obj}/{obj_id}/{resource}", **kwargs)
             status = response.status_code
 
     def _import_annotations_from_cloud_storage(

programming_language: Python