diff --git a/CHANGELOG.md b/CHANGELOG.md index 7035922f..ed222e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to - ✨(backend) add throttle mechanism to limit indexation job - 🌐(front) set html lang attribute on language change - ✨(front) add download and preview events +- ✨(backend) add an allowed file extension list ### Changed diff --git a/docs/env.md b/docs/env.md index 071c558e..0a878302 100644 --- a/docs/env.md +++ b/docs/env.md @@ -2,6 +2,7 @@ This document lists all configurable environment variables for the Drive application, extracted from the Django settings configuration. + | Environment Variable | Description | Default Value | |---------------------|-------------|---------------| | `ALLOWED_HOSTS` | List of allowed hosts for the application (used in Production) | `[]` | @@ -43,6 +44,7 @@ This document lists all configurable environment variables for the Drive applica | `EMAIL_USE_TLS` | Use TLS for SMTP connection | `False` | | `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 | | `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` | @@ -86,6 +88,7 @@ This document lists all configurable environment variables for the Drive applica | `POSTHOG_HOST` | PostHog analytics host URL | `https://eu.i.posthog.com` | | `POSTHOG_KEY` | PostHog analytics API key | `None` | | `REDIS_URL` | Redis connection URL | `redis://redis:6379/0` | +| `RESTRICT_UPLOAD_FILE_TYPE` | Boolean to enable or not upload restriction based on file type (extension + mimetype) | `True` | | `SEARCH_INDEXER_ALLOWED_MIMETYPES` | Indexable files mimetypes | `["text/"]` | | `SEARCH_INDEXER_CLASS` | Class of the backend for item indexation & search || | `SEARCH_INDEXER_BATCH_SIZE` | Size of each batch for indexation of all items | `1000` | diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 42648f79..c5df90fb 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -1,7 +1,9 @@ """Client serializers for the drive core app.""" import json +import logging from datetime import timedelta +from os.path import splitext from urllib.parse import quote from django.conf import settings @@ -15,6 +17,8 @@ from core.api import utils from core.storage import get_storage_compute_backend from wopi import utils as wopi_utils +logger = logging.getLogger(__name__) + # pylint: disable=abstract-method class UsageMetricSerializer(serializers.BaseSerializer): @@ -429,6 +433,15 @@ class CreateItemSerializer(ItemSerializer): code="item_create_file_filename_required", ) + 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"]) + raise serializers.ValidationError( + {"filename": _("This file extension is not allowed.")}, + code="item_create_file_extension_not_allowed", + ) + # When it's a file we force the title with the filename attrs["title"] = attrs["filename"] diff --git a/src/backend/core/tests/items/test_api_items_children_create.py b/src/backend/core/tests/items/test_api_items_children_create.py index 360c1121..9e7406c5 100644 --- a/src/backend/core/tests/items/test_api_items_children_create.py +++ b/src/backend/core/tests/items/test_api_items_children_create.py @@ -299,6 +299,61 @@ def test_api_items_children_create_related_success_override_s3_endpoint(settings assert len(query_params) == 0 +def test_api_items_children_create_file_extension_not_allowed(settings): + """ + Creating a file item with an extension not allowed should fail. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = True + user = factories.UserFactory() + item = factories.ItemFactory(link_reach="restricted", type=ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(user=user, item=item, role="owner") + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/items/{item.id!s}/children/", + { + "type": ItemTypeChoices.FILE, + "filename": "file.notallowed", + }, + ) + + assert response.status_code == 400 + assert response.json() == { + "errors": [ + { + "attr": "filename", + "code": "item_create_file_extension_not_allowed", + "detail": "This file extension is not allowed.", + }, + ], + "type": "validation_error", + } + + +def test_api_items_children_create_file_extension_not_allowed_not_checking_extension( + settings, +): + """ + Creating a file item with an extension not allowed should fail. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = False + user = factories.UserFactory() + item = factories.ItemFactory(link_reach="restricted", type=ItemTypeChoices.FOLDER) + factories.UserItemAccessFactory(user=user, item=item, role="owner") + client = APIClient() + client.force_login(user) + response = client.post( + f"/api/v1.0/items/{item.id!s}/children/", + { + "type": ItemTypeChoices.FILE, + "filename": "file.notallowed", + }, + ) + assert response.status_code == 201 + child = Item.objects.get(id=response.json()["id"]) + assert child.title == "file.notallowed" + + def test_api_items_children_create_force_id_success(): """It should be possible to force the item ID when creating a nested item.""" user = factories.UserFactory() diff --git a/src/backend/core/tests/items/test_api_items_create.py b/src/backend/core/tests/items/test_api_items_create.py index 1c40f3ae..98ae1aeb 100644 --- a/src/backend/core/tests/items/test_api_items_create.py +++ b/src/backend/core/tests/items/test_api_items_create.py @@ -134,6 +134,117 @@ def test_api_items_create_file_authenticated_success(): assert len(query_params) == 0 +def test_api_items_create_file_authenticated_extension_not_allowed(): + """ + Creating a file item with an extension not allowed should fail. + """ + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/items/", + { + "type": ItemTypeChoices.FILE, + "filename": "file.notallowed", + }, + format="json", + ) + assert response.status_code == 400 + assert response.json() == { + "errors": [ + { + "attr": "filename", + "code": "item_create_file_extension_not_allowed", + "detail": "This file extension is not allowed.", + }, + ], + "type": "validation_error", + } + + +def test_api_items_create_file_authenticated_not_checking_extension(settings): + """ + Creating a file item with an extension not allowed should fail. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = False + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/items/", + { + "type": ItemTypeChoices.FILE, + "filename": "file.notallowed", + }, + format="json", + ) + assert response.status_code == 201 + item = Item.objects.exclude(id=user.get_main_workspace().id).get() + assert item.title == "file.notallowed" + + +def test_api_items_create_file_authenticated_no_extension_but_checking_it_should_fail( + settings, +): + """ + Creating a file without an extension but checking the extension should fail. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = True + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/items/", + { + "type": ItemTypeChoices.FILE, + "filename": "file", + }, + format="json", + ) + assert response.status_code == 400 + assert response.json() == { + "errors": [ + { + "attr": "filename", + "code": "item_create_file_extension_not_allowed", + "detail": "This file extension is not allowed.", + }, + ], + "type": "validation_error", + } + + +def test_api_items_create_file_authenticated_hidden_file_but_checking_extension_should_fail( + settings, +): + """ + Creating a hidden file (starting with a dot) but checking the extension should fail. + """ + settings.RESTRICT_UPLOAD_FILE_TYPE = True + user = factories.UserFactory() + client = APIClient() + client.force_login(user) + response = client.post( + "/api/v1.0/items/", + { + "type": ItemTypeChoices.FILE, + "filename": ".file", + }, + ) + + assert response.status_code == 400 + assert response.json() == { + "errors": [ + { + "attr": "filename", + "code": "item_create_file_extension_not_allowed", + "detail": "This file extension is not allowed.", + }, + ], + "type": "validation_error", + } + + def test_api_items_create_authenticated_title_null(): """It should not be possible to create several items with a null title.""" user = factories.UserFactory() diff --git a/src/backend/drive/settings.py b/src/backend/drive/settings.py index ab9a080c..a811d4e6 100755 --- a/src/backend/drive/settings.py +++ b/src/backend/drive/settings.py @@ -210,6 +210,347 @@ class Base(Configuration): environ_name="DATA_UPLOAD_MAX_MEMORY_SIZE", environ_prefix=None, ) + RESTRICT_UPLOAD_FILE_TYPE = values.BooleanValue( + default=True, + environ_name="RESTRICT_UPLOAD_FILE_TYPE", + environ_prefix=None, + ) + FILE_EXTENSIONS_ALLOWED = values.ListValue( + [ + ".001", + ".002", + ".003", + ".3dm", + ".3dmf", + ".3g2", + ".3gp", + ".7z", + ".7z.001", + ".FCStd", + ".LDR", + ".N", + ".SLDDRW", + ".SLDPRT", + ".STEP", + ".aac", + ".accdb", + ".acidcsa", + ".acidppr", + ".acidsca", + ".acidssa", + ".afr", + ".ai", + ".aif", + ".alx", + ".apafis", + ".ape", + ".apk", + ".arc", + ".asc", + ".ase", + ".asy", + ".au", + ".aux", + ".avi", + ".axx", + ".bed", + ".bib", + ".bin", + ".bmp", + ".bpm", + ".bpmn", + ".bz", + ".bz2", + ".c", + ".cdc", + ".cdxml", + ".cer", + ".cif", + ".clmc", + ".clmx", + ".cpg", + ".crt", + ".crypto", + ".csr", + ".csv", + ".dat", + ".dbf", + ".deb", + ".dgn", + ".direct", + ".djvu", + ".dna", + ".doc", + ".docm", + ".docx", + ".docxf", + ".dot", + ".dotm", + ".dotx", + ".drawio", + ".drodm", + ".dsf", + ".dsn", + ".dwg", + ".dwt", + ".dxf", + ".dxi", + ".ecw", + ".eda", + ".edi", + ".egg", + ".emf", + ".eml", + ".ems", + ".enlx", + ".eot", + ".epgz", + ".epoc", + ".eps", + ".epub", + ".err", + ".excalidraw", + ".ext1", + ".ext2", + ".ext3", + ".f0", + ".f3d", + ".fas", + ".fasta", + ".fcstd", + ".fic", + ".fke", + ".fodg", + ".fodp", + ".fods", + ".fodt", + ".freeplane", + ".gan", + ".gbk", + ".gcode", + ".geo", + ".geojson", + ".gif", + ".gml", + ".gpkg", + ".gpx", + ".gradle", + ".graffle", + ".graphml", + ".grist", + ".gz", + ".heic", + ".ico", + ".ics", + ".id", + ".ifc", + ".igs", + ".ind", + ".indd", + ".int", + ".ipynb", + ".jif", + ".jp2", + ".jpe", + ".jpeg", + ".jpg", + ".json", + ".kdbx", + ".key", + ".kml", + ".kmz", + ".layout", + ".loc", + ".log", + ".lss", + ".lst", + ".m", + ".m4a", + ".m4b", + ".m4p", + ".m4r", + ".m4v", + ".map", + ".mbz", + ".mcd", + ".md", + ".mdb", + ".mkv", + ".mm", + ".moo", + ".mov", + ".mp3", + ".mp4", + ".mpd", + ".mpeg", + ".mpga", + ".mph", + ".mpp", + ".mri", + ".mrk", + ".msg", + ".mtgl", + ".mts", + ".mtz", + ".mvdx", + ".naf", + ".nist", + ".nitrace", + ".numbers", + ".oaf", + ".obj", + ".odb", + ".odc", + ".odf", + ".odg", + ".odi", + ".odm", + ".odp", + ".ods", + ".odt", + ".oform", + ".oft", + ".one", + ".opj", + ".opju", + ".osf", + ".otc", + ".otf", + ".otg", + ".oth", + ".oti", + ".oto", + ".otp", + ".ots", + ".ott", + ".p7s", + ".pages", + ".pbix", + ".pdb", + ".pdf", + ".pdfxml", + ".pgw", + ".ply", + ".png", + ".pot", + ".potm", + ".potx", + ".pps", + ".ppsm", + ".ppsx", + ".ppt", + ".pptm", + ".pptx", + ".pr1", + ".pr2", + ".prism", + ".prj", + ".properties", + ".proto", + ".prproj", + ".psd", + ".pub", + ".puw", + ".py", + ".pyc", + ".pzfx", + ".qgr", + ".qgs", + ".qgz", + ".qix", + ".qmd", + ".qml", + ".qpj", + ".qpt", + ".r", + ".rar", + ".rdata", + ".rds", + ".rep", + ".ris", + ".rmd", + ".rtf", + ".rvt", + ".sb2", + ".sb3", + ".sbn", + ".sbx", + ".scwsp", + ".sh", + ".shp", + ".shx", + ".sig", + ".skp", + ".sldasm", + ".slddrw", + ".slk", + ".sphd", + ".sphx", + ".sql", + ".srt", + ".ssp", + ".stc", + ".std", + ".stl", + ".stp", + ".stw", + ".svg", + ".swu", + ".sxc", + ".sxd", + ".sxg", + ".sxi", + ".sxm", + ".sxw", + ".tab", + ".tar", + ".tex", + ".text", + ".textgrid", + ".tfw", + ".tif", + ".tiff", + ".tsv", + ".ttc", + ".ttf", + ".twbx", + ".txt", + ".vcf", + ".vsd", + ".vsdx", + ".vtt", + ".vuw", + ".wa1", + ".wav", + ".weba", + ".webm", + ".webp", + ".wfl", + ".woff", + ".woff2", + ".wsp", + ".xcf", + ".xd", + ".xhl", + ".xls", + ".xlsb", + ".xlsm", + ".xlsx", + ".xlt", + ".xltm", + ".xltx", + ".xmind", + ".xml", + ".xps", + ".xsd", + ".xz", + ".yml", + ".zed", + ".zip", + ], + environ_name="FILE_EXTENSIONS_ALLOWED", + environ_prefix=None, + ) ITEM_PREVIEWABLE_MIME_TYPES = values.ListValue( [