diff --git a/localstack-core/localstack/services/s3/cors.py b/localstack-core/localstack/services/s3/cors.py index 325393e724a92..82193557571f0 100644 --- a/localstack-core/localstack/services/s3/cors.py +++ b/localstack-core/localstack/services/s3/cors.py @@ -205,7 +205,12 @@ def stop_options_chain(): if requested_headers := request.headers.get("Access-Control-Request-Headers"): # if the rule matched, it means all Requested Headers are allowed - response.headers["Access-Control-Allow-Headers"] = requested_headers.lower() + requested_headers_formatted = [ + header.strip().lower() for header in requested_headers.split(",") + ] + response.headers["Access-Control-Allow-Headers"] = ", ".join( + requested_headers_formatted + ) if expose_headers := rule.get("ExposeHeaders"): response.headers["Access-Control-Expose-Headers"] = ", ".join(expose_headers) @@ -266,8 +271,8 @@ def _match_rule(rule: CORSRule, method: str, headers: Headers) -> Optional[CORSR lower_case_allowed_headers = {header.lower() for header in allowed_headers} if "*" not in allowed_headers and not all( - header in lower_case_allowed_headers - for header in request_headers.lower().split(", ") + header.strip() in lower_case_allowed_headers + for header in request_headers.lower().split(",") ): return diff --git a/tests/aws/services/s3/test_s3_cors.py b/tests/aws/services/s3/test_s3_cors.py index 2a9956edfa123..916efe830d552 100644 --- a/tests/aws/services/s3/test_s3_cors.py +++ b/tests/aws/services/s3/test_s3_cors.py @@ -551,6 +551,18 @@ def test_cors_match_headers( match_headers("opt-get-allowed-diff-casing", opt_req) assert opt_req.ok + # test with specific headers and no space after the comma + opt_req = requests.options( + key_url, + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + "Access-Control-Request-Headers": "x-amz-expected-bucket-owner,x-amz-server-side-encryption", + }, + ) + match_headers("opt-get-allowed-no-space", opt_req) + assert opt_req.ok + # test GET with Access-Control-Request-Headers: should not happen in reality, AWS is considering it like an # OPTIONS request get_req = requests.get( diff --git a/tests/aws/services/s3/test_s3_cors.snapshot.json b/tests/aws/services/s3/test_s3_cors.snapshot.json index af6bd103d9425..04d68820c65c5 100644 --- a/tests/aws/services/s3/test_s3_cors.snapshot.json +++ b/tests/aws/services/s3/test_s3_cors.snapshot.json @@ -340,7 +340,7 @@ } }, "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_headers": { - "recorded-date": "02-01-2024, 16:08:41", + "recorded-date": "07-07-2025, 17:12:03", "recorded-content": { "opt-get": { "Body": "", @@ -474,6 +474,23 @@ }, "StatusCode": 200 }, + "opt-get-allowed-no-space": { + "Body": "", + "Headers": { + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Headers": "x-amz-expected-bucket-owner, x-amz-server-side-encryption", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Origin": "https://localhost:4200", + "Access-Control-Max-Age": "3000", + "Content-Length": "0", + "Vary": "Origin, Access-Control-Request-Headers, Access-Control-Request-Method", + "date": "date", + "server": "", + "x-amz-id-2": "", + "x-amz-request-id": "" + }, + "StatusCode": 200 + }, "get-non-allowed-with-acl": { "Body": "test-cors", "Headers": { @@ -484,8 +501,8 @@ "accept-ranges": "bytes", "date": "date", "server": "", - "x-amz-id-2": "", - "x-amz-request-id": "", + "x-amz-id-2": "", + "x-amz-request-id": "", "x-amz-server-side-encryption": "AES256" }, "StatusCode": 200 @@ -505,8 +522,8 @@ "accept-ranges": "bytes", "date": "date", "server": "", - "x-amz-id-2": "", - "x-amz-request-id": "", + "x-amz-id-2": "", + "x-amz-request-id": "", "x-amz-server-side-encryption": "AES256" }, "StatusCode": 200 diff --git a/tests/aws/services/s3/test_s3_cors.validation.json b/tests/aws/services/s3/test_s3_cors.validation.json index c4393561ac0e9..63684e01782f0 100644 --- a/tests/aws/services/s3/test_s3_cors.validation.json +++ b/tests/aws/services/s3/test_s3_cors.validation.json @@ -12,7 +12,13 @@ "last_validated_date": "2023-07-31T10:31:40+00:00" }, "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_headers": { - "last_validated_date": "2024-01-02T16:08:41+00:00" + "last_validated_date": "2025-07-07T17:12:04+00:00", + "durations_in_seconds": { + "setup": 1.68, + "call": 4.08, + "teardown": 1.07, + "total": 6.83 + } }, "tests/aws/services/s3/test_s3_cors.py::TestS3Cors::test_cors_match_methods": { "last_validated_date": "2025-03-17T20:18:58+00:00"