diff --git a/server/mergin/sync/config.py b/server/mergin/sync/config.py index e616a0ca..093927d2 100644 --- a/server/mergin/sync/config.py +++ b/server/mergin/sync/config.py @@ -78,3 +78,5 @@ class Configuration(object): EXCLUDED_CLONE_FILENAMES = config( "EXCLUDED_CLONE_FILENAMES", default="qgis_cfg.xml", cast=Csv() ) + # files that should be ignored during extension and mime type check + UPLOAD_FILES_WHITELIST = config("UPLOAD_FILES_WHITELIST", default="", cast=Csv()) diff --git a/server/mergin/sync/utils.py b/server/mergin/sync/utils.py index de0fbe94..dd2d6a2c 100644 --- a/server/mergin/sync/utils.py +++ b/server/mergin/sync/utils.py @@ -27,6 +27,8 @@ from flask import current_app from pathlib import Path +from .config import Configuration + def generate_checksum(file, chunk_size=4096): """ @@ -349,6 +351,8 @@ def has_trailing_space(filepath: str) -> bool: def is_supported_extension(filepath) -> bool: """Check whether file's extension is supported.""" + if check_skip_validation(filepath): + return True ext = os.path.splitext(filepath)[1].lower() return ext and ext not in FORBIDDEN_EXTENSIONS @@ -491,6 +495,15 @@ def is_supported_extension(filepath) -> bool: ".xnk", } + +def check_skip_validation(file_path: str) -> bool: + """ + Check if we can skip validation for this file path. + Some files are allowed even if they have forbidden extension or mime type. + """ + return file_path in Configuration.UPLOAD_FILES_WHITELIST + + FORBIDDEN_MIME_TYPES = { "application/x-msdownload", "application/x-sh", @@ -515,6 +528,8 @@ def is_supported_extension(filepath) -> bool: def is_supported_type(filepath) -> bool: """Check whether the file mimetype is supported.""" + if check_skip_validation(filepath): + return True mime_type = get_mimetype(filepath) 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 00b3e1c6..424e1e01 100644 --- a/server/mergin/tests/test_utils.py +++ b/server/mergin/tests/test_utils.py @@ -22,10 +22,13 @@ has_valid_characters, has_valid_first_character, check_filename, + is_supported_extension, + is_supported_type, is_valid_path, get_x_accel_uri, wkb2wkt, has_trailing_space, + check_skip_validation, ) from ..auth.models import LoginHistory, User from . import json_headers @@ -322,3 +325,46 @@ class TestSchema(Schema): "size": "disk_usage", } assert schema_map == expected_map + + +def test_check_skip_validation(): + ALLOWED_FILES = ["script.js", "config/script.js"] + + # We patch the Configuration class attribute directly + with patch("mergin.sync.utils.Configuration.UPLOAD_FILES_WHITELIST", ALLOWED_FILES): + + # Test allowed files + for file_path in ALLOWED_FILES: + assert check_skip_validation(file_path) + + # Test not allowed files + assert not check_skip_validation("test.py") + assert not check_skip_validation("/some/path/test.py") + assert not check_skip_validation("image.png") + + +def test_is_supported_extension(): + ALLOWED_FILES = ["script.js", "config/script.js"] + + with patch("mergin.sync.utils.Configuration.UPLOAD_FILES_WHITELIST", ALLOWED_FILES): + for file_path in ALLOWED_FILES: + assert is_supported_extension(file_path) + + # Allowed normal file + assert is_supported_extension("image.png") + + # Forbidden file + assert not is_supported_extension("test.js") + + +def test_mime_type_validation_skip(): + ALLOWED_FILES = ["script.js", "config/script.js"] + # Mocking get_mimetype to return forbidden mime type + with patch( + "mergin.sync.utils.get_mimetype", return_value="application/x-python-code" + ), patch("mergin.sync.utils.Configuration.UPLOAD_FILES_WHITELIST", ALLOWED_FILES): + for file_path in ALLOWED_FILES: + assert is_supported_extension(file_path) + + # Should be forbidden + assert not is_supported_type("other.js")