Skip to content

Commit 48d30e0

Browse files
authored
S3: fix aws-global validation in CreateBucket (#13250)
1 parent 73e4f6c commit 48d30e0

File tree

9 files changed

+220
-19
lines changed

9 files changed

+220
-19
lines changed

localstack-core/localstack/aws/api/s3/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1061,6 +1061,14 @@ class BadDigest(ServiceException):
10611061
CalculatedDigest: Optional[ContentMD5]
10621062

10631063

1064+
class AuthorizationHeaderMalformed(ServiceException):
1065+
code: str = "AuthorizationHeaderMalformed"
1066+
sender_fault: bool = False
1067+
status_code: int = 400
1068+
Region: Optional[BucketRegion]
1069+
HostId: Optional[HostId]
1070+
1071+
10641072
AbortDate = datetime
10651073

10661074

localstack-core/localstack/aws/spec-patches.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,26 @@
13291329
"documentation": "<p>The Content-MD5 you specified did not match what we received.</p>",
13301330
"exception": true
13311331
}
1332+
},
1333+
{
1334+
"op": "add",
1335+
"path": "/shapes/AuthorizationHeaderMalformed",
1336+
"value": {
1337+
"type": "structure",
1338+
"members": {
1339+
"Region": {
1340+
"shape": "BucketRegion"
1341+
},
1342+
"HostId": {
1343+
"shape": "HostId"
1344+
}
1345+
},
1346+
"error": {
1347+
"httpStatusCode": 400
1348+
},
1349+
"documentation": "<p>The authorization header is malformed.</p>",
1350+
"exception": true
1351+
}
13321352
}
13331353
],
13341354
"apigatewayv2/2018-11-29/service-2": [

localstack-core/localstack/services/s3/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
DEFAULT_PRE_SIGNED_ACCESS_KEY_ID = "test"
2222
DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY = "test"
2323

24+
S3_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg="
25+
"""
26+
S3 is returning a Host Id as part of its exceptions
27+
"""
28+
2429
AUTHENTICATED_USERS_ACL_GROUP = "http://acs.amazonaws.com/groups/global/AuthenticatedUsers"
2530
ALL_USERS_ACL_GROUP = "http://acs.amazonaws.com/groups/global/AllUsers"
2631
LOG_DELIVERY_ACL_GROUP = "http://acs.amazonaws.com/groups/s3/LogDelivery"

localstack-core/localstack/services/s3/cors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,13 @@
2121
from localstack.aws.spec import get_service_catalog
2222
from localstack.config import S3_VIRTUAL_HOSTNAME
2323
from localstack.http import Request, Response
24+
from localstack.services.s3.constants import S3_HOST_ID
2425
from localstack.services.s3.utils import S3_VIRTUAL_HOSTNAME_REGEX
2526

2627
# TODO: add more logging statements
2728
LOG = logging.getLogger(__name__)
2829

2930
_s3_virtual_host_regex = re.compile(S3_VIRTUAL_HOSTNAME_REGEX)
30-
FAKE_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg="
3131

3232
# TODO: refactor those to expose the needed methods maybe in another way that both can import
3333
add_default_headers = CorsResponseEnricher.add_cors_headers
@@ -135,7 +135,7 @@ def stop_options_chain():
135135
if is_options_request:
136136
context.operation = self._get_op_from_request(request)
137137
raise BadRequest(
138-
"Insufficient information. Origin request header needed.", HostId=FAKE_HOST_ID
138+
"Insufficient information. Origin request header needed.", HostId=S3_HOST_ID
139139
)
140140
else:
141141
# If the header is missing, Amazon S3 doesn't treat the request as a cross-origin request,
@@ -167,7 +167,7 @@ def stop_options_chain():
167167
context.operation = self._get_op_from_request(request)
168168
raise AccessForbidden(
169169
message,
170-
HostId=FAKE_HOST_ID,
170+
HostId=S3_HOST_ID,
171171
Method=request.headers.get("Access-Control-Request-Method", "OPTIONS"),
172172
ResourceType="BUCKET",
173173
)
@@ -182,7 +182,7 @@ def stop_options_chain():
182182
context.operation = self._get_op_from_request(request)
183183
raise AccessForbidden(
184184
"CORSResponse: This CORS request is not allowed. This is usually because the evalution of Origin, request method / Access-Control-Request-Method or Access-Control-Request-Headers are not whitelisted by the resource's CORS spec.",
185-
HostId=FAKE_HOST_ID,
185+
HostId=S3_HOST_ID,
186186
Method=request.headers.get("Access-Control-Request-Method"),
187187
ResourceType="OBJECT",
188188
)

localstack-core/localstack/services/s3/presigned_url.py

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from localstack.services.s3.constants import (
4141
DEFAULT_PRE_SIGNED_ACCESS_KEY_ID,
4242
DEFAULT_PRE_SIGNED_SECRET_ACCESS_KEY,
43+
S3_HOST_ID,
4344
SIGNATURE_V2_PARAMS,
4445
SIGNATURE_V4_PARAMS,
4546
)
@@ -85,8 +86,6 @@
8586
"x-amz-content-sha256",
8687
]
8788

88-
FAKE_HOST_ID = "9Gjjt1m+cjU4OPvX9O9/8RuvnG41MRb/18Oux2o5H5MY7ISNTlXN+Dz9IG62/ILVxhAGI0qyPfg="
89-
9089
HOST_COMBINATION_REGEX = r"^(.*)(:[\d]{0,6})"
9190
PORT_REPLACEMENT = [":80", ":443", f":{config.GATEWAY_LISTEN[0].port}", ""]
9291

@@ -156,7 +155,7 @@ def create_signature_does_not_match_sig_v2(
156155
"The request signature we calculated does not match the signature you provided. Check your key and signing method."
157156
)
158157
ex.AWSAccessKeyId = access_key_id
159-
ex.HostId = FAKE_HOST_ID
158+
ex.HostId = S3_HOST_ID
160159
ex.SignatureProvided = request_signature
161160
ex.StringToSign = string_to_sign
162161
ex.StringToSignBytes = to_bytes(string_to_sign).hex(sep=" ", bytes_per_sep=2).upper()
@@ -299,7 +298,7 @@ def is_valid_sig_v2(query_args: set) -> bool:
299298
LOG.info("Presign signature calculation failed")
300299
raise AccessDenied(
301300
"Query-string authentication requires the Signature, Expires and AWSAccessKeyId parameters",
302-
HostId=FAKE_HOST_ID,
301+
HostId=S3_HOST_ID,
303302
)
304303

305304
return True
@@ -317,7 +316,7 @@ def is_valid_sig_v4(query_args: set) -> bool:
317316
LOG.info("Presign signature calculation failed")
318317
raise AuthorizationQueryParametersError(
319318
"Query-string authentication version 4 requires the X-Amz-Algorithm, X-Amz-Credential, X-Amz-Signature, X-Amz-Date, X-Amz-SignedHeaders, and X-Amz-Expires parameters.",
320-
HostId=FAKE_HOST_ID,
319+
HostId=S3_HOST_ID,
321320
)
322321

323322
return True
@@ -351,7 +350,7 @@ def validate_presigned_url_s3(context: RequestContext) -> None:
351350
)
352351
else:
353352
raise AccessDenied(
354-
"Request has expired", HostId=FAKE_HOST_ID, Expires=expires, ServerTime=time.time()
353+
"Request has expired", HostId=S3_HOST_ID, Expires=expires, ServerTime=time.time()
355354
)
356355

357356
auth_signer = HmacV1QueryAuthValidation(credentials=signing_credentials, expires=expires)
@@ -450,7 +449,7 @@ def validate_presigned_url_s3v4(context: RequestContext) -> None:
450449
else:
451450
raise AccessDenied(
452451
"There were headers present in the request which were not signed",
453-
HostId=FAKE_HOST_ID,
452+
HostId=S3_HOST_ID,
454453
HeadersNotSigned=", ".join(sigv4_context.missing_signed_headers),
455454
)
456455

@@ -482,7 +481,7 @@ def validate_presigned_url_s3v4(context: RequestContext) -> None:
482481
else:
483482
raise AccessDenied(
484483
"Request has expired",
485-
HostId=FAKE_HOST_ID,
484+
HostId=S3_HOST_ID,
486485
Expires=expiration_time.timestamp(),
487486
ServerTime=time.time(),
488487
X_Amz_Expires=x_amz_expires,
@@ -714,7 +713,7 @@ def _get_region_from_x_amz_credential(credential: str) -> str:
714713
if not (split_creds := credential.split("/")) or len(split_creds) != 5:
715714
raise AuthorizationQueryParametersError(
716715
'Error parsing the X-Amz-Credential parameter; the Credential is mal-formed; expecting "<YOUR-AKID>/YYYYMMDD/REGION/SERVICE/aws4_request".',
717-
HostId=FAKE_HOST_ID,
716+
HostId=S3_HOST_ID,
718717
)
719718

720719
return split_creds[2]
@@ -775,7 +774,7 @@ def validate_post_policy(
775774
"Bucket POST must contain a field named 'key'. If it is specified, please check the order of the fields.",
776775
ArgumentName="key",
777776
ArgumentValue="",
778-
HostId=FAKE_HOST_ID,
777+
HostId=S3_HOST_ID,
779778
)
780779

781780
form_dict = {k.lower(): v for k, v in request_form.items()}
@@ -791,7 +790,7 @@ def validate_post_policy(
791790

792791
if not is_v2 and not is_v4:
793792
ex: AccessDenied = AccessDenied("Access Denied")
794-
ex.HostId = FAKE_HOST_ID
793+
ex.HostId = S3_HOST_ID
795794
raise ex
796795

797796
try:
@@ -810,7 +809,7 @@ def validate_post_policy(
810809
if expiration := policy_decoded.get("expiration"):
811810
if is_expired(_parse_policy_expiration_date(expiration)):
812811
ex: AccessDenied = AccessDenied("Invalid according to Policy: Policy expired.")
813-
ex.HostId = FAKE_HOST_ID
812+
ex.HostId = S3_HOST_ID
814813
raise ex
815814

816815
# TODO: validate the signature
@@ -832,7 +831,7 @@ def validate_post_policy(
832831
str_condition = str(condition).replace("'", '"')
833832
raise AccessDenied(
834833
f"Invalid according to Policy: Policy Condition failed: {str_condition}",
835-
HostId=FAKE_HOST_ID,
834+
HostId=S3_HOST_ID,
836835
)
837836

838837

@@ -885,7 +884,7 @@ def _verify_condition(condition: list | dict, form: dict, additional_policy_meta
885884
"Your proposed upload exceeds the maximum allowed size",
886885
ProposedSize=size,
887886
MaxSizeAllowed=end,
888-
HostId=FAKE_HOST_ID,
887+
HostId=S3_HOST_ID,
889888
)
890889
else:
891890
return True
@@ -934,7 +933,7 @@ def _is_match_with_signature_fields(
934933
f"Bucket POST must contain a field named '{argument_name}'. If it is specified, please check the order of the fields.",
935934
ArgumentName=argument_name,
936935
ArgumentValue="",
937-
HostId=FAKE_HOST_ID,
936+
HostId=S3_HOST_ID,
938937
)
939938

940939
return True

localstack-core/localstack/services/s3/provider.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
AccountId,
2424
AnalyticsConfiguration,
2525
AnalyticsId,
26+
AuthorizationHeaderMalformed,
2627
BadDigest,
2728
Body,
2829
Bucket,
@@ -235,6 +236,7 @@
235236
ARCHIVES_STORAGE_CLASSES,
236237
CHECKSUM_ALGORITHMS,
237238
DEFAULT_BUCKET_ENCRYPTION,
239+
S3_HOST_ID,
238240
)
239241
from localstack.services.s3.cors import S3CorsHandler, s3_cors_request_handler
240242
from localstack.services.s3.exceptions import (
@@ -470,6 +472,16 @@ def create_bucket(
470472
context: RequestContext,
471473
request: CreateBucketRequest,
472474
) -> CreateBucketOutput:
475+
if context.region == "aws-global":
476+
# TODO: extend this logic to probably all the provider, and maybe all services. S3 is the most impacted
477+
# right now so this will help users to properly set a region in their config
478+
# See the `TestS3.test_create_bucket_aws_global` test
479+
raise AuthorizationHeaderMalformed(
480+
f"The authorization header is malformed; the region 'aws-global' is wrong; expecting '{AWS_REGION_US_EAST_1}'",
481+
HostId=S3_HOST_ID,
482+
Region=AWS_REGION_US_EAST_1,
483+
)
484+
473485
bucket_name = request["Bucket"]
474486

475487
if not is_bucket_name_valid(bucket_name):
@@ -637,6 +649,16 @@ def head_bucket(
637649
expected_bucket_owner: AccountId = None,
638650
**kwargs,
639651
) -> HeadBucketOutput:
652+
if context.region == "aws-global":
653+
# TODO: extend this logic to probably all the provider, and maybe all services. S3 is the most impacted
654+
# right now so this will help users to properly set a region in their config
655+
# See the `TestS3.test_create_bucket_aws_global` test
656+
raise AuthorizationHeaderMalformed(
657+
f"The authorization header is malformed; the region 'aws-global' is wrong; expecting '{AWS_REGION_US_EAST_1}'",
658+
HostId=S3_HOST_ID,
659+
Region=AWS_REGION_US_EAST_1,
660+
)
661+
640662
store = self.get_store(context.account_id, context.region)
641663
if not (s3_bucket := store.buckets.get(bucket)):
642664
if not (account_id := store.global_bucket_map.get(bucket)):

tests/aws/services/s3/test_s3.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4150,6 +4150,90 @@ def test_access_bucket_different_region(self, s3_create_bucket, s3_vhost_client,
41504150
assert response.status_code == 200
41514151
assert response.history[0].status_code == 307
41524152

4153+
@markers.aws.validated
4154+
@markers.requires_in_process # we're monkeypatching the handler chain
4155+
@markers.snapshot.skip_snapshot_verify(
4156+
# missing from `HeadBucket` call
4157+
paths=["$..AccessPointAlias"],
4158+
)
4159+
def test_create_bucket_aws_global(
4160+
self,
4161+
aws_client_factory,
4162+
cleanups,
4163+
aws_client,
4164+
snapshot,
4165+
aws_http_client_factory,
4166+
monkeypatch,
4167+
):
4168+
"""
4169+
Some tools use the `aws-global` region instead of `us-east-1` when no region are defined. It is considered
4170+
a valid region by Botocore too when creating the client, as it is a pseudo-region used for endpoint resolving.
4171+
The client is however supposed to then use `us-east-1` to sign the request, not `aws-global`.
4172+
Using `aws-global` to sign the request results in an error in AWS.
4173+
It seems some tools like the Go SDK used by Terraform might have the wrong logic, and skip the part where
4174+
they are supposed to sign with `us-east-1` if you override the endpoint url and skip the endpoint
4175+
resolving part, which is how we end up with those kind of requests.
4176+
"""
4177+
# we need to patch the `DefaultRegionRewriterStrategy` as it wil replace `aws-global` by `us-east-1`, which
4178+
# is its default region
4179+
from localstack.aws.handlers.region import DefaultRegionRewriterStrategy
4180+
4181+
monkeypatch.setattr(DefaultRegionRewriterStrategy, "apply", lambda *_, **__: None)
4182+
4183+
global_region = "aws-global"
4184+
bucket_prefix = f"global-bucket-{short_uid()}"
4185+
bucket_name_1 = f"{bucket_prefix}-{short_uid()}"
4186+
headers = {"x-amz-content-sha256": "UNSIGNED-PAYLOAD"}
4187+
4188+
snapshot.add_transformers_list(
4189+
[
4190+
snapshot.transform.regex(bucket_name_1, "<bucket-name-1>"),
4191+
snapshot.transform.regex(bucket_prefix, "<bucket-prefix>"),
4192+
snapshot.transform.regex(AWS_REGION_US_EAST_1, "<us_east_1_region>"),
4193+
snapshot.transform.key_value("DisplayName"),
4194+
snapshot.transform.key_value("ID"),
4195+
snapshot.transform.key_value("HostId"),
4196+
snapshot.transform.key_value("RequestId"),
4197+
]
4198+
)
4199+
http_client = aws_http_client_factory(
4200+
service="s3", signer_factory=SigV4Auth, region=global_region
4201+
)
4202+
# use the global endpoint with no region
4203+
base_endpoint = _endpoint_url(region=AWS_REGION_US_EAST_1)
4204+
response = http_client.put(f"{base_endpoint}/{bucket_name_1}", headers=headers)
4205+
if response.ok:
4206+
# we still clean up even if we expect it to fail, just in case it doesn't fail in AWS
4207+
cleanups.append(lambda: aws_client.s3.delete_bucket(Bucket=bucket_name_1))
4208+
4209+
assert response.status_code == 400
4210+
xml_error = xmltodict.parse(response.content)
4211+
snapshot.match("xml-error-create-bucket", xml_error)
4212+
4213+
# botocore is automatically signing the request with `us-east-1`, so we don't have issues
4214+
s3_client = aws_client_factory(region_name=global_region).s3
4215+
create_bucket = s3_client.create_bucket(Bucket=bucket_name_1)
4216+
cleanups.append(lambda: aws_client.s3.delete_bucket(Bucket=bucket_name_1))
4217+
snapshot.match("create-bucket-global", create_bucket)
4218+
4219+
head_bucket = s3_client.head_bucket(Bucket=bucket_name_1)
4220+
snapshot.match("head-bucket-global", head_bucket)
4221+
4222+
get_location_1 = aws_client.s3.get_bucket_location(Bucket=bucket_name_1)
4223+
# verify that the bucket 1 is created in `us-east-1`
4224+
snapshot.match("get-location-1", get_location_1)
4225+
4226+
list_buckets_per_region = aws_client.s3.list_buckets(
4227+
BucketRegion=AWS_REGION_US_EAST_1, Prefix=bucket_prefix
4228+
)
4229+
snapshot.match("list-buckets", list_buckets_per_region)
4230+
4231+
# test that HeadBucket also raises an exception
4232+
head_response = http_client.head(f"{base_endpoint}/{bucket_name_1}", headers=headers)
4233+
4234+
assert head_response.status_code == 400
4235+
assert not head_response.content
4236+
41534237
@markers.aws.validated
41544238
def test_bucket_does_not_exist(self, s3_vhost_client, snapshot, aws_client):
41554239
snapshot.add_transformer(snapshot.transform.s3_api())

0 commit comments

Comments
 (0)