CVE-2024-37306: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-06-13T15:15Z
Repository: https://github.com/cvat-ai/cvat
Language: Python
Files changed: changelog.d/20240523_192101_roman_export_backup_csrf.md, cvat/apps/dataset_manager/tests/test_rest_api_formats.py, cvat/apps/engine/backup.py, cvat/apps/engine/mixins.py, cvat/apps/engine/tests/test_rest_api_3D.py, cvat/apps/engine/views.py, tests/python/rest_api/test_csrf_workaround.py, tests/python/shared/utils/resource_import_export.py
Commit date: 2024-06-13 15:17:13 +0300
Commit message: 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
Diff: diff --git a/changelog.d/20240523_192101_roman_export_backup_csrf.md b/changelog.d/20240523_192101_roman_export_backup_csrf.md
new file mode 100644
index 000000000..3143c1a76
--- /dev/null
+++ b/changelog.d/20240523_192101_roman_export_backup_csrf.md
@@ -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>)
diff --git a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py
index b8be48b17..8143ef344 100644
--- a/cvat/apps/dataset_manager/tests/test_rest_api_formats.py
+++ b/cvat/apps/dataset_manager/tests/test_rest_api_formats.py
@@ -315,11 +315,11 @@ class _DbTestBase(ApiTestBase):
         self.assertEqual(response.status_code, status.HTTP_200_OK)
 
     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))
         with open(file_name, "wb") as f:
             f.write(content.getvalue())
@@ -657,11 +657,10 @@ class TaskDumpUploadTest(_DbTestBase):
                         self._create_annotations_in_job(task, job_id, "CVAT for images 1.1 different types", "random")
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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)
                     self._remove_annotations(url, self.admin)
                     if upload_type == "task":
@@ -698,11 +697,10 @@ class TaskDumpUploadTest(_DbTestBase):
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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)
                     self._remove_annotations(url, self.admin)
                     if upload_type == "task":
@@ -730,11 +728,10 @@ class TaskDumpUploadTest(_DbTestBase):
         with TestDir() as test_dir:
             url = self._generate_url_dump_tasks_annotations(task_id)
             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)
 
             with open(file_zip_name, 'rb') as binary_file:
@@ -754,11 +751,10 @@ class TaskDumpUploadTest(_DbTestBase):
             url = self._generate_url_dump_tasks_annotations(task_id)
             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)
 
             with open(file_zip_name, 'rb') as binary_file:
@@ -779,11 +775,10 @@ class TaskDumpUploadTest(_DbTestBase):
         with TestDir() as test_dir:
             url = self._generate_url_dump_tasks_annotations(task_id)
             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)
 
             # remove annotations
@@ -815,11 +810,10 @@ class TaskDumpUploadTest(_DbTestBase):
                     url = self._generate_url_dump_tasks_annotations(task_id)
 
                     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)
 
                     # remove annotations
@@ -903,11 +897,10 @@ class TaskDumpUploadTest(_DbTestBase):
                     url = self._generate_url_dump_tasks_annotations(task_id)
 
                     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)
 
             for upload_format in upload_formats:
@@ -981,11 +974,10 @@ class TaskDumpUploadTest(_DbTestBase):
                     url = self._generate_url_dump_tasks_annotations(task_id)
 
                     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)
 
                     self._remove_annotations(url, self.admin)
@@ -1025,11 +1017,10 @@ class TaskDumpUploadTest(_DbTestBase):
                 with TestDir() as test_dir:
                     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)
 
                     # remove annotations
@@ -1098,11 +1089,10 @@ class TaskDumpUploadTest(_DbTestBase):
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     with TestDir() as test_dir:
                         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)
 
                         # remove annotations
@@ -1126,11 +1116,10 @@ class TaskDumpUploadTest(_DbTestBase):
         images = self._generate_task_images(10)
         task = self._create_task(tasks["main"], images)
         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")
         annotation_points = annotations[annotation_name]["tracks"][0]["shapes"][0]['points']
 
@@ -1168,11 +1157,10 @@ class TaskDumpUploadTest(_DbTestBase):
 
                 # dump annotations
                 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')
                     self._download_file(url, data, self.admin, file_zip_name)
                     self._check_downloaded_file(file_zip_name)
@@ -1205,11 +1193,10 @@ class TaskDumpUploadTest(_DbTestBase):
 
                 # dump annotations
                 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')
                     self._download_file(url, data, self.admin, file_zip_name)
                     self._check_downloaded_file(file_zip_name)
@@ -1243,11 +1230,10 @@ class TaskDumpUploadTest(_DbTestBase):
 
                 # dump annotations
                 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')
                     self._download_file(url, data, self.admin, file_zip_name)
                     self._check_downloaded_file(file_zip_name)
diff --git a/cvat/apps/engine/backup.py b/cvat/apps/engine/backup.py
index a3a63c082..1a316a14c 100644
--- a/cvat/apps/engine/backup.py
+++ b/cvat/apps/engine/backup.py
@@ -950,63 +950,72 @@ def export(db_instance, request, queue_name):
         obj=obj,
         use_settings=use_settings,
         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
         if request_time is None or request_time < last_instance_update_time:
             # in 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.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
 
     func = _create_backup if location == Location.LOCAL else export_resource_to_cloud_storage
diff --git a/cvat/apps/engine/mixins.py b/cvat/apps/engine/mixins.py
index 3b43f80cd..751a19864 100644
--- a/cvat/apps/engine/mixins.py
+++ b/cvat/apps/engine/mixins.py
@@ -10,17 +10,19 @@ import os.path
 import uuid
 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
 from cvat.apps.engine.models import Location
 from cvat.apps.engine.serializers import DataSerializer
@@ -496,5 +498,38 @@ class PartialUpdateModelMixin:
         mixins.UpdateModelMixin.perform_update(self, serializer=serializer)
 
     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
diff --git a/cvat/apps/engine/tests/test_rest_api_3D.py b/cvat/apps/engine/tests/test_rest_api_3D.py
index fc8f8e95c..a67a79109 100644
--- a/cvat/apps/engine/tests/test_rest_api_3D.py
+++ b/cvat/apps/engine/tests/test_rest_api_3D.py
@@ -151,11 +151,11 @@ class _DbTestBase(ApiTestBase):
         return response
 
     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))
         with open(file_name, "wb") as f:
             f.write(content.getvalue())
@@ -581,11 +581,10 @@ class Task3DTest(_DbTestBase):
                     task_ann_prev.init_from_db()
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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))
 
                     self._remove_annotations(task_id)
@@ -620,11 +619,10 @@ class Task3DTest(_DbTestBase):
                 with self.subTest(format=f"{format_name}"):
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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))
 
                     file_name = osp.join(test_dir, f"{format_name}.zip")
@@ -661,11 +659,10 @@ class Task3DTest(_DbTestBase):
                     task_ann_prev.init_from_db()
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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._remove_annotations(task_id)
 
@@ -685,11 +682,10 @@ class Task3DTest(_DbTestBase):
                 with self.subTest(format=f"{format_name}_dump"):
                     url = self._generate_url_dump_tasks_annotations(task_id)
                     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))
 
                 self._remove_annotations(task_id)
diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py
index 2ef73e29c..b5e4d2cc7 100644
--- a/cvat/apps/engine/views.py
+++ b/cvat/apps/engine/views.py
@@ -5,11 +5,11 @@
 
 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
 from datetime import datetime
 from tempfile import NamedTemporaryFile
@@ -74,11 +74,13 @@ from cvat.apps.engine.utils import (
     parse_exception_message, get_rq_job_meta, get_import_rq_id,
     import_resource_with_clean_up_after, sendfile, define_dependent_job, get_rq_lock_by_user,
     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
 from .log import ServerLogManager
 from cvat.apps.iam.filters import ORGANIZATION_OPEN_API_PARAMETERS
@@ -204,10 +206,16 @@ class ServerViewSet(viewsets.ViewSet):
             'MODELS': to_bool(os.environ.get("CVAT_SERVERLESS", False)),
             'PREDICT': False, # FIXME: it is unused anymore (for UI only)
         }
         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(
         summary='List projects',
         responses={
@@ -237,11 +245,11 @@ class ServerViewSet(viewsets.ViewSet):
             '200': ProjectReadSerializer, # check ProjectWriteSerializer.to_representation
         })
 )
 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',
     ).prefetch_related('tasks').all()
 
@@ -345,11 +353,13 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '202': OpenApiResponse(RqIdSerializer, description='Importing has been started'),
             '400': OpenApiResponse(description='Failed to import dataset'),
             '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()
 
         if request.method in {'POST', 'OPTIONS'}:
             return self.import_annotations(
@@ -477,11 +487,12 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '202': OpenApiResponse(description='Dump of annotations has been started'),
             '401': OpenApiResponse(description='Format is not specified'),
             '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(
             request=request,
             db_obj=self._object,
@@ -509,11 +520,12 @@ class ProjectViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
         responses={
             '200': OpenApiResponse(description='Download of file started'),
             '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)
 
     @extend_schema(methods=['POST'], summary='Recreate a project from a backup',
         description=textwrap.dedent("""
@@ -763,11 +775,11 @@ 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',
         'target_storage', 'source_storage', 'annotation_guide',
     ).prefetch_related(
@@ -878,11 +890,12 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '200': OpenApiResponse(description='Download of file started'),
             '201': OpenApiResponse(description='Output backup file is ready for downloading'),
             '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(
                 data='Backup of a task without data is not allowed',
                 status=status.HTTP_400_BAD_REQUEST
@@ -1328,11 +1341,12 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
     @extend_schema(methods=['DELETE'], summary='Delete task annotations',
         responses={
             '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':
             if self._object.data:
                 return self.export_annotations(
@@ -1507,11 +1521,11 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '202': OpenApiResponse(description='Exporting has been started'),
             '400': OpenApiResponse(description='Exporting without data is not allowed'),
             '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()
 
         if self._object.data:
             return self.export_annotations(
@@ -1582,11 +1596,11 @@ class TaskViewSet(viewsets.GenericViewSet, mixins.ListModelMixin,
             '204': OpenApiResponse(description='The job has been deleted'),
         }),
 )
 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',
     ).annotate(
         Count('issues', distinct=True),
@@ -1774,11 +1788,12 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateMo
     @extend_schema(methods=['DELETE'], summary='Delete job annotations',
         responses={
             '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':
             return self.export_annotations(
                 request=request,
@@ -1871,11 +1886,11 @@ class JobViewSet(viewsets.GenericViewSet, mixins.ListModelMixin, mixins.CreateMo
             '201': OpenApiResponse(description='Output file is ready for downloading'),
             '202': OpenApiResponse(description='Exporting has been started'),
             '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()
 
         return self.export_annotations(
             request=request,
@@ -2949,114 +2964,134 @@ def _export_annotations(
         raise serializers.ValidationError(
             "Unknown format specified for the request")
     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(
             f"Unexpected location {location} specified for the request"
         )
 
     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 + '://'
         server_address += request.get_host()
     except Exception:
diff --git a/tests/python/rest_api/test_csrf_workaround.py b/tests/python/rest_api/test_csrf_workaround.py
new file mode 100644
index 000000000..667263a1c
--- /dev/null
+++ b/tests/python/rest_api/test_csrf_workaround.py
@@ -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")
diff --git a/tests/python/shared/utils/resource_import_export.py b/tests/python/shared/utils/resource_import_export.py
index 9ee8bdec2..82997749e 100644
--- a/tests/python/shared/utils/resource_import_export.py
+++ b/tests/python/shared/utils/resource_import_export.py
@@ -95,12 +95,12 @@ class _CloudStorageResourceTest(ABC):
 
         response = get_method(user, f"{obj}/{obj_id}/{resource}", **kwargs)
         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(
         self,
         obj_id,

Vulnerable code:         response = self._get_request_with_data(url, data, user)
                        "action": "download",
                        "action": "download",
                "action": "download",
                "action": "download",
                "action": "download",
                        "action": "download",
                        "action": "download",
                        "action": "download",
                        "action": "download",
                            "action": "download",
            "action": "download",
                    "action": "download",
                    "action": "download",
                    "action": "download",
    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')

        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))
                return Response(exc_info,
                    status=status.HTTP_500_INTERNAL_SERVER_ERROR)
                return Response(status=status.HTTP_202_ACCEPTED)
from typing import Optional, Callable, Dict, Any
        response = self._get_request_with_data(url, data, user)
                        "action": "download",
                        "action": "download",
                        "action": "download",
                        "action": "download",
from typing import Optional, Any, Dict, List, cast, Callable
from cvat.apps.engine.mixins import PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
        url_path=r'dataset/?$', parser_classes=_UPLOAD_PARSER_CLASSES)
        serializer_class=LabeledDataSerializer)
    @action(methods=['GET'], detail=True, url_path='backup')
    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin
    @action(methods=['GET'], detail=True, url_path='backup')
        serializer_class=None, parser_classes=_UPLOAD_PARSER_CLASSES)
        url_path='dataset')
    UploadMixin, AnnotationMixin
        serializer_class=LabeledDataSerializer, parser_classes=_UPLOAD_PARSER_CLASSES)
        url_path='dataset')
    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])
        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=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)
            assert status in (HTTPStatus.CREATED, HTTPStatus.ACCEPTED)
            response = get_method(user, f"{obj}/{obj_id}/{resource}", action="download", **kwargs)
Fixed code: ### Security

- Mitigated a CSRF vulnerability in backup and export-related endpoints
  (<https://github.com/cvat-ai/cvat/security/advisories/GHSA-jpf9-646h-4px7>)
        response = self._get_request_with_data(url, {**data, "action": "download"}, user)
    last_instance_update_time = timezone.localtime(db_instance.updated_date)

            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:
                return Response(status=status.HTTP_200_OK)
                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)
from typing import Optional, Callable, Dict, Any, Mapping
from rest_framework.authentication import SessionAuthentication
from rest_framework.views import APIView

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
        response = self._get_request_with_data(url, {**data, "action": "download"}, user)
from typing import Optional, Any, Dict, List, cast, Callable, Mapping
from cvat.apps.engine.mixins import (
    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
)
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'

    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
        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")
        serializer_class=LabeledDataSerializer,
        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
    @action(methods=['GET'], detail=True, url_path='backup',
        csrf_workaround_is_needed=csrf_workaround_is_needed_for_backup)
    PartialUpdateModelMixin, UploadMixin, AnnotationMixin, SerializeMixin, CsrfWorkaroundMixin
    @action(methods=['GET'], detail=True, url_path='backup',
        csrf_workaround_is_needed=csrf_workaround_is_needed_for_backup)
        serializer_class=None, parser_classes=_UPLOAD_PARSER_CLASSES,
        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
        url_path='dataset', csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
    UploadMixin, AnnotationMixin, CsrfWorkaroundMixin
        serializer_class=LabeledDataSerializer, parser_classes=_UPLOAD_PARSER_CLASSES,
        csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
        url_path='dataset', csrf_workaround_is_needed=csrf_workaround_is_needed_for_export)
    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])

    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

    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.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.
            return Response(status=status.HTTP_202_ACCEPTED)
# 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")
            assert status == HTTPStatus.ACCEPTED
            response = get_method(user, f"{obj}/{obj_id}/{resource}", **kwargs)