Skip to content

Commit c8cf415

Browse files
committed
chore: harden S3 download path handling
Align master-v2 with master by validating that files downloaded from S3 resolve within the intended destination directory before writing. Adds a shared validate_path_within_directory() helper in sagemaker.utils and wires it into the S3 download paths in _download_files_under_prefix, Session.download_data, mlflow.utils._download_s3_artifacts, and the local transform job. Includes unit tests for the new validation behavior.
1 parent 36a7dcc commit c8cf415

8 files changed

Lines changed: 303 additions & 2 deletions

File tree

‎src/sagemaker/local/entities.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@
2828

2929
from sagemaker.local.image import _SageMakerContainer
3030
from sagemaker.local.utils import copy_directory_structure, move_to_destination, get_docker_host
31-
from sagemaker.utils import DeferredError, get_config_value, format_tags
31+
from sagemaker.utils import (
32+
DeferredError,
33+
get_config_value,
34+
format_tags,
35+
validate_path_within_directory,
36+
)
3237
from sagemaker.local.exceptions import StepExecutionException
3338

3439
logger = logging.getLogger(__name__)
@@ -511,9 +516,12 @@ def _perform_batch_inference(self, input_data, output_data, **kwargs):
511516

512517
relative_path = os.path.dirname(os.path.relpath(fn, dataset_dir))
513518
filename = os.path.basename(fn)
514-
copy_directory_structure(working_dir, relative_path)
515519
destination_path = os.path.join(working_dir, relative_path, filename + ".out")
516520

521+
validate_path_within_directory(destination_path, working_dir, source_description=fn)
522+
523+
copy_directory_structure(working_dir, relative_path)
524+
517525
with open(destination_path, "wb") as f:
518526
for item in batch_provider.pad(fn, max_payload):
519527
# call the container and add the result to inference.

‎src/sagemaker/serve/model_format/mlflow/utils.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import os
2222

2323
from sagemaker import Session, image_uris
24+
from sagemaker.utils import validate_path_within_directory
2425
from sagemaker.serve.utils.types import ModelServer
2526
from sagemaker.serve.detector.image_detector import _cast_to_compatible_version
2627
from sagemaker.serve.model_format.mlflow.constants import (
@@ -260,6 +261,8 @@ def _download_s3_artifacts(s3_path: str, dst_path: str, session: Session) -> Non
260261
rel_path = os.path.relpath(key, s3_key)
261262
local_file_path = os.path.join(dst_path, rel_path)
262263

264+
validate_path_within_directory(local_file_path, dst_path, source_description=key)
265+
263266
if not key.endswith("/"):
264267
local_file_dir = os.path.dirname(local_file_path)
265268
os.makedirs(local_file_dir, exist_ok=True)

‎src/sagemaker/session.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@
137137
format_tags,
138138
Tags,
139139
TagsDict,
140+
validate_path_within_directory,
140141
)
141142
from sagemaker import exceptions
142143
from sagemaker.session_settings import SessionSettings
@@ -551,12 +552,16 @@ def download_data(self, path, bucket, key_prefix="", extra_args=None):
551552
download_extra_args["ExpectedBucketOwner"] = expected_owner
552553
downloaded_paths = []
553554
for dir_path in directories:
555+
validate_path_within_directory(dir_path, path)
554556
os.makedirs(os.path.dirname(dir_path), exist_ok=True)
555557
for key in keys:
556558
tail_s3_uri_path = os.path.basename(key)
557559
if not os.path.splitext(key_prefix)[1]:
558560
tail_s3_uri_path = os.path.relpath(key, key_prefix)
559561
destination_path = os.path.join(path, tail_s3_uri_path)
562+
563+
validate_path_within_directory(destination_path, path, source_description=key)
564+
560565
if not os.path.exists(os.path.dirname(destination_path)):
561566
os.makedirs(os.path.dirname(destination_path), exist_ok=True)
562567
s3.download_file(

‎src/sagemaker/utils.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,31 @@ def download_folder(bucket_name, prefix, target, sagemaker_session):
449449
_download_files_under_prefix(bucket_name, prefix, target, s3, extra_args=extra_args)
450450

451451

452+
def validate_path_within_directory(file_path, target_directory, source_description=""):
453+
"""Validate that file_path resolves to a location within target_directory.
454+
455+
Prevents path traversal attacks (CWE-22) by resolving both paths to their
456+
canonical forms and checking containment.
457+
458+
Args:
459+
file_path (str): The file path to validate.
460+
target_directory (str): The directory that file_path must stay within.
461+
source_description (str): Optional description of the source (e.g. S3 key)
462+
included in the error message for debugging.
463+
464+
Raises:
465+
ValueError: If file_path resolves to a location outside target_directory.
466+
"""
467+
target_real = os.path.realpath(target_directory)
468+
file_real = os.path.realpath(file_path)
469+
if not file_real.startswith(target_real + os.sep) and file_real != target_real:
470+
source_info = f"'{source_description}' resolves to " if source_description else ""
471+
raise ValueError(
472+
f"Path traversal detected: {source_info}"
473+
f"'{file_real}' which is outside the target directory '{target_real}'"
474+
)
475+
476+
452477
def _download_files_under_prefix(bucket_name, prefix, target, s3, extra_args=None):
453478
"""Download all S3 files which match the given prefix
454479
@@ -469,6 +494,8 @@ def _download_files_under_prefix(bucket_name, prefix, target, s3, extra_args=Non
469494
s3_relative_path = obj_sum.key[len(prefix) :].lstrip("/")
470495
file_path = os.path.join(target, s3_relative_path)
471496

497+
validate_path_within_directory(file_path, target, source_description=obj_sum.key)
498+
472499
try:
473500
os.makedirs(os.path.dirname(file_path))
474501
except OSError as exc:

‎tests/unit/sagemaker/local/test_local_entities.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,44 @@ def test_local_transform_job_perform_batch_inference(
173173
assert "file2.out" in output_files
174174

175175

176+
def test_local_transform_job_perform_batch_inference_path_traversal(
177+
sagemaker_local_session, tmpdir
178+
):
179+
"""Test that file paths resolving outside working_dir are blocked (CWE-22)."""
180+
# Build a dedicated job instance to avoid mutating the shared session-scoped fixture.
181+
with patch(
182+
"sagemaker.local.local_session.LocalSagemakerClient.describe_model"
183+
) as describe_model:
184+
describe_model.return_value = {
185+
"PrimaryContainer": {"Environment": {}, "Image": "some-image:1.0"}
186+
}
187+
job = sagemaker.local.entities._LocalTransformJob(
188+
"traversal-transform-job", "some-model", sagemaker_local_session
189+
)
190+
191+
working_dir = str(tmpdir.mkdir("working"))
192+
dataset_dir = str(tmpdir.mkdir("dataset"))
193+
194+
job._get_working_directory = Mock(return_value=working_dir)
195+
196+
data_source = Mock()
197+
data_source.get_root_dir.return_value = dataset_dir
198+
# A file that resolves outside working_dir via '..' relative path
199+
traversal_file = os.path.join(dataset_dir, "..", "..", "..", "etc", "passwd")
200+
data_source.get_file_list.return_value = [traversal_file]
201+
202+
batch_provider = Mock()
203+
job._prepare_data_transformation = Mock(return_value=(data_source, batch_provider))
204+
205+
input_data = {"ContentType": "text/csv"}
206+
output_data = {"S3OutputPath": "s3://bucket/output", "Accept": "text/csv"}
207+
208+
with pytest.raises(ValueError, match="Path traversal detected"):
209+
job._perform_batch_inference(
210+
input_data, output_data, BatchStrategy="MultiRecord", MaxPayloadInMB="6"
211+
)
212+
213+
176214
@patch("sagemaker.local.entities._SageMakerContainer", Mock())
177215
@patch("sagemaker.local.entities.get_docker_host")
178216
@patch("sagemaker.local.entities._perform_request")

‎tests/unit/sagemaker/serve/model_format/mlflow/test_mlflow_utils.py‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from __future__ import absolute_import
1414

1515
import os
16+
import tempfile
1617
from pathlib import Path
1718
from unittest.mock import patch, MagicMock, mock_open
1819

@@ -230,6 +231,60 @@ def test_download_s3_artifacts_valid_s3_path(mock_os_makedirs, mock_session):
230231
)
231232

232233

234+
@patch("sagemaker.Session")
235+
@patch("os.makedirs")
236+
def test_download_s3_artifacts_path_traversal_via_dotdot_in_key(mock_os_makedirs, mock_session):
237+
"""Test that S3 keys with '..' traversal sequences are blocked."""
238+
mock_s3_client = MagicMock()
239+
mock_s3_client.get_paginator.return_value.paginate.return_value = [
240+
{"Contents": [{"Key": "model/../../../../etc/passwd"}]}
241+
]
242+
mock_session.boto_session.client.return_value = mock_s3_client
243+
mock_session._get_account_id_if_default_bucket = lambda bucket: None
244+
245+
with tempfile.TemporaryDirectory() as tmpdir:
246+
with pytest.raises(ValueError, match="Path traversal detected"):
247+
_download_s3_artifacts("s3://my-bucket/model", tmpdir, mock_session)
248+
249+
mock_s3_client.download_file.assert_not_called()
250+
251+
252+
@patch("sagemaker.Session")
253+
@patch("os.makedirs")
254+
def test_download_s3_artifacts_path_traversal_overwrite_ssh_keys(mock_os_makedirs, mock_session):
255+
"""Test the attack scenario from the vulnerability report targeting SSH keys."""
256+
mock_s3_client = MagicMock()
257+
mock_s3_client.get_paginator.return_value.paginate.return_value = [
258+
{"Contents": [{"Key": "mlruns/exp1/model/../../../../Users/alice/.ssh/authorized_keys"}]}
259+
]
260+
mock_session.boto_session.client.return_value = mock_s3_client
261+
mock_session._get_account_id_if_default_bucket = lambda bucket: None
262+
263+
with tempfile.TemporaryDirectory() as tmpdir:
264+
with pytest.raises(ValueError, match="Path traversal detected"):
265+
_download_s3_artifacts(
266+
"s3://shared-bucket/mlruns/exp1/model", tmpdir, mock_session
267+
)
268+
269+
mock_s3_client.download_file.assert_not_called()
270+
271+
272+
@patch("sagemaker.Session")
273+
def test_download_s3_artifacts_safe_keys_are_allowed(mock_session):
274+
"""Test that normal S3 keys within the target directory are allowed."""
275+
mock_s3_client = MagicMock()
276+
mock_s3_client.get_paginator.return_value.paginate.return_value = [
277+
{"Contents": [{"Key": "model/MLmodel"}, {"Key": "model/subdir/model.pkl"}]}
278+
]
279+
mock_session.boto_session.client.return_value = mock_s3_client
280+
mock_session._get_account_id_if_default_bucket = lambda bucket: None
281+
282+
with tempfile.TemporaryDirectory() as tmpdir:
283+
_download_s3_artifacts("s3://my-bucket/model", tmpdir, mock_session)
284+
285+
assert mock_s3_client.download_file.call_count == 2
286+
287+
233288
@patch("sagemaker.image_uris.retrieve")
234289
@patch("sagemaker.serve.model_format.mlflow.utils._cast_to_compatible_version")
235290
@patch("sagemaker.serve.model_format.mlflow.utils._get_framework_version_from_requirements")

‎tests/unit/test_session.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import json
1919
import logging
2020
import os
21+
import tempfile
2122

2223
import pytest
2324
import six
@@ -7052,6 +7053,68 @@ def test_download_data_with_file_and_directory(makedirs, sagemaker_session):
70527053
)
70537054

70547055

7056+
def test_download_data_path_traversal_in_file_key(sagemaker_session):
7057+
"""Test that S3 keys with '..' traversal sequences are blocked."""
7058+
sagemaker_session.s3_client = Mock()
7059+
sagemaker_session.s3_client.list_objects_v2 = Mock(
7060+
return_value={
7061+
"Contents": [
7062+
{"Key": "data/../../../../etc/passwd", "Size": 100},
7063+
]
7064+
}
7065+
)
7066+
7067+
with tempfile.TemporaryDirectory() as tmpdir:
7068+
with pytest.raises(ValueError, match="Path traversal detected"):
7069+
sagemaker_session.download_data(
7070+
path=tmpdir, bucket="foo-bucket", key_prefix="data/"
7071+
)
7072+
7073+
sagemaker_session.s3_client.download_file.assert_not_called()
7074+
7075+
7076+
def test_download_data_path_traversal_overwrite_credentials(sagemaker_session):
7077+
"""Test the exact attack scenario from the vulnerability report."""
7078+
sagemaker_session.s3_client = Mock()
7079+
sagemaker_session.s3_client.list_objects_v2 = Mock(
7080+
return_value={
7081+
"Contents": [
7082+
{"Key": "data/../../../../Users/alice/.aws/credentials", "Size": 200},
7083+
]
7084+
}
7085+
)
7086+
7087+
with tempfile.TemporaryDirectory() as tmpdir:
7088+
with pytest.raises(ValueError, match="Path traversal detected"):
7089+
sagemaker_session.download_data(
7090+
path=tmpdir, bucket="shared-bucket", key_prefix="data/"
7091+
)
7092+
7093+
sagemaker_session.s3_client.download_file.assert_not_called()
7094+
7095+
7096+
@patch("os.makedirs")
7097+
def test_download_data_safe_keys_are_allowed(makedirs, sagemaker_session):
7098+
"""Test that normal S3 keys within the target directory are allowed."""
7099+
sagemaker_session.s3_client = Mock()
7100+
sagemaker_session.s3_client.list_objects_v2 = Mock(
7101+
return_value={
7102+
"Contents": [
7103+
{"Key": "data/train.csv", "Size": 100},
7104+
{"Key": "data/models/model.pkl", "Size": 500},
7105+
]
7106+
}
7107+
)
7108+
7109+
with tempfile.TemporaryDirectory() as tmpdir:
7110+
result = sagemaker_session.download_data(
7111+
path=tmpdir, bucket="foo-bucket", key_prefix="data/"
7112+
)
7113+
7114+
assert len(result) == 2
7115+
assert sagemaker_session.s3_client.download_file.call_count == 2
7116+
7117+
70557118
def test_create_hub(sagemaker_session):
70567119
sagemaker_session.create_hub(
70577120
hub_name="mock-hub-name",

0 commit comments

Comments
 (0)