CVE-2023-6753 (published 2023-12-13T00:15Z)

Path Traversal in GitHub repository mlflow/mlflow prior to 2.9.2.

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

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/mlflow/mlflow
commit_details: repo_url: https://github.com/mlflow/mlflow
author: Ben Wilson
author_date: 2023-12-08 10:15:35-05:00
msg: Fix for windows traversal attack (#10647)

Signed-off-by: Ben Wilson <benjamin.wilson@databricks.com>
Signed-off-by: Ben Wilson <39283302+BenWilson2@users.noreply.github.com>
Co-authored-by: Harutaka Kawamura <hkawamura0130@gmail.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: http_dataset_source.py
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,4 @@
 import os
-import posixpath
 import re
 from typing import Any, Dict
 from urllib.parse import urlparse
@@ -41,6 +40,23 @@ class HTTPDatasetSource(DatasetSource):
     def _get_source_type() -> str:
         return "http"
 
+    def _extract_filename(self, response) -> str:
+        """
+        Extracts a filename from the Content-Disposition header or the URL's path.
+        """
+        if content_disposition := response.headers.get("Content-Disposition"):
+            for match in re.finditer(r"filename=(.+)", content_disposition):
+                filename = match[1].strip("'\"")
+                if _is_path(filename):
+                    raise MlflowException.invalid_parameter_value(
+                        f"Invalid filename in Content-Disposition header: {filename}. "
+                        "It must be a file name, not a path."
+                    )
+                return filename
+
+        # Extract basename from URL if no valid filename in Content-Disposition
+        return os.path.basename(urlparse(self.url).path)
+
     def load(self, dst_path=None) -> str:
         """
         Downloads the dataset source to the local filesystem.
@@ -58,21 +74,9 @@ class HTTPDatasetSource(DatasetSource):
         )
         augmented_raise_for_status(resp)
 
-        path = urlparse(self.url).path
-        content_disposition = resp.headers.get("Content-Disposition")
-        if content_disposition is not None and (
-            file_name := next(re.finditer(r"filename=(.+)", content_disposition), None)
-        ):
-            # NB: If the filename is quoted, unquote it
-            basename = file_name[1].strip("'\"")
-            if _is_path(basename):
-                raise MlflowException.invalid_parameter_value(
-                    f"Invalid filename in Content-Disposition header: {basename}. "
-                    "It must be a file name, not a path."
-                )
-        elif path is not None and len(posixpath.basename(path)) > 0:
-            basename = posixpath.basename(path)
-        else:
+        basename = self._extract_filename(resp)
+
+        if not basename:
             basename = "dataset_source"
 
         if dst_path is None:

programming_language: Python
filename: test_http_dataset_source.py
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ import pytest
 from mlflow.data.dataset_source_registry import get_dataset_source_from_json, resolve_dataset_source
 from mlflow.data.http_dataset_source import HTTPDatasetSource
 from mlflow.exceptions import MlflowException
+from mlflow.utils.os import is_windows
 from mlflow.utils.rest_utils import cloud_storage_http_request
 
 
@@ -155,3 +156,30 @@ def test_source_load_with_content_disposition_header_invalid_filename(filename):
 
         with pytest.raises(MlflowException, match="Invalid filename in Content-Disposition header"):
             source.load()
+
+
+@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
+@pytest.mark.parametrize(
+    "filename",
+    [
+        r"..\..\poc.txt",
+        r"Users\User\poc.txt",
+    ],
+)
+def test_source_load_with_content_disposition_header_invalid_filename_windows(filename):
+    def download_with_mock_content_disposition_headers(*args, **kwargs):
+        response = cloud_storage_http_request(*args, **kwargs)
+        response.headers = {"Content-Disposition": f"attachment; filename={filename}"}
+        return response
+
+    with mock.patch(
+        "mlflow.data.http_dataset_source.cloud_storage_http_request",
+        side_effect=download_with_mock_content_disposition_headers,
+    ):
+        source = HTTPDatasetSource(
+            "https://raw.githubusercontent.com/mlflow/mlflow/master/tests/datasets/winequality-red.csv"
+        )
+
+        # Expect an MlflowException for invalid filenames
+        with pytest.raises(MlflowException, match="Invalid filename in Content-Disposition header"):
+            source.load()

programming_language: Python