(backend) add an allowed file extension list

We want to limit what type of file can be uploaded. For this we use an
allowed list of file extension. This allowed list can be disabled using
a setting and this list is configurable using a setting. When enabled,
file with no extension and hidden file are also rejected.
This commit is contained in:
Manuel Raynaud
2026-01-09 10:43:53 +01:00
parent 1c1d9233e7
commit d7eccd766f
6 changed files with 524 additions and 0 deletions
+1
View File
@@ -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
+3
View File
@@ -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` |
+13
View File
@@ -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"]
@@ -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()
@@ -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()
+341
View File
@@ -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(
[