@@ -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