Skip to content

Commit a0f1fe0

Browse files
committed
Security Related Updates:
- Adding CMK for DynamoDB table - Adding CMK for Cloudwatch Logs - Add ability to disable Catch-All functionality - Updated README files
1 parent abc712b commit a0f1fe0

File tree

4 files changed

+65
-10
lines changed

4 files changed

+65
-10
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ In this solution there are two flows:
2020
> In the above diagram, this flow begins typically with an automated account vending solution or automation or invoked manually. In this request, Lambda is invoked with a payload (examples in [src/events](src/events)) containing metadata, the solution uses this information to generate a unique account name and email address, stores it in a database and returns the values to the caller. These values can then be used to create a new AWS account (typically using AWS Organizations)
2121
2. Forward email
2222
> When an AWS account is created using the account email generated from the flow mentioned above, AWS will send various emails to that email address, like account registration confirmation and periodic notifications. As part of the solution, you configure your AWS account with SES to receive emails for the entire domain. This solution configures forward rules that allow AWS Lambda to process all incoming emails, check to see if the TO address is in the table and forwards the message on to the account owner's email address instead. This allows account owners to have multiple accounts associated with one email address.
23+
3. Read / Update / Delete Email Configuration (not pictured)
24+
> Because this is an example of one way this functionality could be created, this solution does not yet implement a read, update, or delete flow that could be used to read the configuration, make modifications or remove mappings. However, an administrator could create more Lambda functions to do this or use the AWS console directly to interact with the Account Table. A recommended pattern is to implement [Amazon API Gateway](https://aws.amazon.com/api-gateway/) to provide an API for other applications to consume this functionality.
2325
2426
## Prerequisites
2527
- Administrative access to an AWS account
@@ -57,6 +59,7 @@ variables.
5759
- ADDRESS_ADMIN: This is the email address you wish to use if the solution is unable to find or forward an email to a valid account owner. Emails will be SENT to this email address. Typically customers set this to a shared mailbox that the IT team monitors.
5860
- MAIL_HEADER_VALUE: This is the value of the X-Processed-By header that is added to every email forwarded through this system
5961
- COUNTER_LENGTH: This is the length of the number appended to account names (including leading zeros). e.g. this-is-my-account-name-001
62+
- DISABLE_CATCH_ALL: This setting is not present in cdk.json by default. By adding this setting with any value, it will disable the catch-all behavior and the solution will no longer forward messages where the account owner email is not found. To help prevent a denial of service attack, the catch-all functionality should be disabled. To enable catch-all, ensure this setting is NOT present in cdk.json.
6063

6164
## Deployment
6265

@@ -139,8 +142,9 @@ Alternatively, you can delete the CloudFormation stack "AwsMailFwdStack" through
139142
* `cdk diff` compare deployed stack with current state
140143
* `cdk docs` open CDK documentation
141144

142-
143-
145+
## Improvement Ideas
146+
- Implement Read/Update/Delete flows for email address configurations
147+
- Implement a Dead Letter Queue (DLQ) where undeliverable messages can go for archive or reprocessing
144148

145149
## Security
146150

aws_mail_fwd/aws_mail_fwd_stack.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,18 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
3434
mail_key = kms.Key(
3535
self, "KmsKey", enable_key_rotation=True, alias="alias/email-processing"
3636
)
37+
# KMS key for SNS
3738
sns_key = kms.Key(
3839
self, "KmsKeySns", enable_key_rotation=True, alias="alias/sns-mail-receipt"
3940
)
41+
# KMS Key for DynamoDb Table
42+
ddb_key: kms.Key = kms.Key(
43+
self, "KmsDdb", enable_key_rotation=True, alias="alias/acct-factory-email-ddb"
44+
)
45+
# KMS key for CloudWatch Logs Group
46+
logs_key = kms.Key(
47+
self, "KmsLogs", enable_key_rotation=True, alias="alias/acct-factory-email-cw-logs"
48+
)
4049
mail_key.grant_decrypt(iam.ServicePrincipal("ses.amazonaws.com"))
4150
sns_key.grant_encrypt_decrypt(iam.ServicePrincipal("ses.amazonaws.com"))
4251
# Allow SES to decrypt messages as per requirement
@@ -79,7 +88,8 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
7988
name="AccountEmail", type=dynamodb.AttributeType.STRING
8089
),
8190
table_name=self.node.try_get_context("ACCOUNT_TABLE_NAME"),
82-
encryption=dynamodb.TableEncryption.AWS_MANAGED,
91+
encryption=dynamodb.TableEncryption.CUSTOMER_MANAGED,
92+
encryption_key=ddb_key,
8393
billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
8494
point_in_time_recovery=True,
8595
)
@@ -104,6 +114,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
104114
"VendEmailLogGroup",
105115
removal_policy=RemovalPolicy.DESTROY,
106116
retention=aws_logs.RetentionDays.ONE_MONTH,
117+
encryption_key=logs_key,
107118
)
108119

109120
# Create Vend Email Lambda IAM Role
@@ -114,6 +125,9 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
114125
description="AwsMailFwd Vend Email Lambda Function role",
115126
)
116127
vend_email_log_group.grant_write(vend_email_role)
128+
ddb_key.grant_encrypt_decrypt(vend_email_role)
129+
logs_key.grant_encrypt_decrypt(vend_email_role)
130+
logs_key.grant_encrypt_decrypt(iam.ServicePrincipal("logs.amazonaws.com"))
117131

118132
# Create lambda function for vending emails
119133
vend_email_function = aws_lambda.Function(
@@ -145,6 +159,7 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
145159
"SesMailForwardLogGroup",
146160
removal_policy=RemovalPolicy.DESTROY,
147161
retention=aws_logs.RetentionDays.ONE_MONTH,
162+
encryption_key=logs_key,
148163
)
149164
ses_fwd_function_role = iam.Role(
150165
self,
@@ -210,6 +225,10 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
210225
ses_fwd_function.add_environment(
211226
"TABLE_NAME", account_table.table_name
212227
)
228+
disable_catch_all = self.node.try_get_context("DISABLE_CATCH_ALL")
229+
if disable_catch_all:
230+
ses_fwd_function.add_environment("DISABLE_CATCH_ALL", str(disable_catch_all))
231+
213232
vend_email_function.add_environment(
214233
"SES_DOMAIN_NAME", self.node.try_get_context("SES_DOMAIN_NAME")
215234
)
@@ -269,6 +288,10 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
269288
mail_bucket.grant_read(ses_fwd_function_role)
270289
mail_key.grant_decrypt(ses_fwd_function_role)
271290
sns_key.grant_encrypt_decrypt(ses_fwd_function_role)
291+
ddb_key.grant_decrypt(ses_fwd_function_role)
292+
ddb_key.grant_encrypt_decrypt(iam.ServicePrincipal("dynamodb.amazonaws.com"))
293+
logs_key.grant_encrypt_decrypt(ses_fwd_function_role)
294+
logs_key.grant_encrypt_decrypt(iam.ServicePrincipal("logs.amazonaws.com"))
272295

273296
# Grant permissions to Lambda to perform SES actions
274297
ses_fwd_function_role.add_to_policy(
@@ -457,6 +480,20 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
457480
],
458481
True
459482
)
483+
NagSuppressions.add_resource_suppressions(
484+
vend_email_role,
485+
[
486+
{
487+
"id": "AwsSolutions-IAM5",
488+
"reason": "All the KMS actions are needed for the role to encrypt and decrypt DynamoDb items",
489+
"appliesTo": [
490+
"Action::kms:GenerateDataKey*",
491+
"Action::kms:ReEncrypt*"
492+
],
493+
}
494+
],
495+
True
496+
)
460497
NagSuppressions.add_resource_suppressions(
461498
vend_email_role,
462499
[

src/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ This function delivers incoming message to the proper recipient. The process is
3737
|ADDRESS_FROM | cdk.json context.ADDRESS_FROM
3838
|ADDRESS_ADMIN | cdk.json context.ADDRESS_ADMIN
3939
|TABLE_NAME | cdk.json context.ACCOUNT_TABLE_NAME
40+
|DISABLE_CATCH_ALL | cdk.json context.DISABLE_CATCH_ALL
4041

4142
# /events
4243
The /events folder contains several sample events that are used to debug or build further functionality in the future.

src/fwdEmail/app.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,23 @@
99

1010
ADDRESS_FROM = os.getenv("ADDRESS_FROM")
1111
ADDRESS_ADMIN = os.getenv("ADDRESS_ADMIN")
12+
DISABLE_CATCH_ALL = os.getenv("DISABLE_CATCH_ALL", False)
1213
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
1314
logger = logging.getLogger("FWD-EMAIL")
1415
logging.getLogger().setLevel(getattr(logging, LOG_LEVEL.upper(), logging.INFO))
1516

1617

18+
def get_recipient(source_mail_to: str, account_owner: str) -> str:
19+
"""Returns the proper recipient or None"""
20+
if source_mail_to == ADDRESS_FROM:
21+
# Forward emails for the solution's FROM address to the admin
22+
return ADDRESS_ADMIN
23+
if DISABLE_CATCH_ALL and not account_owner:
24+
# IF catch-all is disabled, do not forward to ADDRESS_ADMIN
25+
return None
26+
return account_owner if account_owner else ADDRESS_ADMIN
27+
28+
1729
def lambda_handler(event, context):
1830
# Get the unique ID of the message. This corresponds to the name of the file
1931
# in S3.
@@ -37,13 +49,14 @@ def lambda_handler(event, context):
3749
# Retrieve the file from the S3 bucket.
3850
file_dict = ses.get_message_from_s3(mail_bucket, object_path)
3951

40-
# Determine who to send the email to
41-
if mail_to == ADDRESS_FROM:
42-
# Forward emails for the solution's FROM address to the admin
43-
send_to = ADDRESS_ADMIN
44-
else:
45-
account_owner = ddb.get_account_owner_address(mail_to)
46-
send_to = account_owner if account_owner else ADDRESS_ADMIN
52+
# Get the account owner
53+
account_owner = ddb.get_account_owner_address(mail_to)
54+
55+
# Determine the recipient
56+
send_to = get_recipient(mail_to, account_owner)
57+
if not send_to:
58+
logger.info(f"Unable to determine the proper recipient for {mail_to}")
59+
return
4760

4861
# Create the message.
4962
message = ses.create_message(ADDRESS_FROM, send_to, file_dict)

0 commit comments

Comments
 (0)