Repository: localstack/localstack

Issue:
bug: CORSConfiguration.CORSRules error when deploying through cdklocal
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Current Behavior

When deploying a stack containing S3 buckets with cors similar to

```javascript
cors: [{ allowedOrigins: ["https://localhost:3000", "https://www.example.com", "https://127.0.0.1:3000"], allowedMethods: [s3.HttpMethods.PUT], allowedHeaders: ["*"] }],
```
I am getting the following in the log

```
2023-04-19T21:07:12.853 DEBUG --- [uncthread142] l.s.c.e.template_deployer  : Error applying changes for CloudFormation stack "localstack-000000000000-s3-stack": Parameter validation failed:
Invalid type for parameter CORSConfiguration.CORSRules[0].ID, value: None, type: <class 'NoneType'>, valid types: <class 'str'>
Invalid type for parameter CORSConfiguration.CORSRules[0].ExposeHeaders, value: None, type: <class 'NoneType'>, valid types: <class 'list'>, <class 'tuple'>
Invalid type for parameter CORSConfiguration.CORSRules[0].MaxAgeSeconds, value: None, type: <class 'NoneType'>, valid types: <class 'int'> Traceback (most recent call last):
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/localstack/services/cloudformation/engine/template_deployer.py", line 1336, in _run
    self.do_apply_changes_in_loop(changes, stack)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/localstack/services/cloudformation/engine/template_deployer.py", line 1413, in do_apply_changes_in_loop
    self.apply_change(change, stack=stack)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/localstack/services/cloudformation/engine/template_deployer.py", line 1495, in apply_change
    result = execute_resource_action(resource_id, self, ACTION_CREATE)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/localstack/services/cloudformation/engine/template_deployer.py", line 738, in execute_resource_action
    result = func["function"](resource_id, resources, resource_type, func, stack_name)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/localstack/services/cloudformation/models/s3.py", line 177, in _put_bucket_cors_configuration
    s3_client.put_bucket_cors(
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/botocore/client.py", line 530, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/botocore/client.py", line 919, in _make_api_call
    request_dict = self._convert_to_request_dict(
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/botocore/client.py", line 990, in _convert_to_request_dict
    request_dict = self._serializer.serialize_to_request(
  File "/opt/code/localstack/.venv/lib/python3.10/site-packages/botocore/validate.py", line 381, in serialize_to_request
    raise ParamValidationError(report=report.generate_report())
botocore.exceptions.ParamValidationError: Parameter validation failed:
Invalid type for parameter CORSConfiguration.CORSRules[0].ID, value: None, type: <class 'NoneType'>, valid types: <class 'str'>
Invalid type for parameter CORSConfiguration.CORSRules[0].ExposeHeaders, value: None, type: <class 'NoneType'>, valid types: <class 'list'>, <class 'tuple'>
Invalid type for parameter CORSConfiguration.CORSRules[0].MaxAgeSeconds, value: None, type: <class 'NoneType'>, valid types: <class 'int'>
```

### Expected Behavior

No errors :smiley: 
There were no errors prior to trying LocalStack version 2, nor are there any errors running it against AWS.

### How are you starting LocalStack?

With a `docker run` command

### Steps To Reproduce

#### How are you starting localstack (e.g., `bin/localstack` command, arguments, or `docker-compose.yml`)

Approximately:

    docker run --detach --name LOCALSTACK_NAME --network LOCALSTACK_NETWORK <plenty of environment variables and ports> --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock" --mount type=bind,src={os.getcwd()}/.cache/localstack/volume,dst=/var/lib/localstack localstack/localstack-pro:2.0.0

#### Client commands (e.g., AWS SDK code snippet, or sequence of "awslocal" commands)

```
npx cdklocal bootstrap 000000000000/us-east-1
npx cdklocal deploy localstack --require-approval=never
```


### Environment

```markdown
- OS: Ubuntu 22.04.2 LTS
- LocalStack: Tested on -pro:2.0.0, -pro:2.0.2, and `latest`
```


### Anything else?

_No response_


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).
Hi @k3KAW8Pnf7mkmdSMPHz27 and thanks for your report!
It seems it has been introduced with #7866, and is missing some logic concerning optional parameters. I'll work on this in the next days, thanks again for reporting! 
@bentsku thank you for the quick response and hypothesis!
I hope you are right, I felt quite guilty for not providing a MRE :flushed: 

Resolving patch:
diff --git a/localstack/services/cloudformation/models/s3.py b/localstack/services/cloudformation/models/s3.py
--- a/localstack/services/cloudformation/models/s3.py
+++ b/localstack/services/cloudformation/models/s3.py
@@ -67,22 +67,35 @@ def convert_acl_cf_to_s3(acl):
             return re.sub("(?<!^)(?=[A-Z])", "-", acl).lower()
 
         def transform_cfn_cors(cors_config):
+            # See https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutBucketCors.html
+            # https://docs.aws.amazon.com/AmazonS3/latest/API/API_CORSRule.html
+            # only AllowedMethods and AllowedOrigins are required
             if not cors_config:
                 return {}
 
-            transformed_cors = cors_config.copy()
-            transformed_cors["CORSRules"] = [
-                {
-                    "ID": rule.get("Id"),
-                    "AllowedHeaders": rule.get("AllowedHeaders"),
-                    "AllowedMethods": rule.get("AllowedMethods"),
-                    "AllowedOrigins": rule.get("AllowedOrigins"),
-                    "ExposeHeaders": rule.get("ExposedHeaders"),
-                    "MaxAgeSeconds": rule.get("MaxAge"),
+            cors_rules = []
+            for cfn_rule in cors_config.get("CorsRules", []):
+                rule = {
+                    "AllowedOrigins": cfn_rule.get("AllowedOrigins"),
+                    "AllowedMethods": cfn_rule.get("AllowedMethods"),
                 }
-                for rule in transformed_cors.pop("CorsRules", [])
-            ]
-            return transformed_cors
+                # we should not pass those to PutBucketCors if they are None, as S3 will provide default values and
+                # does not accept None
+                if (allowed_headers := cfn_rule.get("AllowedHeaders")) is not None:
+                    rule["AllowedHeaders"] = allowed_headers
+
+                if (allowed_headers := cfn_rule.get("ExposedHeaders")) is not None:
+                    rule["ExposeHeaders"] = allowed_headers
+
+                if (allowed_headers := cfn_rule.get("MaxAge")) is not None:
+                    rule["MaxAgeSeconds"] = allowed_headers
+
+                if (allowed_headers := cfn_rule.get("Id")) is not None:
+                    rule["ID"] = allowed_headers
+
+                cors_rules.append(rule)
+
+            return {"CORSRules": cors_rules}
 
         def s3_bucket_notification_config(params, **kwargs):
             notif_config = params.get("NotificationConfiguration")


Test patch:
diff --git a/tests/integration/cloudformation/resources/test_s3.py b/tests/integration/cloudformation/resources/test_s3.py
--- a/tests/integration/cloudformation/resources/test_s3.py
+++ b/tests/integration/cloudformation/resources/test_s3.py
@@ -56,6 +56,7 @@ def test_bucket_versioning(deploy_cfn_template, aws_client):
     assert bucket_version["Status"] == "Enabled"
 
 
+@pytest.mark.aws_validated
 def test_cors_configuration(deploy_cfn_template, snapshot, aws_client):
     snapshot.add_transformer(snapshot.transform.cloudformation_api())
     snapshot.add_transformer(snapshot.transform.s3_api())
@@ -65,7 +66,10 @@ def test_cors_configuration(deploy_cfn_template, snapshot, aws_client):
             os.path.dirname(__file__), "../../templates/s3_cors_bucket.yaml"
         ),
     )
-    bucket_name = result.outputs["BucketName"]
-    cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name)
+    bucket_name_optional = result.outputs["BucketNameAllParameters"]
+    cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_optional)
+    snapshot.match("cors-info-optional", cors_info)
 
-    snapshot.match("cors_info", cors_info)
+    bucket_name_required = result.outputs["BucketNameOnlyRequired"]
+    cors_info = aws_client.s3.get_bucket_cors(Bucket=bucket_name_required)
+    snapshot.match("cors-info-only-required", cors_info)
diff --git a/tests/integration/cloudformation/resources/test_s3.snapshot.json b/tests/integration/cloudformation/resources/test_s3.snapshot.json
--- a/tests/integration/cloudformation/resources/test_s3.snapshot.json
+++ b/tests/integration/cloudformation/resources/test_s3.snapshot.json
@@ -1,8 +1,8 @@
 {
   "tests/integration/cloudformation/resources/test_s3.py::test_cors_configuration": {
-    "recorded-date": "16-03-2023, 10:22:58",
+    "recorded-date": "20-04-2023, 20:17:17",
     "recorded-content": {
-      "cors_info": {
+      "cors-info-optional": {
         "CORSRules": [
           {
             "AllowedHeaders": [
@@ -26,6 +26,22 @@
           "HTTPHeaders": {},
           "HTTPStatusCode": 200
         }
+      },
+      "cors-info-only-required": {
+        "CORSRules": [
+          {
+            "AllowedMethods": [
+              "GET"
+            ],
+            "AllowedOrigins": [
+              "*"
+            ]
+          }
+        ],
+        "ResponseMetadata": {
+          "HTTPHeaders": {},
+          "HTTPStatusCode": 200
+        }
       }
     }
   }
diff --git a/tests/integration/templates/s3_cors_bucket.yaml b/tests/integration/templates/s3_cors_bucket.yaml
--- a/tests/integration/templates/s3_cors_bucket.yaml
+++ b/tests/integration/templates/s3_cors_bucket.yaml
@@ -15,6 +15,20 @@ Resources:
               - Date
             Id: "test-cors-id"
             MaxAge: 3600
+
+  LocalBucket2:
+    Type: AWS::S3::Bucket
+    Properties:
+      CorsConfiguration:
+        CorsRules:
+          - AllowedMethods:
+              - GET
+            AllowedOrigins:
+              - '*'
+
+
 Outputs:
-  BucketName:
+  BucketNameAllParameters:
     Value: !Ref LocalBucket
+  BucketNameOnlyRequired:
+    Value: !Ref LocalBucket2