From 022e1a9a0780d5ba4033ade06f1bb6ee2e94744f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 22 Jun 2026 15:34:07 +0200 Subject: [PATCH 1/3] Support for whitelisting extensions/mimetype --- deployment/community/.env.template | 4 ++++ server/mergin/sync/config.py | 8 ++++++++ server/mergin/sync/files.py | 2 +- server/mergin/sync/utils.py | 4 ++++ server/mergin/tests/test_utils.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index ea6b8ccc..8754d263 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -109,6 +109,10 @@ LOCAL_PROJECTS=/data #BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv() +# extra file types to permit beyond the default block-list (e.g. scripts) +#UPLOAD_EXTENSIONS_WHITELIST='' # cast=Csv() +#UPLOAD_MIME_TYPES_WHITELIST='' # cast=Csv() + #FILE_EXPIRATION=48 * 3600 # for clean up of old files where diffs were applied, in seconds #LOCKFILE_EXPIRATION=300 # in seconds diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 8a5081ec..3313bc7f 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -82,5 +82,13 @@ class Configuration(object): ) # files that should be ignored during extension and MIME type checks UPLOAD_FILES_WHITELIST = config("UPLOAD_FILES_WHITELIST", default="", cast=Csv()) + # extra extensions to permit beyond the default block-list + UPLOAD_EXTENSIONS_WHITELIST = config( + "UPLOAD_EXTENSIONS_WHITELIST", default="", cast=Csv() + ) + # extra MIME types to permit beyond the default block-list + UPLOAD_MIME_TYPES_WHITELIST = config( + "UPLOAD_MIME_TYPES_WHITELIST", default="", cast=Csv() + ) # max batch size for fetch projects in batch endpoint MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index d22358d5..9326b30f 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -224,7 +224,7 @@ def validate(self, data, **kwargs): if not is_supported_extension(file_path): raise ValidationError( - f"Unsupported file type detected: '{file_path}'. " + f"stop Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) # new checks must restrict only new files not to block existing projects diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 48966457..5843595a 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -315,6 +315,8 @@ def is_supported_extension(filepath) -> bool: if check_skip_validation(filepath): return True ext = os.path.splitext(filepath)[1].lower() + if ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST}: + return True return ext and ext not in FORBIDDEN_EXTENSIONS @@ -493,6 +495,8 @@ def is_supported_type(filepath) -> bool: if check_skip_validation(filepath): return True mime_type = get_mimetype(filepath) + if mime_type in Configuration.UPLOAD_MIME_TYPES_WHITELIST: + return True return mime_type.startswith("image/") or mime_type not in FORBIDDEN_MIME_TYPES diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 1f447875..8e4192a1 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -402,3 +402,32 @@ def test_mime_type_validation_skip(): # Should be forbidden assert not is_supported_type("other.js") + + +def test_allowed_extensions_override(): + """Extensions in UPLOAD_EXTENSIONS_WHITELIST are accepted even though they are in FORBIDDEN_EXTENSIONS.""" + with patch( + "mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", [".py", ".sh"] + ): + # forbidden by default, now explicitly allowed + assert is_supported_extension("model.py") + assert is_supported_extension("scripts/deploy.sh") + # match is case-insensitive + assert is_supported_extension("MODEL.PY") + # extensions not in the override stay blocked + assert not is_supported_extension("malware.exe") + assert not is_supported_extension("app.js") + + +def test_allowed_mime_types_override(): + """MIME types in UPLOAD_MIME_TYPES_WHITELIST are accepted even though they are in FORBIDDEN_MIME_TYPES.""" + with patch("mergin.sync.utils.get_mimetype", return_value="text/x-shellscript"): + # blocked by default + with patch("mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", []): + assert not is_supported_type("deploy.sh") + # explicitly allowed + with patch( + "mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", + ["text/x-shellscript"], + ): + assert is_supported_type("deploy.sh") From c001c7ad5d9ff1c94ad4fa5e6d93b92a665d9e9f Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Mon, 22 Jun 2026 15:49:50 +0200 Subject: [PATCH 2/3] rm debugging residue --- server/mergin/sync/files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mergin/sync/files.py b/server/mergin/sync/files.py index 9326b30f..d22358d5 100644 --- a/server/mergin/sync/files.py +++ b/server/mergin/sync/files.py @@ -224,7 +224,7 @@ def validate(self, data, **kwargs): if not is_supported_extension(file_path): raise ValidationError( - f"stop Unsupported file type detected: '{file_path}'. " + f"Unsupported file type detected: '{file_path}'. " f"Please remove the file or try compressing it into a ZIP file before uploading.", ) # new checks must restrict only new files not to block existing projects From 64a3844aa1e639a8be4ac83a02063c9cfe8f2349 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Thu, 2 Jul 2026 15:36:34 +0200 Subject: [PATCH 3/3] address @MarcelGeo comments - use check_skip_validation -> also skips mimetype check -> mime_type config var is not needed --- deployment/community/.env.template | 5 ++--- server/mergin/sync/config.py | 4 ---- server/mergin/sync/utils.py | 9 ++++----- server/mergin/tests/test_utils.py | 13 ++++++------- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/deployment/community/.env.template b/deployment/community/.env.template index 8754d263..95497b68 100644 --- a/deployment/community/.env.template +++ b/deployment/community/.env.template @@ -109,9 +109,8 @@ LOCAL_PROJECTS=/data #BLACKLIST='.mergin/, .DS_Store, .directory' # cast=Csv() -# extra file types to permit beyond the default block-list (e.g. scripts) -#UPLOAD_EXTENSIONS_WHITELIST='' # cast=Csv() -#UPLOAD_MIME_TYPES_WHITELIST='' # cast=Csv() +# extra file extensions to permit beyond the default block-list, e.g. '.py, .sh' +#UPLOAD_EXTENSIONS_WHITELIST= #FILE_EXPIRATION=48 * 3600 # for clean up of old files where diffs were applied, in seconds diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index 3313bc7f..a5c8167a 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -86,9 +86,5 @@ class Configuration(object): UPLOAD_EXTENSIONS_WHITELIST = config( "UPLOAD_EXTENSIONS_WHITELIST", default="", cast=Csv() ) - # extra MIME types to permit beyond the default block-list - UPLOAD_MIME_TYPES_WHITELIST = config( - "UPLOAD_MIME_TYPES_WHITELIST", default="", cast=Csv() - ) # max batch size for fetch projects in batch endpoint MAX_BATCH_SIZE = config("MAX_BATCH_SIZE", default=100, cast=int) diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index 5843595a..6dd7abe1 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -315,8 +315,6 @@ def is_supported_extension(filepath) -> bool: if check_skip_validation(filepath): return True ext = os.path.splitext(filepath)[1].lower() - if ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST}: - return True return ext and ext not in FORBIDDEN_EXTENSIONS @@ -465,7 +463,10 @@ def check_skip_validation(file_path: str) -> bool: Some files are allowed even if they have forbidden extension or mime type. """ file_name = os.path.basename(file_path) - return file_name in Configuration.UPLOAD_FILES_WHITELIST + if file_name in Configuration.UPLOAD_FILES_WHITELIST: + return True + ext = os.path.splitext(file_path)[1].lower() + return ext in {e.lower() for e in Configuration.UPLOAD_EXTENSIONS_WHITELIST} FORBIDDEN_MIME_TYPES = { @@ -495,8 +496,6 @@ def is_supported_type(filepath) -> bool: if check_skip_validation(filepath): return True mime_type = get_mimetype(filepath) - if mime_type in Configuration.UPLOAD_MIME_TYPES_WHITELIST: - return True return mime_type.startswith("image/") or mime_type not in FORBIDDEN_MIME_TYPES diff --git a/server/mergin/tests/test_utils.py b/server/mergin/tests/test_utils.py index 8e4192a1..288577b0 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -419,15 +419,14 @@ def test_allowed_extensions_override(): assert not is_supported_extension("app.js") -def test_allowed_mime_types_override(): - """MIME types in UPLOAD_MIME_TYPES_WHITELIST are accepted even though they are in FORBIDDEN_MIME_TYPES.""" +def test_extension_whitelist_skips_mime_check(): + """A whitelisted extension also bypasses the MIME check via check_skip_validation.""" with patch("mergin.sync.utils.get_mimetype", return_value="text/x-shellscript"): - # blocked by default - with patch("mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", []): + # blocked when the extension is not whitelisted + with patch("mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", []): assert not is_supported_type("deploy.sh") - # explicitly allowed + # allowed once the extension is whitelisted with patch( - "mergin.sync.utils.Configuration.UPLOAD_MIME_TYPES_WHITELIST", - ["text/x-shellscript"], + "mergin.sync.utils.Configuration.UPLOAD_EXTENSIONS_WHITELIST", [".sh"] ): assert is_supported_type("deploy.sh")