Skip to content

Commit fa129a7

Browse files
authored
fix: s3 bucket operations (#5801)
Add ExpectedBucketOwner to S3 data-plane calls
1 parent d05d411 commit fa129a7

11 files changed

Lines changed: 921 additions & 26 deletions

File tree

‎sagemaker-core/src/sagemaker/core/common_utils.py‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -429,12 +429,21 @@ def download_folder(bucket_name, prefix, target, sagemaker_session):
429429
if ".." in prefix:
430430
raise ValueError("Traversal components are not allowed in S3 path!")
431431

432+
# Spot check: enforce ownership only when downloading from the session's default
433+
# bucket. Cross-account buckets are left untouched.
434+
expected_owner = sagemaker_session._get_account_id_if_default_bucket(bucket_name)
435+
extra_args = None
436+
if expected_owner:
437+
extra_args = {"ExpectedBucketOwner": expected_owner}
438+
432439
# Try to download the prefix as an object first, in case it is a file and not a 'directory'.
433440
# Do this first, in case the object has broader permissions than the bucket.
434441
if not prefix.endswith("/"):
435442
try:
436443
file_destination = os.path.join(target, os.path.basename(prefix))
437-
s3.Object(bucket_name, prefix).download_file(file_destination)
444+
s3.Object(bucket_name, prefix).download_file(
445+
file_destination, ExtraArgs=extra_args
446+
)
438447
return
439448
except botocore.exceptions.ClientError as e:
440449
err_info = e.response["Error"]
@@ -445,17 +454,19 @@ def download_folder(bucket_name, prefix, target, sagemaker_session):
445454
else:
446455
raise
447456

448-
_download_files_under_prefix(bucket_name, prefix, target, s3)
457+
_download_files_under_prefix(bucket_name, prefix, target, s3, extra_args=extra_args)
449458

450459

451-
def _download_files_under_prefix(bucket_name, prefix, target, s3):
460+
def _download_files_under_prefix(bucket_name, prefix, target, s3, extra_args=None):
452461
"""Download all S3 files which match the given prefix
453462
454463
Args:
455464
bucket_name (str): S3 bucket name
456465
prefix (str): S3 prefix within the bucket that will be downloaded
457466
target (str): destination path where the downloaded items will be placed
458467
s3 (boto3.resources.base.ServiceResource): S3 resource
468+
extra_args (dict): Optional extra arguments passed to each download_file call.
469+
Used to carry ExpectedBucketOwner when the bucket is the session's default.
459470
"""
460471
target_real = os.path.realpath(target)
461472
bucket = s3.Bucket(bucket_name)
@@ -476,7 +487,7 @@ def _download_files_under_prefix(bucket_name, prefix, target, s3):
476487
# anything else will be raised.
477488
if exc.errno != errno.EEXIST:
478489
raise
479-
obj.download_file(file_path)
490+
obj.download_file(file_path, ExtraArgs=extra_args)
480491

481492

482493
def create_tar_file(source_files, target=None):
@@ -623,6 +634,16 @@ def _save_model(repacked_model_uri, tmp_model_path, sagemaker_session, kms_key):
623634
extra_args = {"ServerSideEncryption": "aws:kms"}
624635
else:
625636
extra_args = None
637+
638+
# Spot check: when the model is being uploaded to the session's default bucket,
639+
# assert ownership to defend against bucket-squatting on the predictable default
640+
# name. Other caller-supplied buckets are left untouched.
641+
if sagemaker_session is not None:
642+
expected_owner = sagemaker_session._get_account_id_if_default_bucket(bucket)
643+
if expected_owner:
644+
extra_args = dict(extra_args) if extra_args else {}
645+
extra_args["ExpectedBucketOwner"] = expected_owner
646+
626647
sagemaker_session.boto_session.resource(
627648
"s3", region_name=sagemaker_session.boto_region_name
628649
).Object(bucket, new_key).upload_file(tmp_model_path, ExtraArgs=extra_args)
@@ -770,7 +791,17 @@ def download_file(bucket_name, path, target, sagemaker_session):
770791

771792
s3 = boto_session.resource("s3", region_name=sagemaker_session.boto_region_name)
772793
bucket = s3.Bucket(bucket_name)
773-
bucket.download_file(path, target)
794+
795+
# Spot check: assert ownership only when downloading from the session's default
796+
# bucket. Non-default buckets (e.g. caller-supplied model URIs pointing at shared
797+
# or cross-account data) are downloaded without ExpectedBucketOwner to preserve
798+
# legitimate cross-account flows.
799+
expected_owner = sagemaker_session._get_account_id_if_default_bucket(bucket_name)
800+
extra_args = None
801+
if expected_owner:
802+
extra_args = {"ExpectedBucketOwner": expected_owner}
803+
804+
bucket.download_file(path, target, ExtraArgs=extra_args)
774805

775806

776807
def sts_regional_endpoint(region):

‎sagemaker-core/src/sagemaker/core/experiments/_helper.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ def upload_artifact(self, file_path, extra_args=None):
9595
artifact_s3_key = "{}/{}/{}".format(
9696
self.artifact_prefix, self.trial_component_name, artifact_name
9797
)
98+
99+
# Spot check: enforce ownership only when uploading to the session's default
100+
# bucket. Cross-account destinations are left untouched.
101+
expected_owner = self.sagemaker_session._get_account_id_if_default_bucket(
102+
self.artifact_bucket
103+
)
104+
if expected_owner:
105+
extra_args = dict(extra_args) if extra_args else {}
106+
extra_args["ExpectedBucketOwner"] = expected_owner
107+
98108
self._s3_client.upload_file(
99109
file_path,
100110
self.artifact_bucket,
@@ -133,9 +143,21 @@ def upload_object_artifact(self, artifact_name, artifact_object, file_extension=
133143
artifact_s3_key = "{}/{}/{}".format(
134144
self.artifact_prefix, self.trial_component_name, artifact_name
135145
)
136-
self._s3_client.put_object(
137-
Body=json.dumps(artifact_object), Bucket=self.artifact_bucket, Key=artifact_s3_key
146+
147+
# Spot check: enforce ownership only when uploading to the session's default
148+
# bucket. Cross-account destinations are left untouched.
149+
put_kwargs = {
150+
"Body": json.dumps(artifact_object),
151+
"Bucket": self.artifact_bucket,
152+
"Key": artifact_s3_key,
153+
}
154+
expected_owner = self.sagemaker_session._get_account_id_if_default_bucket(
155+
self.artifact_bucket
138156
)
157+
if expected_owner:
158+
put_kwargs["ExpectedBucketOwner"] = expected_owner
159+
160+
self._s3_client.put_object(**put_kwargs)
139161
etag = self._try_get_etag(artifact_s3_key)
140162
return "s3://{}/{}".format(self.artifact_bucket, artifact_s3_key), etag
141163

@@ -149,7 +171,14 @@ def _try_get_etag(self, key):
149171
str: The S3 object ETag if it allows, otherwise return None.
150172
"""
151173
try:
152-
response = self._s3_client.head_object(Bucket=self.artifact_bucket, Key=key)
174+
head_kwargs = {"Bucket": self.artifact_bucket, "Key": key}
175+
expected_owner = self.sagemaker_session._get_account_id_if_default_bucket(
176+
self.artifact_bucket
177+
)
178+
if expected_owner:
179+
head_kwargs["ExpectedBucketOwner"] = expected_owner
180+
181+
response = self._s3_client.head_object(**head_kwargs)
153182
return response["ETag"]
154183
except botocore.exceptions.ClientError as error:
155184
# requires read permissions

‎sagemaker-core/src/sagemaker/core/fw_utils.py‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ def tar_and_upload_dir(
400400
kms_key=None,
401401
s3_resource=None,
402402
settings: Optional[SessionSettings] = None,
403+
expected_bucket_owner: Optional[str] = None,
403404
) -> UploadedCode:
404405
"""Package source files and upload a compress tar file to S3.
405406
@@ -430,6 +431,12 @@ def tar_and_upload_dir(
430431
settings (sagemaker.session_settings.SessionSettings): Optional. The settings
431432
of the SageMaker ``Session``, can be used to override the default encryption
432433
behavior (default: None).
434+
expected_bucket_owner (str): Optional. AWS account id passed as
435+
``ExpectedBucketOwner`` on the upload. Callers should supply this when
436+
``bucket`` is the session's default bucket (via
437+
``Session._get_account_id_if_default_bucket``) to defend against
438+
bucket-squatting on the predictable default name. Leave as ``None`` for
439+
cross-account destination buckets.
433440
Returns:
434441
sagemaker.fw_utils.UploadedCode: An object with the S3 bucket and key (S3 prefix) and
435442
script name.
@@ -471,6 +478,10 @@ def tar_and_upload_dir(
471478
else:
472479
extra_args = None
473480

481+
if expected_bucket_owner:
482+
extra_args = dict(extra_args) if extra_args else {}
483+
extra_args["ExpectedBucketOwner"] = expected_bucket_owner
484+
474485
if s3_resource is None:
475486
s3_resource = session.resource("s3", region_name=session.region_name)
476487
else:

‎sagemaker-core/src/sagemaker/core/helper/session_helper.py‎

Lines changed: 113 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,14 @@ def upload_data(self, path, bucket=None, key_prefix="data", callback=None, extra
429429
bucket=bucket, key_prefix=key_prefix, sagemaker_session=self
430430
)
431431

432+
# Spot check: if the resolved bucket is the session's default bucket, enforce
433+
# ownership on the upload to defend against bucket-squatting on the predictable
434+
# default name. Other buckets are left untouched to preserve cross-account flows.
435+
expected_owner = self._get_account_id_if_default_bucket(bucket)
436+
if expected_owner:
437+
extra_args = dict(extra_args) if extra_args else {}
438+
extra_args["ExpectedBucketOwner"] = expected_owner
439+
432440
# Generate a tuple for each file that we want to upload of the form (local_path, s3_key).
433441
files = []
434442
key_suffix = None
@@ -485,10 +493,17 @@ def upload_string_as_file_body(self, body, bucket, key, kms_key=None):
485493

486494
s3_object = s3.Object(bucket_name=bucket, key=key)
487495

496+
# Spot check: enforce ownership only when writing to the session's default
497+
# bucket. Cross-account destinations are left untouched.
498+
put_kwargs = {"Body": body}
488499
if kms_key is not None:
489-
s3_object.put(Body=body, SSEKMSKeyId=kms_key, ServerSideEncryption="aws:kms")
490-
else:
491-
s3_object.put(Body=body)
500+
put_kwargs["SSEKMSKeyId"] = kms_key
501+
put_kwargs["ServerSideEncryption"] = "aws:kms"
502+
expected_owner = self._get_account_id_if_default_bucket(bucket)
503+
if expected_owner:
504+
put_kwargs["ExpectedBucketOwner"] = expected_owner
505+
506+
s3_object.put(**put_kwargs)
492507

493508
s3_uri = "s3://{}/{}".format(bucket, key)
494509
return s3_uri
@@ -512,11 +527,19 @@ def download_data(self, path, bucket, key_prefix="", extra_args=None):
512527
else:
513528
s3 = self.s3_client
514529

530+
# Spot check: if the caller-supplied bucket is the session's default bucket,
531+
# assert ownership on the list/download calls to defend against squatting on
532+
# the predictable default bucket name. Non-default buckets are left untouched
533+
# to preserve legitimate cross-account flows.
534+
expected_owner = self._get_account_id_if_default_bucket(bucket)
535+
515536
# Initialize the variables used to loop through the contents of the S3 bucket.
516537
keys = []
517538
directories = []
518539
next_token = ""
519540
base_parameters = {"Bucket": bucket, "Prefix": key_prefix}
541+
if expected_owner:
542+
base_parameters["ExpectedBucketOwner"] = expected_owner
520543

521544
# Loop through the contents of the bucket, 1,000 objects at a time. Gathering all keys into
522545
# a "keys" list.
@@ -543,6 +566,9 @@ def download_data(self, path, bucket, key_prefix="", extra_args=None):
543566

544567
# For each object key, create the directory on the local machine if needed, and then
545568
# download the file.
569+
download_extra_args = dict(extra_args) if extra_args else {}
570+
if expected_owner:
571+
download_extra_args["ExpectedBucketOwner"] = expected_owner
546572
downloaded_paths = []
547573
path_real = os.path.realpath(path)
548574
for dir_path in directories:
@@ -561,7 +587,10 @@ def download_data(self, path, bucket, key_prefix="", extra_args=None):
561587
if not os.path.exists(os.path.dirname(destination_path)):
562588
os.makedirs(os.path.dirname(destination_path), exist_ok=True)
563589
s3.download_file(
564-
Bucket=bucket, Key=key, Filename=destination_path, ExtraArgs=extra_args
590+
Bucket=bucket,
591+
Key=key,
592+
Filename=destination_path,
593+
ExtraArgs=download_extra_args or None,
565594
)
566595
downloaded_paths.append(destination_path)
567596
return downloaded_paths
@@ -581,8 +610,16 @@ def read_s3_file(self, bucket, key_prefix):
581610
else:
582611
s3 = self.s3_client
583612

613+
# Spot check: assert ownership only when the caller is reading from the
614+
# session's default bucket. Other buckets (e.g. JumpStart content buckets)
615+
# are read without ExpectedBucketOwner to preserve cross-account flows.
616+
get_kwargs = {"Bucket": bucket, "Key": key_prefix}
617+
expected_owner = self._get_account_id_if_default_bucket(bucket)
618+
if expected_owner:
619+
get_kwargs["ExpectedBucketOwner"] = expected_owner
620+
584621
# Explicitly passing a None kms_key to boto3 throws a validation error.
585-
s3_object = s3.get_object(Bucket=bucket, Key=key_prefix)
622+
s3_object = s3.get_object(**get_kwargs)
586623

587624
return s3_object["Body"].read().decode("utf-8")
588625

@@ -707,32 +744,50 @@ def general_bucket_check_if_user_has_permission(
707744
If there is any other error that comes up with calling head bucket, it is raised up here
708745
If there is no bucket , it will create one
709746
747+
When the SDK selected the bucket name itself (``_default_bucket_set_by_sdk`` is True),
748+
the probe is issued with ``ExpectedBucketOwner`` set to the caller's account id so that
749+
S3 returns ``403`` if a bucket with the SDK-chosen name happens to exist in another
750+
account (bucket-squatting defense). For user-overridden bucket names the probe is
751+
issued without ``ExpectedBucketOwner`` to preserve legitimate cross-account usage.
752+
710753
Args:
711754
bucket_name (str): Name of the S3 bucket
712755
s3 (str): S3 object from boto session
713756
region (str): The region in which to create the bucket.
714757
bucket_creation_date_none (bool):Indicating whether S3 bucket already exists or not
715758
"""
759+
extra_args = {}
760+
if self._default_bucket_set_by_sdk:
761+
extra_args["ExpectedBucketOwner"] = self.account_id()
762+
716763
try:
717764
if self.default_bucket_prefix:
718765
s3.meta.client.list_objects_v2(
719-
Bucket=bucket_name, Prefix=self.default_bucket_prefix
766+
Bucket=bucket_name, Prefix=self.default_bucket_prefix, **extra_args
720767
)
721768
else:
722-
s3.meta.client.head_bucket(Bucket=bucket_name)
769+
s3.meta.client.head_bucket(Bucket=bucket_name, **extra_args)
723770
except ClientError as e:
724771
error_code = e.response["Error"]["Code"]
725772
message = e.response["Error"]["Message"]
726-
# bucket does not exist or forbidden to access
773+
# bucket does not exist, is owned by another account, or is forbidden to access
727774
if bucket_creation_date_none:
728775
if error_code == "404" and message == "Not Found":
729776
self.create_bucket_for_not_exist_error(bucket_name, region, s3)
730777
elif error_code == "403" and message == "Forbidden":
731-
LOGGER.error(
732-
"Bucket %s exists, but access is forbidden. Please try again after "
733-
"adding appropriate access.",
734-
bucket.name,
735-
)
778+
if self._default_bucket_set_by_sdk:
779+
LOGGER.error(
780+
"Bucket %s is not accessible as the default bucket. It may be "
781+
"owned by another account or access is forbidden. To unblock, "
782+
"pass a custom default_bucket parameter to sagemaker.Session.",
783+
bucket.name,
784+
)
785+
else:
786+
LOGGER.error(
787+
"Bucket %s exists, but access is forbidden. Please try again "
788+
"after adding appropriate access.",
789+
bucket.name,
790+
)
736791
raise
737792
else:
738793
raise
@@ -781,6 +836,51 @@ def generate_default_sagemaker_bucket_name(self, boto_session):
781836
).get_caller_identity()["Account"]
782837
return "sagemaker-{}-{}".format(region, account)
783838

839+
def _get_account_id_if_default_bucket(self, bucket):
840+
"""Return the caller's account id if ``bucket`` is the SDK-generated default bucket.
841+
842+
Used by S3 operations that receive a caller-supplied bucket name to apply the
843+
"spot check": when the bucket matches the SDK-generated default bucket name,
844+
the call should assert ownership via ``ExpectedBucketOwner`` to defend against
845+
bucket-squatting on the predictable default name. For any other bucket — including
846+
user-overridden default buckets (which may legitimately be cross-account),
847+
JumpStart, marketplace artifacts, or shared-team buckets — this returns ``None``
848+
so the call proceeds unchanged.
849+
850+
This check is passive: it does not trigger default-bucket resolution or creation.
851+
If ``default_bucket()`` has not been called yet, this returns ``None``.
852+
853+
Args:
854+
bucket (str): The bucket name the caller is about to use.
855+
856+
Returns:
857+
Optional[str]: The expected account id, or ``None`` if the spot check does
858+
not apply.
859+
"""
860+
if not bucket:
861+
return None
862+
863+
# Only apply the spot check when the SDK generated the bucket name itself.
864+
# User-overridden default buckets (_default_bucket_name_override) may legitimately
865+
# be in another account, so we must not assert caller-account ownership on them.
866+
if not self._default_bucket_set_by_sdk:
867+
return None
868+
869+
# Use the already-resolved default bucket (fast, no side effects).
870+
# _default_bucket is only set after default_bucket() has been called at least once.
871+
if self._default_bucket and bucket == self._default_bucket:
872+
try:
873+
return self.account_id()
874+
except Exception: # pylint: disable=broad-except
875+
# account_id() issues an STS call; if it fails we skip the spot check
876+
# rather than block the S3 operation.
877+
LOGGER.warning(
878+
"Could not resolve caller account id for ExpectedBucketOwner check "
879+
"on bucket %s; proceeding without the check.",
880+
bucket,
881+
)
882+
return None
883+
784884
def determine_bucket_and_prefix(
785885
self, bucket: Optional[str] = None, key_prefix: Optional[str] = None, sagemaker_session=None
786886
):

0 commit comments

Comments
 (0)