From a3d603afdc35af238ec60de46f6ce6fa59371261 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 17 Jul 2026 19:53:14 +0300 Subject: [PATCH] fix(iac/aws): scope Fargate SES send to email_from_domain (match Lambda) The Fargate task role's SES policy granted ses:SendEmail and ses:SendRawEmail on Resource="*" with no Condition, so a compromised ECS task could spoof mail from any SES-verified identity in the account. Split the single "SendFromAnyVerifiedIdentity" statement into two, to mirror the Lambda module's existing defence-in-depth pattern: - "SendFromCUDlyDomain": ses:SendEmail + ses:SendRawEmail on Resource="*" gated by StringLike { "ses:FromAddress" = "*@${var.email_from_domain}" } - "SESReadOnly": ses:GetAccount + ses:GetEmailIdentity on Resource="*" (read-only; no send risk; no Condition required) var.email_from_domain is already declared in the Fargate module's variables.tf and used in the count guard, so no variable threading is needed. Deployments with email_from_domain="" still receive zero SES permissions. terraform fmt and tflint both pass on the touched module. Found via adversarial security review of IaC in origin/main. --- terraform/modules/compute/aws/fargate/main.tf | 44 ++++++++++++++----- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index e4fc370e2..eb97312fd 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -172,9 +172,26 @@ resource "aws_iam_role_policy" "task_secrets" { }) } -# SES email sending access — scoped to the verified From domain plus +# SES email sending access, scoped to the verified From domain plus # stack-specific configuration sets when email_from_domain is set; otherwise -# no SES policy is attached at all. Matches the Lambda module's shape. +# no SES policy is attached at all. Mirrors the Lambda module exactly. +# +# Resource = "*" on the send actions: the narrower identity/${domain} +# patterns we tried earlier don't cover the real case we hit in production: +# a From address on an unverified subdomain (e.g. noreply@cudly.leanercloud.com) +# where SES walks up the identity hierarchy and evaluates IAM against the +# verified parent's ARN (identity/leanercloud.com). Enumerating every +# ancestor the operator might verify would work on paper but drifts every +# time the SES identity tree is reorganised. +# +# To still narrow the blast radius (a compromised task must not be able to +# spoof emails from arbitrary verified identities in the same account), +# the SendEmail/SendRawEmail actions are gated by a `ses:FromAddress` +# condition restricting the From header to *@${var.email_from_domain}. +# SES enforces FromAddress on the wire, so this prevents phishing from +# unrelated identities even though the resource ARN remains broad. +# Configuration-set access stays scoped to ${stack_name}* so a compromised +# task can't touch unrelated stacks' config sets either. resource "aws_iam_role_policy" "ses_access" { count = var.email_from_domain != "" ? 1 : 0 @@ -183,20 +200,27 @@ resource "aws_iam_role_policy" "ses_access" { policy = jsonencode({ Version = "2012-10-17" - # See the Lambda module's comment for the full rationale behind Resource - # = "*" on the send actions: SES's identity hierarchy forces the policy - # to grant access to parent domains the operator verifies, and - # enumerating them is fragile. `*` defers the real authorisation to - # SES itself (which still rejects sends from unverified identities at - # the service layer); configuration-set access stays stack-scoped so - # a compromised task can't touch unrelated stacks' config sets. Statement = [ { - Sid = "SendFromAnyVerifiedIdentity" + Sid = "SendFromCUDlyDomain" Effect = "Allow" Action = [ "ses:SendEmail", "ses:SendRawEmail", + ] + Resource = "*" + Condition = { + StringLike = { + "ses:FromAddress" = "*@${var.email_from_domain}" + } + } + }, + { + # Read-only SES status checks for healthchecks and quota + # introspection. No spoofing risk -- these don't send mail. + Sid = "SESReadOnly" + Effect = "Allow" + Action = [ "ses:GetAccount", "ses:GetEmailIdentity", ]