Skip to content

Commit 266392d

Browse files
committed
contain artifact download paths to the destination directory
1 parent 5505e1d commit 266392d

4 files changed

Lines changed: 104 additions & 2 deletions

File tree

sdk/ml/azure-ai-ml/azure/ai/ml/_artifacts/_blob_storage_helper.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,9 +246,16 @@ def download(
246246
try:
247247
my_list = list(self.container_client.list_blobs(name_starts_with=starts_with, include="metadata"))
248248
download_size_in_mb = 0
249+
resolved_destination = Path(destination).resolve()
249250
for item in my_list:
250251
blob_name = item.name[len(starts_with) :].lstrip("/") or Path(starts_with).name
251252
target_path = Path(destination, blob_name).resolve()
253+
if target_path != resolved_destination and not str(target_path).startswith(
254+
str(resolved_destination) + os.sep
255+
):
256+
raise ValueError(
257+
f"Blob name contains a path traversal entry and cannot be downloaded safely: {item.name}"
258+
)
252259

253260
if _blob_is_hdi_folder(item):
254261
target_path.mkdir(parents=True, exist_ok=True)

sdk/ml/azure-ai-ml/azure/ai/ml/_artifacts/_fileshare_storage_helper.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,16 +403,30 @@ def recursive_download(
403403
files = [item for item in items if not item["is_directory"]]
404404
folders = [item for item in items if item["is_directory"]]
405405

406+
resolved_destination = Path(destination).resolve()
406407
for f in files:
407408
Path(destination).mkdir(parents=True, exist_ok=True)
408409
file_name = f["name"]
410+
local_path = Path(destination, file_name).resolve()
411+
if local_path != resolved_destination and not str(local_path).startswith(
412+
str(resolved_destination) + os.sep
413+
):
414+
raise ValueError(
415+
f"File name contains a path traversal entry and cannot be downloaded safely: {file_name}"
416+
)
409417
file_client = client.get_file_client(file_name)
410418
file_content = file_client.download_file(max_concurrency=max_concurrency)
411-
local_path = Path(destination, file_name)
412419
with open(local_path, "wb") as file_data:
413420
file_data.write(file_content.readall())
414421

415422
for f in folders:
423+
sub_destination = Path(destination, f["name"]).resolve()
424+
if sub_destination != resolved_destination and not str(sub_destination).startswith(
425+
str(resolved_destination) + os.sep
426+
):
427+
raise ValueError(
428+
f"Directory name contains a path traversal entry and cannot be downloaded safely: {f['name']}"
429+
)
416430
sub_client = client.get_subdirectory_client(f["name"])
417431
destination = "/".join((destination, f["name"]))
418432
recursive_download(sub_client, destination=destination, max_concurrency=max_concurrency)

sdk/ml/azure-ai-ml/azure/ai/ml/_artifacts/_gen2_storage_helper.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,9 +206,16 @@ def download(self, starts_with: str, destination: Union[str, os.PathLike] = Path
206206
try:
207207
mylist = self.file_system_client.get_paths(path=starts_with)
208208
download_size_in_mb = 0
209+
resolved_destination = Path(destination).resolve()
209210
for item in mylist:
210211
file_name = item.name[len(starts_with) :].lstrip("/") or Path(starts_with).name
211-
target_path = Path(destination, file_name)
212+
target_path = Path(destination, file_name).resolve()
213+
if target_path != resolved_destination and not str(target_path).startswith(
214+
str(resolved_destination) + os.sep
215+
):
216+
raise ValueError(
217+
f"Path name contains a path traversal entry and cannot be downloaded safely: {item.name}"
218+
)
212219

213220
if item.is_directory:
214221
target_path.mkdir(parents=True, exist_ok=True)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from unittest.mock import MagicMock, patch
2+
3+
import pytest
4+
5+
from azure.ai.ml.exceptions import MlException
6+
7+
_CLOUD = {"storage_endpoint": "core.windows.net"}
8+
9+
10+
def _named(name, is_directory=False):
11+
item = MagicMock()
12+
item.name = name
13+
item.is_directory = is_directory
14+
return item
15+
16+
17+
@pytest.mark.unittest
18+
class TestStorageDownloadTraversal:
19+
"""Server-controlled blob/file names with ``..`` segments must not escape the destination."""
20+
21+
def test_blob_download_rejects_path_traversal(self, tmp_path):
22+
from azure.ai.ml._artifacts._blob_storage_helper import BlobStorageClient
23+
24+
with patch("azure.ai.ml._artifacts._blob_storage_helper.BlobServiceClient"):
25+
client = BlobStorageClient(
26+
credential="cred", account_url="https://acct.blob.core.windows.net", container_name="c"
27+
)
28+
client.container_client = MagicMock()
29+
client.container_client.list_blobs.return_value = [_named("asset/../escaped.txt")]
30+
blob_content = client.container_client.download_blob.return_value
31+
blob_content.size = 1
32+
blob_content.content_as_bytes.return_value = b"data"
33+
34+
dest = tmp_path / "dest"
35+
dest.mkdir()
36+
with patch("azure.ai.ml._artifacts._blob_storage_helper._blob_is_hdi_folder", return_value=False), patch(
37+
"azure.ai.ml._artifacts._blob_storage_helper._get_cloud_details", return_value=_CLOUD
38+
):
39+
with pytest.raises(MlException):
40+
client.download(starts_with="asset/", destination=str(dest))
41+
assert not (tmp_path / "escaped.txt").exists()
42+
43+
def test_gen2_download_rejects_path_traversal(self, tmp_path):
44+
from azure.ai.ml._artifacts._gen2_storage_helper import Gen2StorageClient
45+
46+
with patch("azure.ai.ml._artifacts._gen2_storage_helper.DataLakeServiceClient"):
47+
client = Gen2StorageClient(
48+
credential="cred", file_system="fs", account_url="https://acct.dfs.core.windows.net"
49+
)
50+
client.file_system_client = MagicMock()
51+
client.file_system_client.get_paths.return_value = [_named("asset/../escaped.txt")]
52+
file_client = client.file_system_client.get_file_client.return_value
53+
file_client.get_file_properties.return_value.size = 1
54+
file_client.download_file.return_value.readall.return_value = b"data"
55+
56+
dest = tmp_path / "dest"
57+
dest.mkdir()
58+
with patch("azure.ai.ml._artifacts._gen2_storage_helper._get_cloud_details", return_value=_CLOUD):
59+
with pytest.raises(MlException):
60+
client.download(starts_with="asset/", destination=str(dest))
61+
assert not (tmp_path / "escaped.txt").exists()
62+
63+
def test_fileshare_recursive_download_rejects_path_traversal(self, tmp_path):
64+
from azure.ai.ml._artifacts._fileshare_storage_helper import recursive_download
65+
66+
client = MagicMock()
67+
client.list_directories_and_files.return_value = [{"name": "../escaped.txt", "is_directory": False}]
68+
client.get_file_client.return_value.download_file.return_value.readall.return_value = b"data"
69+
70+
dest = tmp_path / "dest"
71+
dest.mkdir()
72+
with pytest.raises(MlException):
73+
recursive_download(client, destination=str(dest), max_concurrency=1)
74+
assert not (tmp_path / "escaped.txt").exists()

0 commit comments

Comments
 (0)