Repository: medmunds/aws-cfn-ses-domain

Issue:
Boolean Options are not handled correctly
## Summary
The lambda function does not handle `EnableSend` or `EnableReceive` properly.

The `true`/`false` values are converted to textual `"true"`/`"false"` not actual Python boolean values. 

You can see this in the log output of the lambda function:

```
Expanded properties to {'Domain': 'example.com, 'EnableSend': 'false', 'EnableReceive': 'true', 'MailFromSubdomain': 'mail', 'CustomDMARC': '', 'TTL': '1800', 'Region': 'us-east-1'}
```
Note I removed the service token and obviously changed the domain name in the output.

## Steps to reproduce

1. Create `test.cf.yaml`:
```yaml
  SESDomain:
    Type: Custom::SES_Domain
    Properties:
      ServiceToken: !GetAtt CfnSESDomain.Outputs.Arn
      Domain: "example.com"
      EnableSend: false
      EnableReceive: true
      MailFromSubdomain: "mail"
      TTL: "1800"
      CustomDMARC: ''
      Region: !Ref "AWS::Region"
 ```

2. Run `aws cloudformation deploy --capabilities CAPABILITY_IAM --template-file test.cf.yaml ...`

3. Check stack status in CloudFormation dashboard
   
### Results
SES outbound sending was enabled.

### Expected results
SES outbound sending should not be enabled.


## System
* aws-cfn-ses-domain version: 0.2
* aws cli version: aws-cli/1.16.159 Python/3.7.3 Darwin/17.7.0 botocore/1.12.155
* OS: Mac

## Additional Info

The unit tests seem to cover this case but they use Python boolean values in the parameters which is not how the parameters are actually passed to the function at runtime.


Also thanks, nice library overall.


Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6726bc..dda2185 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,11 @@
 * Support using the [`Region`](README.md#region) property to provision an Amazon SES 
   domain in a different region from where you're running your CloudFormation stack.
   (Thanks to @gfodor.)
+  
+* Fix incorrect handling of `EnableSend: false` and other potential problems with
+  Boolean properties, by working around CloudFormation's non-standard YAML parsing. 
+  (Thanks to @aajtodd.)
+
 
 ### Features
 
diff --git a/aws_cfn_ses_domain/ses_domain_identity.py b/aws_cfn_ses_domain/ses_domain_identity.py
index d57d77f..623558e 100644
--- a/aws_cfn_ses_domain/ses_domain_identity.py
+++ b/aws_cfn_ses_domain/ses_domain_identity.py
@@ -8,8 +8,7 @@ import boto3
 from botocore.exceptions import BotoCoreError, ClientError
 
 from .cfnresponse import FAILED, SUCCESS, send
-from .utils import format_arn
-
+from .utils import format_arn, to_bool
 
 logger = logging.getLogger()
 logger.setLevel(os.getenv("LOG_LEVEL", "WARNING"))
@@ -24,6 +23,7 @@ DEFAULT_PROPERTIES = {
     "TTL": "1800",
     "Region": os.getenv("AWS_REGION"),  # where the stack (lambda fn) is running
 }
+BOOLEAN_PROPERTIES = ("EnableSend", "EnableReceive")
 
 
 def handle_domain_identity_request(event, context):
@@ -52,6 +52,17 @@ def handle_domain_identity_request(event, context):
         resource_type="identity", resource_name=domain,
         defaults_from=event["StackId"])  # current stack's ARN has account and partition
 
+    for prop in BOOLEAN_PROPERTIES:
+        # CloudFormation may convert YAML/JSON bools to strings, so reverse that
+        # https://github.com/medmunds/aws-cfn-ses-domain/issues/10
+        try:
+            properties[prop] = to_bool(properties[prop])
+        except ValueError:
+            return send(event, context, FAILED,
+                        reason=f"The '{prop}' property must be 'true' or 'false',"
+                               f" not '{properties[prop]}'.",
+                        physical_resource_id=domain_arn)
+
     if event["RequestType"] == "Delete" and event["PhysicalResourceId"] == domain:
         # v0.3 backwards compatibility:
         # Earlier versions used just the domain as the PhysicalResourceId.
diff --git a/aws_cfn_ses_domain/utils.py b/aws_cfn_ses_domain/utils.py
index a4787eb..a4937ee 100644
--- a/aws_cfn_ses_domain/utils.py
+++ b/aws_cfn_ses_domain/utils.py
@@ -25,3 +25,34 @@ def format_arn(partition=None, service=None, region=None, account=None,
         resource = resource if resource is not None else _resource
 
     return f"arn:{partition}:{service}:{region}:{account}:{resource}"
+
+
+def to_bool(val):
+    """Convert val to True or False.
+
+    Converts 'true' (case-insensitive) and 1, '1', or True to True.
+    Converts 'false', 'null' or 'none' (case-insensitive), the empty string '',
+    and 0, '0', or False to False.
+    Raises a ValueError for any other input.
+
+    >>> to_bool('true')
+    True
+    >>> to_bool('False')
+    False
+    >>> to_bool(0)
+    False
+    >>> to_bool('0')
+    False
+    >>> to_bool(None)
+    False
+    >>> to_bool('yes')
+    ValueError("Invalid boolean value 'yes'")
+    """
+    # (Loosely adapted from distutils.util.strtobool)
+    strval = str(val).lower()
+    if strval in ('true', '1'):
+        return True
+    elif strval in ('false', '0', 'null', 'none', ''):
+        return False
+    else:
+        raise ValueError(f"Invalid boolean value {val!r}")


Test patch:
diff --git a/tests/base.py b/tests/base.py
index 98cdc61..ad2c04f 100644
--- a/tests/base.py
+++ b/tests/base.py
@@ -1,4 +1,4 @@
-from unittest.case import TestCase
+from unittest import TestCase
 from unittest.mock import patch, ANY as MOCK_ANY
 
 import boto3
diff --git a/tests/test_ses_domain_identity.py b/tests/test_ses_domain_identity.py
index 9664a39..7396429 100644
--- a/tests/test_ses_domain_identity.py
+++ b/tests/test_ses_domain_identity.py
@@ -98,8 +98,8 @@ class TestDomainIdentityHandler(HandlerTestCase):
             "RequestType": "Create",
             "ResourceProperties": {
                 "Domain": "example.com.",
-                "EnableSend": True,
-                "EnableReceive": True,
+                "EnableSend": "true",
+                "EnableReceive": "true",
                 "MailFromSubdomain": "bounce",
                 "CustomDMARC": '"v=DMARC1; p=quarantine; rua=mailto:d@example.com;"',
                 "TTL": "300",
@@ -166,8 +166,8 @@ class TestDomainIdentityHandler(HandlerTestCase):
             "RequestType": "Update",
             "ResourceProperties": {
                 "Domain": "example.com.",
-                "EnableSend": False,
-                "EnableReceive": True,
+                "EnableSend": "false",
+                "EnableReceive": "true",
                 "CustomDMARC": None,
             },
             "StackId": self.mock_stack_id}
@@ -208,8 +208,8 @@ class TestDomainIdentityHandler(HandlerTestCase):
             "PhysicalResourceId": "arn:aws:ses:mock-region:111111111111:identity/example.com",
             "ResourceProperties": {
                 "Domain": "example.com.",
-                "EnableSend": True,
-                "EnableReceive": True,
+                "EnableSend": "true",
+                "EnableReceive": "true",
             },
             "StackId": self.mock_stack_id}
         self.ses_stubber.add_response(
@@ -244,8 +244,8 @@ class TestDomainIdentityHandler(HandlerTestCase):
             "PhysicalResourceId": "example.com",  # old format: just the domain
             "ResourceProperties": {
                 "Domain": "example.com.",
-                "EnableSend": True,
-                "EnableReceive": True,
+                "EnableSend": "true",
+                "EnableReceive": "true",
             },
             "StackId": self.mock_stack_id}
         # self.ses_stubber.nothing: *no* SES ops should occur
@@ -280,3 +280,17 @@ class TestDomainIdentityHandler(HandlerTestCase):
             'ERROR:root:Error updating SES: An error occurred (InvalidParameterValue) when'
             ' calling the VerifyDomainIdentity operation: Invalid domain name bad domain name.',
             cm.output[0])
+
+    def test_invalid_boolean_property(self):
+        event = {
+            "RequestType": "Create",
+            "ResourceProperties": {
+                "Domain": "example.com",
+                "EnableSend": "yes",
+            },
+            "StackId": self.mock_stack_id}
+        handle_domain_identity_request(event, self.mock_context)
+        self.assertSentResponse(
+            event, status="FAILED",
+            reason="The 'EnableSend' property must be 'true' or 'false', not 'yes'.",
+            physical_resource_id=MOCK_ANY)
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..2b49ed2
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,41 @@
+from unittest import TestCase
+
+from aws_cfn_ses_domain.utils import to_bool
+
+
+class TestToBool(TestCase):
+    TRUE_VALUES = (
+        'true', 'True', 'TRUE', 'tRuE',
+        1, '1',
+        True,
+    )
+
+    FALSE_VALUES = (
+        'false', 'False', 'FALSE', 'fAlSe',
+        0, '0',
+        None, 'None',
+        'null',  # JSON's None as a string
+        '',  # empty string
+        False,
+    )
+
+    INVALID_VALUES = (
+        'yes', 'no', 't', 'f', ' ',
+        100, -1, 0.5,
+    )
+
+    def test_true(self):
+        for value in self.TRUE_VALUES:
+            with self.subTest(value=value):
+                self.assertIs(to_bool(value), True)
+
+    def test_false(self):
+        for value in self.FALSE_VALUES:
+            with self.subTest(value=value):
+                self.assertIs(to_bool(value), False)
+
+    def test_invalid(self):
+        for value in self.INVALID_VALUES:
+            with self.subTest(value=value):
+                with self.assertRaises(ValueError):
+                    to_bool(value)