mail: harden the IAM policy against ARN and domain validation
Four security hardening fixes from the adversarial review:
1. Refuse ARNs with wildcards (* or ?) — IAM expands these as match patterns,
so arn:aws:ses:*:*:identity/* grants over every SES identity in the account.
2. Validate ARN shape strictly: arn:aws:ses:<region>:<account-id>:identity/<name>.
3. Require each identity name to match a granted domain: either exactly one,
or a parent domain of one (e.g., example.com covers sub.example.com).
4. Fix UserName/StandaloneUserName collision: app names allow hyphens, so
UserName("bag", "see-s-laptop") collided with StandaloneUserName("bag",
"see", "laptop"). Changed separator to +s+ which app names cannot contain.
5. Add test coverage for multi-domain/multi-ARN steady state.
2 files changed,
+78
−2
internal/mail/policy.go+29 −2internal/mail/policy_test.go+49 −0
diff --git a/internal/mail/policy.go b/internal/mail/policy.go| index 94e97df73..e964bd523 100644 |
| --- a/internal/mail/policy.go |
| +++ b/internal/mail/policy.go |
| @@ -31,7 +31,7 @@ func UserName(acct, app string) string { return "carlos-mail-" + acct + "-" + ap |
| // already spend both. A standalone key on the shared user would work until |
| // the first rotation and then make rotation impossible. |
| func StandaloneUserName(acct, app, name string) string { |
| - return "carlos-mail-" + acct + "-" + app + "-s-" + name |
| + return "carlos-mail-" + acct + "-" + app + "+s+" + name |
| } |
| // SendPolicyJSON renders the policy for a set of verified identity ARNs and |
| @@ -41,6 +41,9 @@ func StandaloneUserName(acct, app, name string) string { |
| // An empty grant returns an error rather than a permissive document: the |
| // failure worth having is "this app cannot send", never "this app can send |
| // as anything in the estate". |
| +// Each ARN must have the shape arn:aws:ses:<region>:<account-id>:identity/<name>, |
| +// the identity name must not contain wildcards, and it must be either exactly |
| +// one of the domains or a parent domain of one of them. |
| func SendPolicyJSON(identityARNs, domains []string) (string, error) { |
| if len(identityARNs) == 0 { |
| return "", fmt.Errorf("mail: refusing to render a send policy with no identity ARNs") |
| @@ -59,9 +62,33 @@ func SendPolicyJSON(identityARNs, domains []string) (string, error) { |
| froms = append(froms, "*@"+d) |
| } |
| for _, arn := range identityARNs { |
| - if !strings.HasPrefix(arn, "arn:") { |
| + // Validate ARN shape: arn:aws:ses:<region>:<account-id>:identity/<name> |
| + parts := strings.Split(arn, ":") |
| + if len(parts) < 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "ses" { |
| return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| } |
| + // Extract identity name from the resource part |
| + resourceParts := strings.SplitN(parts[5], "/", 2) |
| + if len(resourceParts) != 2 || resourceParts[0] != "identity" { |
| + return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| + } |
| + identityName := resourceParts[1] |
| + // Refuse wildcards in the identity name |
| + if strings.Contains(identityName, "*") || strings.Contains(identityName, "?") { |
| + return "", fmt.Errorf("mail: invalid identity ARN %q in send policy", arn) |
| + } |
| + // Check that identity matches domains: either exactly one of them, |
| + // or a parent domain of one of them. |
| + found := false |
| + for _, d := range domains { |
| + if identityName == d || strings.HasSuffix(d, "."+identityName) { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + return "", fmt.Errorf("mail: identity %q in ARN %q does not match any granted domain", identityName, arn) |
| + } |
| } |
| doc := map[string]any{ |
| "Version": "2012-10-17", |
diff --git a/internal/mail/policy_test.go b/internal/mail/policy_test.go| index 01a8bee35..c3183794d 100644 |
| --- a/internal/mail/policy_test.go |
| +++ b/internal/mail/policy_test.go |
| @@ -69,6 +69,51 @@ func TestPolicyListsEveryVerifiedARN(t *testing.T) { |
| } |
| } |
| +// Steady state is multiple domains per app. Untested single-domain case would |
| +// allow an implementation that rendered only froms[0] to pass the suite. |
| +func TestPolicyWithMultipleDomainsAndARNs(t *testing.T) { |
| + got, err := SendPolicyJSON([]string{ |
| + "arn:aws:ses:eu-west-1:1:identity/platform.com", |
| + "arn:aws:ses:us-east-1:1:identity/custom.app", |
| + }, []string{"platform.com", "custom.app"}) |
| + if err != nil { |
| + t.Fatalf("SendPolicyJSON: %v", err) |
| + } |
| + d := parsePolicy(t, got) |
| + st := d.Statement[0] |
| + |
| + // Check resources |
| + if len(st.Resource) != 2 { |
| + t.Fatalf("Resource count = %d, want 2", len(st.Resource)) |
| + } |
| + resourceSet := make(map[string]bool) |
| + for _, r := range st.Resource { |
| + resourceSet[r] = true |
| + } |
| + if !resourceSet["arn:aws:ses:eu-west-1:1:identity/platform.com"] { |
| + t.Fatalf("Resource missing arn:aws:ses:eu-west-1:1:identity/platform.com") |
| + } |
| + if !resourceSet["arn:aws:ses:us-east-1:1:identity/custom.app"] { |
| + t.Fatalf("Resource missing arn:aws:ses:us-east-1:1:identity/custom.app") |
| + } |
| + |
| + // Check from addresses |
| + froms := st.Condition["StringLike"]["ses:FromAddress"] |
| + if len(froms) != 2 { |
| + t.Fatalf("ses:FromAddress count = %d, want 2", len(froms)) |
| + } |
| + fromSet := make(map[string]bool) |
| + for _, f := range froms { |
| + fromSet[f] = true |
| + } |
| + if !fromSet["*@platform.com"] { |
| + t.Fatalf("ses:FromAddress missing *@platform.com") |
| + } |
| + if !fromSet["*@custom.app"] { |
| + t.Fatalf("ses:FromAddress missing *@custom.app") |
| + } |
| +} |
| + |
| // An empty grant must not render a policy that permits everything. Refusing |
| // is the only safe direction: a wildcard here would let one app send as any |
| // identity in the estate. |
| @@ -102,4 +147,8 @@ func TestUserNamesAreStableAndDistinct(t *testing.T) { |
| if StandaloneUserName("bag", "seapointish", "laptop") == UserName("bag", "seapointish") { |
| t.Fatal("standalone credential collided with the delivered user") |
| } |
| + // App names allow hyphens, so ensure no collision with a different separator. |
| + if StandaloneUserName("bag", "see", "laptop") == UserName("bag", "see-s-laptop") { |
| + t.Fatal("standalone credential collided with a hyphenated app name") |
| + } |
| } |