Skip to content

Commit 8e19cd3

Browse files
committed
contain job output download path within download_path
1 parent 0e10e6f commit 8e19cd3

3 files changed

Lines changed: 51 additions & 1 deletion

File tree

sdk/ml/azure-ai-ml/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features Added
66

77
### Bugs Fixed
8+
- Fixed `JobOperations.download` writing outside the requested `download_path` when a job output name returned by the service contained `..` segments or an absolute path. Such outputs are now skipped instead of escaping the download directory.
89

910
### Other Changes
1011

sdk/ml/azure-ai-ml/azure/ai/ml/operations/_job_operations.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1053,6 +1053,16 @@ def log_missing_uri(what: str) -> None:
10531053
destination = download_path / artifact_directory_name
10541054
else:
10551055
destination = download_path / output_directory_name / item_name
1056+
# item_name is an output name taken from the run's service response; an entry
1057+
# containing ".." or an absolute path would resolve outside download_path.
1058+
try:
1059+
destination.resolve().relative_to(download_path.resolve())
1060+
except ValueError:
1061+
module_logger.warning(
1062+
"Skipping output '%s': resolved path is outside the download directory.",
1063+
item_name,
1064+
)
1065+
continue
10561066

10571067
module_logger.info("Downloading artifact %s to %s", uri, destination)
10581068
download_artifact_from_aml_uri(

sdk/ml/azure-ai-ml/tests/job_common/unittests/test_job_operations.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import os
33
import platform
4+
from pathlib import Path
45
from unittest.mock import Mock, patch
56

67
import jwt
@@ -11,12 +12,18 @@
1112
from azure.ai.ml._azure_environments import _get_aml_resource_id_from_metadata, _resource_to_scopes
1213
from azure.ai.ml._restclient.v2023_04_01_preview import models
1314
from azure.ai.ml._scope_dependent_operations import OperationConfig, OperationScope
14-
from azure.ai.ml.constants._common import AZUREML_PRIVATE_FEATURES_ENV_VAR, AzureMLResourceType, GitProperties
15+
from azure.ai.ml.constants._common import (
16+
AZUREML_PRIVATE_FEATURES_ENV_VAR,
17+
DEFAULT_ARTIFACT_STORE_OUTPUT_NAME,
18+
AzureMLResourceType,
19+
GitProperties,
20+
)
1521
from azure.ai.ml.entities._builders import Command
1622
from azure.ai.ml.entities._job.job import Job
1723
from azure.ai.ml.operations import DatastoreOperations, EnvironmentOperations, JobOperations, WorkspaceOperations
1824
from azure.ai.ml.operations._code_operations import CodeOperations
1925
from azure.ai.ml.operations._job_ops_helper import get_git_properties
26+
from azure.ai.ml.operations._run_history_constants import JobStatus
2027
from azure.ai.ml.operations._run_operations import RunOperations
2128
from azure.core.credentials import AccessToken
2229
from azure.identity import DefaultAzureCredential
@@ -266,6 +273,38 @@ def test_restore(self, mock_method, mock_job_operation: JobOperations) -> None:
266273
mock_job_operation.service_client_01_2024_preview.jobs.get.assert_called_once()
267274
mock_job_operation._operation_2023_02_preview.create_or_update.assert_called_once()
268275

276+
def test_download_skips_output_name_path_traversal(
277+
self, mocker: MockFixture, mock_job_operation: JobOperations, tmp_path
278+
) -> None:
279+
# Output names come from the run's service response; a malicious name must not
280+
# let the download destination escape download_path.
281+
job_details = Mock()
282+
job_details.status = JobStatus.COMPLETED
283+
job_details.properties = {}
284+
job_details.tags = {}
285+
mock_job_operation.get = Mock(return_value=job_details)
286+
287+
download_path = tmp_path / "download"
288+
mock_job_operation._get_named_output_uri = Mock(
289+
return_value={
290+
DEFAULT_ARTIFACT_STORE_OUTPUT_NAME: "azureml://datastores/store/paths/logs",
291+
"../../../../evil": "azureml://datastores/store/paths/evil",
292+
"/tmp/evil-abs": "azureml://datastores/store/paths/evil-abs",
293+
}
294+
)
295+
mocked_download = mocker.patch("azure.ai.ml.operations._job_operations.download_artifact_from_aml_uri")
296+
297+
mock_job_operation.download("job-1", download_path=str(download_path), all=True)
298+
299+
resolved_root = download_path.resolve()
300+
for call in mocked_download.call_args_list:
301+
destination = Path(call.kwargs["destination"]).resolve()
302+
destination.relative_to(resolved_root) # raises ValueError if it escaped
303+
downloaded_uris = {call.kwargs["uri"] for call in mocked_download.call_args_list}
304+
assert "azureml://datastores/store/paths/evil" not in downloaded_uris
305+
assert "azureml://datastores/store/paths/evil-abs" not in downloaded_uris
306+
assert "azureml://datastores/store/paths/logs" in downloaded_uris
307+
269308
@pytest.mark.parametrize(
270309
"corrupt_job_data",
271310
[

0 commit comments

Comments
 (0)