From 3901e57cab8cc5e401b14d8ec8e6df759daadd84 Mon Sep 17 00:00:00 2001 From: Manuel Raynaud Date: Wed, 7 Jan 2026 09:52:29 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20an=20allowed=20file?= =?UTF-8?q?=20mimetype=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allong the allowed extension list we added an allowed mimetype list. We combine both to ensure that the user is allowed to make the upload. If the mimetype is not allowed, then the file and the item are deleted. --- docs/env.md | 1 + src/backend/core/api/serializers.py | 6 +- src/backend/core/api/viewsets.py | 25 ++- .../tests/items/test_api_item_upload_ended.py | 65 +++++++ src/backend/drive/settings.py | 160 ++++++++++++++++++ 5 files changed, 253 insertions(+), 4 deletions(-) diff --git a/docs/env.md b/docs/env.md index 0a878302..b1291cf9 100644 --- a/docs/env.md +++ b/docs/env.md @@ -45,6 +45,7 @@ This document lists all configurable environment variables for the Drive applica | `FEATURES_ALPHA` | Enable alpha features | `False` | | `FEATURES_INDEXED_SEARCH` | Enable the search of indexed files through the API | `True` | | `FILE_EXTENSIONS_ALLOWED` | List of file extension allowed to be uploaded | See in the settings.py file | +| `FILE_MIMETYPE_ALLOWED` | List of file mimetype allowed to be uploaded | See in the setings.py file | | `FRONTEND_THEME` | Frontend theme configuration | `None` | | `FRONTEND_FEEDBACK_BUTTON_SHOW` | Show feedback button | `False` | | `FRONTEND_FEEDBACK_BUTTON_IDLE` | Make feedback button idle (e.g. to bind to external library) | `False` | diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index c5df90fb..a3bb5dd5 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -436,7 +436,11 @@ class CreateItemSerializer(ItemSerializer): if settings.RESTRICT_UPLOAD_FILE_TYPE: _root, extension = splitext(attrs["filename"]) if extension not in settings.FILE_EXTENSIONS_ALLOWED: - logger.info("create_item: file extension not allowed %s for filename %s", extension, attrs["filename"]) + logger.info( + "create_item: file extension not allowed %s for filename %s", + extension, + attrs["filename"], + ) raise serializers.ValidationError( {"filename": _("This file extension is not allowed.")}, code="item_create_file_extension_not_allowed", diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index d607a0b7..fd357256 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -682,9 +682,7 @@ class ItemViewSet( entitlements_backend = get_entitlements_backend() can_upload = entitlements_backend.can_upload(self.request.user) if not can_upload["result"]: - item.soft_delete() - item.hard_delete() - process_item_deletion.delay(item.id) + self._complete_item_deletion(item) raise drf.exceptions.PermissionDenied( detail=can_upload.get( "message", "You do not have permission to upload files." @@ -713,6 +711,21 @@ class ItemViewSet( # Use improved MIME type detection combining magic bytes and file extension mimetype = utils.detect_mimetype(file_head, filename=item.filename) + if ( + settings.RESTRICT_UPLOAD_FILE_TYPE + and mimetype not in settings.FILE_MIMETYPE_ALLOWED + ): + self._complete_item_deletion(item) + logger.info( + "upload_ended: mimetype not allowed %s for filename %s", + mimetype, + item.filename, + ) + raise drf.exceptions.ValidationError( + detail="The file type is not allowed.", + code="file_type_not_allowed", + ) + item.upload_state = models.ItemUploadStateChoices.ANALYZING item.mimetype = mimetype item.size = file_size @@ -737,6 +750,12 @@ class ItemViewSet( return drf_response.Response(serializer.data, status=status.HTTP_200_OK) + def _complete_item_deletion(self, item): + """Completely delete an item.""" + item.soft_delete() + item.hard_delete() + process_item_deletion.delay(item.id) + @drf.decorators.action( detail=False, methods=["get"], diff --git a/src/backend/core/tests/items/test_api_item_upload_ended.py b/src/backend/core/tests/items/test_api_item_upload_ended.py index 2ebffea3..df43aec3 100644 --- a/src/backend/core/tests/items/test_api_item_upload_ended.py +++ b/src/backend/core/tests/items/test_api_item_upload_ended.py @@ -1,5 +1,6 @@ """Test related to item upload ended API.""" +import logging from io import BytesIO from unittest import mock @@ -237,3 +238,67 @@ def test_api_item_upload_ended_entitlements_backend_returns_falsy_custom_message } assert not models.Item.objects.filter(id=item.id).exists() + + +def test_api_item_upload_ended_mimetype_not_allowed(settings, caplog): + """ + Test that the API returns a 400 when the mimetype is not allowed. + Item should be deleted and the file should be deleted from the storage. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = True + settings.FILE_MIMETYPE_ALLOWED = ["application/pdf"] + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + item = factories.ItemFactory(type=ItemTypeChoices.FILE, filename="my_file.txt") + factories.UserItemAccessFactory(item=item, user=user, role="owner") + + default_storage.save( + item.file_key, + BytesIO(b"my prose"), + ) + + with caplog.at_level(logging.INFO): + response = client.post(f"/api/v1.0/items/{item.id!s}/upload-ended/") + + assert response.status_code == 400 + assert ( + "upload_ended: mimetype not allowed text/plain for filename my_file.txt" + in caplog.text + ) + + assert not models.Item.objects.filter(id=item.id).exists() + assert not default_storage.exists(item.file_key) + + +def test_api_item_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings): + """ + Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = False + settings.FILE_MIMETYPE_ALLOWED = ["application/pdf"] + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + + item = factories.ItemFactory(type=ItemTypeChoices.FILE, filename="my_file.txt") + factories.UserItemAccessFactory(item=item, user=user, role="owner") + + default_storage.save( + item.file_key, + BytesIO(b"my prose"), + ) + + with mock.patch.object(malware_detection, "analyse_file") as mock_analyse_file: + response = client.post(f"/api/v1.0/items/{item.id!s}/upload-ended/") + + mock_analyse_file.assert_called_once_with(item.file_key, item_id=item.id) + assert response.status_code == 200 + + item.refresh_from_db() + assert item.upload_state == ItemUploadStateChoices.ANALYZING + assert item.mimetype == "text/plain" + assert item.size == 8 + + assert response.json()["mimetype"] == "text/plain" diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index a811d4e6..2e552202 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -552,6 +552,166 @@ class Base(Configuration): environ_prefix=None, ) + FILE_MIMETYPE_ALLOWED = values.ListValue( + [ + "application/epub+zip", + "application/gml+xml", + "application/gpx+xml", + "application/gzip", + "application/json", + "application/msword", + "application/octet-stream", + "application/pdf", + "application/pgp-signature", + "application/pkcs10", + "application/pkcs7-signature", + "application/pkix-cert", + "application/postscript", + "application/rtf", + "application/sql", + "application/vnd.android.package-archive", + "application/vnd.businessobjects", + "application/vnd.chemdraw+xml", + "application/vnd.dna", + "application/vnd.dynageo", + "application/vnd.google-earth.kml+xml", + "application/vnd.google-earth.kmz", + "application/vnd.koan", + "application/vnd.mcd", + "application/vnd.ms-excel", + "application/vnd.ms-excel.sheet.binary.macroenabled.12", + "application/vnd.ms-excel.sheet.macroenabled.12", + "application/vnd.ms-excel.template.macroenabled.12", + "application/vnd.ms-fontobject", + "application/vnd.ms-outlook", + "application/vnd.ms-pki.stl", + "application/vnd.ms-powerpoint", + "application/vnd.ms-powerpoint.presentation.macroenabled.12", + "application/vnd.ms-powerpoint.slideshow.macroenabled.12", + "application/vnd.ms-powerpoint.template.macroenabled.12", + "application/vnd.ms-project", + "application/vnd.ms-word.document.macroenabled.12", + "application/vnd.ms-word.template.macroenabled.12", + "application/vnd.ms-xpsdocument", + "application/vnd.oasis.opendocument.chart", + "application/vnd.oasis.opendocument.chart-template", + "application/vnd.oasis.opendocument.database", + "application/vnd.oasis.opendocument.formula", + "application/vnd.oasis.opendocument.graphics", + "application/vnd.oasis.opendocument.graphics-template", + "application/vnd.oasis.opendocument.image", + "application/vnd.oasis.opendocument.image-template", + "application/vnd.oasis.opendocument.presentation", + "application/vnd.oasis.opendocument.presentation-template", + "application/vnd.oasis.opendocument.spreadsheet", + "application/vnd.oasis.opendocument.spreadsheet-template", + "application/vnd.oasis.opendocument.text", + "application/vnd.oasis.opendocument.text-master", + "application/vnd.oasis.opendocument.text-template", + "application/vnd.oasis.opendocument.text-web", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.presentationml.slideshow", + "application/vnd.openxmlformats-officedocument.presentationml.template", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.spreadsheetml.template", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.palm", + "application/vnd.realvnc.bed", + "application/vnd.sun.xml.calc", + "application/vnd.sun.xml.calc.template", + "application/vnd.sun.xml.draw", + "application/vnd.sun.xml.draw.template", + "application/vnd.sun.xml.impress", + "application/vnd.sun.xml.math", + "application/vnd.sun.xml.writer", + "application/vnd.sun.xml.writer.global", + "application/vnd.sun.xml.writer.template", + "application/vnd.visio", + "application/vnd.yamaha.openscoreformat", + "application/x-7z-compressed", + "application/x-bzip", + "application/x-bzip2", + "application/x-debian-package", + "application/x-empty", + "application/x-freearc", + "application/x-keepass", + "application/x-keepass2", + "application/x-msaccess", + "application/x-msmetafile", + "application/x-mspublisher", + "application/x-python-bytecodeapplication/x-shellscript", + "application/x-python-code", + "application/x-rar", + "application/x-rar-compressed", + "application/x-research-info-systems", + "application/x-sh", + "application/x-sql", + "application/x-subrip", + "application/x-tar", + "application/x-tex", + "application/x-tgif", + "application/x-x509-ca-cert", + "application/x-xz", + "application/xml", + "application/yaml", + "application/zip", + "audio/aac", + "audio/basic", + "audio/mp4", + "audio/mpeg", + "audio/wav", + "audio/webm", + "audio/x-aac", + "audio/x-aiff", + "audio/x-wav", + "chemical/x-cif", + "font/collection", + "font/otf", + "font/ttf", + "font/woff", + "font/woff2", + "image/bmp", + "image/gif", + "image/heic", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/vnd.adobe.photoshop", + "image/vnd.djvu", + "image/vnd.dwg", + "image/vnd.dxf", + "image/webp", + "image/x-icon", + "message/rfc822", + "model/iges", + "text/calendar", + "text/csv", + "text/markdown", + "text/plain", + "text/tab-separated-values", + "text/vtt", + "text/x-c", + "text/x-python", + "text/x-vcard", + "video/3gpp", + "video/3gpp2", + "video/mp2t", + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/webm", + "video/x-m4v", + "video/x-matroska", + "video/x-msvideo", + # Fallback + "application/octet-stream", + ], + environ_name="FILE_MIMETYPE_ALLOWED", + environt_prefix=None, + ) + ITEM_PREVIEWABLE_MIME_TYPES = values.ListValue( [ "image/",