CVE-2023-6909 (published 2023-12-18T04:15Z)

Path Traversal: '\\..\\filename' in GitHub repository mlflow/mlflow prior to 2.9.2.

CVSS v3 base score: 7.5, HIGH (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N)

Weakness information:
cwe_id: CWE-29
cwe_name: Path Traversal: '\..\filename'
description: The product uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize '\..\filename' (leading backslash dot dot) sequences that can resolve to a location that is outside of that directory.
extended_description: 
            This allows attackers to traverse the file system to access files or directories that are outside of the restricted directory.
            This is similar to CWE-25, except using "\" instead of "/". Sometimes a program checks for "..\" at the beginning of the input, so a "\..\" can bypass that check. It is also useful for bypassing path traversal protection schemes that only assume that the "/" separator is valid.
         
         

Fix information:
repo_url: https://github.com/mlflow/mlflow
commit_details: repo_url: https://github.com/mlflow/mlflow
author: Yuki Watanabe
author_date: 2023-12-08 15:00:19+09:00
msg: Prevent path traversal with encoded URL (#10650)

Signed-off-by: B-Step62 <yuki.watanabe@databricks.com>
repository: repo_url: https://github.com/mlflow/mlflow
repo_name: mlflow/mlflow
description: Open source platform for the machine learning lifecycle
repo_language: Python
owner: mlflow
file_changes: filename: uri.py
change_type: ModificationType.MODIFY
diff: @@ -430,7 +430,10 @@ def validate_path_is_safe(path):
     """
     from mlflow.utils.file_utils import local_file_uri_to_path
 
-    exc = MlflowException(f"Invalid path: {path}", error_code=INVALID_PARAMETER_VALUE)
+    # We must decode URL before validating it
+    path = urllib.parse.unquote(path)
+
+    exc = MlflowException("Invalid path", error_code=INVALID_PARAMETER_VALUE)
     if any((s in path) for s in ("#", "%23")):
         raise exc
 

programming_language: Python
filename: test_handlers.py
change_type: ModificationType.MODIFY
diff: @@ -38,11 +38,12 @@ from mlflow.protos.model_registry_pb2 import (
     UpdateRegisteredModel,
 )
 from mlflow.protos.service_pb2 import CreateExperiment, SearchRuns
-from mlflow.server import BACKEND_STORE_URI_ENV_VAR, app
+from mlflow.server import BACKEND_STORE_URI_ENV_VAR, SERVE_ARTIFACTS_ENV_VAR, app
 from mlflow.server.handlers import (
     _create_experiment,
     _create_model_version,
     _create_registered_model,
+    _delete_artifact_mlflow_artifacts,
     _delete_model_version,
     _delete_model_version_tag,
     _delete_registered_model,
@@ -105,6 +106,11 @@ def mock_model_registry_store():
         yield mock_store
 
 
+@pytest.fixture
+def enable_serve_artifacts(monkeypatch):
+    monkeypatch.setenv(SERVE_ARTIFACTS_ENV_VAR, "true")
+
+
 def test_health():
     with app.test_client() as c:
         response = c.get("/health")
@@ -777,3 +783,22 @@ def test_get_model_version_by_alias(mock_get_request_message, mock_model_registr
     _, args = mock_model_registry_store.get_model_version_by_alias.call_args
     assert args == {"name": name, "alias": alias}
     assert json.loads(resp.get_data()) == {"model_version": jsonify(mvd)}
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/path",
+        "path/../to/file",
+        "/etc/passwd",
+        "/etc/passwd%00.jpg",
+        "/file://etc/passwd",
+        "%2E%2E%2F%2E%2E%2Fpath",
+    ],
+)
+def test_delete_artifact_mlflow_artifacts_throws_for_malicious_path(enable_serve_artifacts, path):
+    response = _delete_artifact_mlflow_artifacts(path)
+    assert response.status_code == 400
+    json_response = json.loads(response.get_data())
+    assert json_response["error_code"] == ErrorCode.Name(INVALID_PARAMETER_VALUE)
+    assert json_response["message"] == "Invalid path"

programming_language: Python
filename: test_http_artifact_repo.py
change_type: ModificationType.MODIFY
diff: @@ -246,7 +246,7 @@ def test_list_artifacts(http_artifact_repo):
             http_artifact_repo.list_artifacts()
 
 
-@pytest.mark.parametrize("path", ["/tmp/path", "../../path"])
+@pytest.mark.parametrize("path", ["/tmp/path", "../../path", "%2E%2E%2Fpath"])
 def test_list_artifacts_malicious_path(http_artifact_repo, path):
     with mock.patch(
         "mlflow.store.artifact.http_artifact_repo.http_request",
@@ -259,7 +259,7 @@ def test_list_artifacts_malicious_path(http_artifact_repo, path):
             200,
         ),
     ):
-        with pytest.raises(MlflowException, match=f"Invalid path: {path}"):
+        with pytest.raises(MlflowException, match="Invalid path"):
             http_artifact_repo.list_artifacts()
 
 

programming_language: Python
filename: test_rest_tracking.py
change_type: ModificationType.MODIFY
diff: @@ -35,7 +35,6 @@ from mlflow.entities import (
 )
 from mlflow.exceptions import MlflowException
 from mlflow.models import Model
-from mlflow.server.handlers import validate_path_is_safe
 from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
 from mlflow.utils import mlflow_tags
 from mlflow.utils.file_utils import TempDir, path_to_local_file_uri
@@ -514,152 +513,6 @@ def test_set_tag_validation(mlflow_client):
     assert response.status_code == 200
 
 
-@pytest.mark.parametrize(
-    "path",
-    [
-        "path",
-        "path/",
-        "path/to/file",
-    ],
-)
-def test_validate_path_is_safe_good(path):
-    validate_path_is_safe(path)
-
-
-@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
-@pytest.mark.parametrize(
-    "path",
-    [
-        # relative path from current directory of C: drive
-        ".../...//",
-    ],
-)
-def test_validate_path_is_safe_windows_good(path):
-    validate_path_is_safe(path)
-
-
-@pytest.mark.skipif(is_windows(), reason="This test does not pass on Windows")
-@pytest.mark.parametrize(
-    "path",
-    [
-        "/path",
-        "../path",
-        "../../path",
-        "./../path",
-        "path/../to/file",
-        "path/../../to/file",
-        "file://a#/..//tmp",
-        "file://a%23/..//tmp/",
-        "/etc/passwd",
-        "/etc/passwd%00.jpg",
-        "/etc/passwd%00.html",
-        "/etc/passwd%00.txt",
-        "/etc/passwd%00.php",
-        "/etc/passwd%00.asp",
-        "/file://etc/passwd",
-    ],
-)
-def test_validate_path_is_safe_bad(path):
-    with pytest.raises(MlflowException, match="Invalid path"):
-        validate_path_is_safe(path)
-
-
-@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
-@pytest.mark.parametrize(
-    "path",
-    [
-        r"../path",
-        r"../../path",
-        r"./../path",
-        r"path/../to/file",
-        r"path/../../to/file",
-        r"..\path",
-        r"..\..\path",
-        r".\..\path",
-        r"path\..\to\file",
-        r"path\..\..\to\file",
-        # Drive-relative paths
-        r"C:path",
-        r"C:path/",
-        r"C:path/to/file",
-        r"C:../path/to/file",
-        r"C:\path",
-        r"C:/path",
-        r"C:\path\to\file",
-        r"C:\path/to/file",
-        r"C:\path\..\to\file",
-        r"C:/path/../to/file",
-        # UNC(Universal Naming Convention) paths
-        r"\\path\to\file",
-        r"\\path/to/file",
-        r"\\.\\C:\path\to\file",
-        r"\\?\C:\path\to\file",
-        r"\\?\UNC/path/to/file",
-        # Other potential attackable paths
-        r"/etc/password",
-        r"/path",
-        r"/etc/passwd%00.jpg",
-        r"/etc/passwd%00.html",
-        r"/etc/passwd%00.txt",
-        r"/etc/passwd%00.php",
-        r"/etc/passwd%00.asp",
-        r"/Windows/no/such/path",
-        r"/file://etc/passwd",
-        r"/file:c:/passwd",
-        r"/file://d:/windows/win.ini",
-        r"/file://./windows/win.ini",
-        r"file://c:/boot.ini",
-        r"file://C:path",
-        r"file://C:path/",
-        r"file://C:path/to/file",
-        r"file:///C:/Windows/System32/",
-        r"file:///etc/passwd",
-        r"file:///d:/windows/repair/sam",
-        r"file:///proc/version",
-        r"file:///inetpub/wwwroot/global.asa",
-        r"/file://../windows/win.ini",
-        r"../etc/passwd",
-        r"..\Windows\System32\\",
-        r"C:\Windows\System32\\",
-        r"/etc/passwd",
-        r"::Windows\System32",
-        r"..\..\..\..\Windows\System32\\",
-        r"../Windows/System32",
-        r"....\\",
-        r"\\?\C:\Windows\System32\\",
-        r"\\.\C:\Windows\System32\\",
-        r"\\UNC\Server\Share\\",
-        r"\\Server\Share\folder\\",
-        r"\\127.0.0.1\c$\Windows\\",
-        r"\\localhost\c$\Windows\\",
-        r"\\smbserver\share\path\\",
-        r"..\\?\C:\Windows\System32\\",
-        r"C:/Windows/../Windows/System32/",
-        r"C:\Windows\..\Windows\System32\\",
-        r"../../../../../../../../../../../../Windows/System32",
-        r"../../../../../../../../../../../../etc/passwd",
-        r"../../../../../../../../../../../../var/www/html/index.html",
-        r"../../../../../../../../../../../../usr/local/etc/openvpn/server.conf",
-        r"../../../../../../../../../../../../Program Files (x86)",
-        r"/../../../../../../../../../../../../Windows/System32",
-        r"/Windows\../etc/passwd",
-        r"/Windows\..\Windows\System32\\",
-        r"/Windows\..\Windows\System32\cmd.exe",
-        r"/Windows\..\Windows\System32\msconfig.exe",
-        r"/Windows\..\Windows\System32\regedit.exe",
-        r"/Windows\..\Windows\System32\taskmgr.exe",
-        r"/Windows\..\Windows\System32\control.exe",
-        r"/Windows\..\Windows\System32\services.msc",
-        r"/Windows\..\Windows\System32\diskmgmt.msc",
-        r"/Windows\..\Windows\System32\eventvwr.msc",
-        r"/Windows/System32/drivers/etc/hosts",
-    ],
-)
-def test_validate_path_is_safe_windows_bad(path):
-    with pytest.raises(MlflowException, match="Invalid path"):
-        validate_path_is_safe(path)
-
-
 def test_path_validation(mlflow_client):
     experiment_id = mlflow_client.create_experiment("tags validation")
     created_run = mlflow_client.create_run(experiment_id)
@@ -670,7 +523,7 @@ def test_path_validation(mlflow_client):
         assert resp.status_code == 400
         assert response.json() == {
             "error_code": "INVALID_PARAMETER_VALUE",
-            "message": f"Invalid path: {invalid_path}",
+            "message": "Invalid path",
         }
 
     response = requests.get(

programming_language: Python
filename: test_uri.py
change_type: ModificationType.MODIFY
diff: @@ -24,6 +24,7 @@ from mlflow.utils.uri import (
     is_valid_dbfs_uri,
     remove_databricks_profile_info_from_artifact_uri,
     resolve_uri_if_local,
+    validate_path_is_safe,
 )
 
 
@@ -724,3 +725,152 @@ def test_correctly_detect_fuse_and_uc_uris(uri):
 )
 def test_negative_detection(uri):
     assert not is_fuse_or_uc_volumes_uri(uri)
+
+
+@pytest.mark.parametrize(
+    "path",
+    [
+        "path",
+        "path/",
+        "path/to/file",
+    ],
+)
+def test_validate_path_is_safe_good(path):
+    validate_path_is_safe(path)
+
+
+@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
+@pytest.mark.parametrize(
+    "path",
+    [
+        # relative path from current directory of C: drive
+        ".../...//",
+    ],
+)
+def test_validate_path_is_safe_windows_good(path):
+    validate_path_is_safe(path)
+
+
+@pytest.mark.skipif(is_windows(), reason="This test does not pass on Windows")
+@pytest.mark.parametrize(
+    "path",
+    [
+        "/path",
+        "../path",
+        "../../path",
+        "./../path",
+        "path/../to/file",
+        "path/../../to/file",
+        "file://a#/..//tmp",
+        "file://a%23/..//tmp/",
+        "/etc/passwd",
+        "/etc/passwd%00.jpg",
+        "/etc/passwd%00.html",
+        "/etc/passwd%00.txt",
+        "/etc/passwd%00.php",
+        "/etc/passwd%00.asp",
+        "/file://etc/passwd",
+        # Encoded paths with '..'
+        "%2E%2E%2Fpath",
+        "%2E%2E%2F%2E%2E%2Fpath",
+    ],
+)
+def test_validate_path_is_safe_bad(path):
+    with pytest.raises(MlflowException, match="Invalid path"):
+        validate_path_is_safe(path)
+
+
+@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
+@pytest.mark.parametrize(
+    "path",
+    [
+        r"../path",
+        r"../../path",
+        r"./../path",
+        r"path/../to/file",
+        r"path/../../to/file",
+        r"..\path",
+        r"..\..\path",
+        r".\..\path",
+        r"path\..\to\file",
+        r"path\..\..\to\file",
+        # Drive-relative paths
+        r"C:path",
+        r"C:path/",
+        r"C:path/to/file",
+        r"C:../path/to/file",
+        r"C:\path",
+        r"C:/path",
+        r"C:\path\to\file",
+        r"C:\path/to/file",
+        r"C:\path\..\to\file",
+        r"C:/path/../to/file",
+        # UNC(Universal Naming Convention) paths
+        r"\\path\to\file",
+        r"\\path/to/file",
+        r"\\.\\C:\path\to\file",
+        r"\\?\C:\path\to\file",
+        r"\\?\UNC/path/to/file",
+        # Other potential attackable paths
+        r"/etc/password",
+        r"/path",
+        r"/etc/passwd%00.jpg",
+        r"/etc/passwd%00.html",
+        r"/etc/passwd%00.txt",
+        r"/etc/passwd%00.php",
+        r"/etc/passwd%00.asp",
+        r"/Windows/no/such/path",
+        r"/file://etc/passwd",
+        r"/file:c:/passwd",
+        r"/file://d:/windows/win.ini",
+        r"/file://./windows/win.ini",
+        r"file://c:/boot.ini",
+        r"file://C:path",
+        r"file://C:path/",
+        r"file://C:path/to/file",
+        r"file:///C:/Windows/System32/",
+        r"file:///etc/passwd",
+        r"file:///d:/windows/repair/sam",
+        r"file:///proc/version",
+        r"file:///inetpub/wwwroot/global.asa",
+        r"/file://../windows/win.ini",
+        r"../etc/passwd",
+        r"..\Windows\System32\\",
+        r"C:\Windows\System32\\",
+        r"/etc/passwd",
+        r"::Windows\System32",
+        r"..\..\..\..\Windows\System32\\",
+        r"../Windows/System32",
+        r"....\\",
+        r"\\?\C:\Windows\System32\\",
+        r"\\.\C:\Windows\System32\\",
+        r"\\UNC\Server\Share\\",
+        r"\\Server\Share\folder\\",
+        r"\\127.0.0.1\c$\Windows\\",
+        r"\\localhost\c$\Windows\\",
+        r"\\smbserver\share\path\\",
+        r"..\\?\C:\Windows\System32\\",
+        r"C:/Windows/../Windows/System32/",
+        r"C:\Windows\..\Windows\System32\\",
+        r"../../../../../../../../../../../../Windows/System32",
+        r"../../../../../../../../../../../../etc/passwd",
+        r"../../../../../../../../../../../../var/www/html/index.html",
+        r"../../../../../../../../../../../../usr/local/etc/openvpn/server.conf",
+        r"../../../../../../../../../../../../Program Files (x86)",
+        r"/../../../../../../../../../../../../Windows/System32",
+        r"/Windows\../etc/passwd",
+        r"/Windows\..\Windows\System32\\",
+        r"/Windows\..\Windows\System32\cmd.exe",
+        r"/Windows\..\Windows\System32\msconfig.exe",
+        r"/Windows\..\Windows\System32\regedit.exe",
+        r"/Windows\..\Windows\System32\taskmgr.exe",
+        r"/Windows\..\Windows\System32\control.exe",
+        r"/Windows\..\Windows\System32\services.msc",
+        r"/Windows\..\Windows\System32\diskmgmt.msc",
+        r"/Windows\..\Windows\System32\eventvwr.msc",
+        r"/Windows/System32/drivers/etc/hosts",
+    ],
+)
+def test_validate_path_is_safe_windows_bad(path):
+    with pytest.raises(MlflowException, match="Invalid path"):
+        validate_path_is_safe(path)

programming_language: Python