| 1 | // Discussion #216: a member must not be able to take over an SES identity |
| 2 | // this account's claim did not create, and a sending-domain claim that was |
| 3 | // never verified must not hold a domain forever. |
| 4 | package mail |
| 5 | |
| 6 | import ( |
| 7 | "context" |
| 8 | "errors" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/dnsrec" |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // seedForeignIdentity stands in an identity verified in SES outside CARLOS: |
| 17 | // tito.io in spec 2026-09-15 §1, an operator's own sign-in domain, or one |
| 18 | // created before the claim gate existed. |
| 19 | func seedForeignIdentity(p *MemProvisioner, region, domain string) *MemIdentity { |
| 20 | id := &MemIdentity{ |
| 21 | ARN: "arn:aws:ses:" + region + ":shared:identity/" + domain, |
| 22 | DKIMTokens: []string{"classic1", "classic2", "classic3"}, |
| 23 | MailFrom: "bounce." + domain, |
| 24 | ConfigSet: "classic-events", |
| 25 | Status: Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}, |
| 26 | } |
| 27 | p.Identities[identityKey(region, domain)] = id |
| 28 | return id |
| 29 | } |
| 30 | |
| 31 | // The HIGH finding as it stood: a member's `domains add` on a domain someone |
| 32 | // else verified in SES adopted the identity, rewrote its MAIL FROM, and |
| 33 | // would have bound and later deleted it. |
| 34 | func TestEnsureDomainRefusesAnIdentityItDidNotCreate(t *testing.T) { |
| 35 | ctx := context.Background() |
| 36 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 37 | p := NewMemProvisioner("shared") |
| 38 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 39 | d := testDeployment() |
| 40 | foreign := seedForeignIdentity(p, d.Region, "tito.io") |
| 41 | |
| 42 | _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "jelly", "tito.io", now) |
| 43 | if !errors.Is(err, ErrIdentityNotOwned) { |
| 44 | t.Fatalf("EnsureDomain on a foreign identity = %v, want ErrIdentityNotOwned", err) |
| 45 | } |
| 46 | if foreign.MailFrom != "bounce.tito.io" || foreign.ConfigSet != "classic-events" { |
| 47 | t.Fatalf("the foreign identity was mutated: MailFrom=%q ConfigSet=%q", foreign.MailFrom, foreign.ConfigSet) |
| 48 | } |
| 49 | if rec, _, err := GetDomain(ctx, s, "aaa", "tito.io"); err != nil || rec != nil { |
| 50 | t.Fatalf("a domain record was written for a refused domain: %+v %v", rec, err) |
| 51 | } |
| 52 | // The refusal leaves nothing holding the domain: no claim to block the |
| 53 | // real owner, and nothing a later Teardown could delete. |
| 54 | if c, err := getDomainClaim(ctx, s, "tito.io"); err != nil || c != nil { |
| 55 | t.Fatalf("a refused domain kept its claim: %+v %v", c, err) |
| 56 | } |
| 57 | if err := Teardown(ctx, s, p, w, d, "aaa", "jelly"); err != nil { |
| 58 | t.Fatalf("teardown: %v", err) |
| 59 | } |
| 60 | if _, ok := p.Identities[identityKey(d.Region, "tito.io")]; !ok { |
| 61 | t.Fatal("teardown deleted an identity this account never created") |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | // fakeTXT is the injectable resolver: name -> TXT values. |
| 66 | type fakeTXT map[string][]string |
| 67 | |
| 68 | func (f fakeTXT) LookupTXT(_ context.Context, name string) ([]string, error) { |
| 69 | if v, ok := f[name]; ok { |
| 70 | return v, nil |
| 71 | } |
| 72 | return nil, errors.New("no such host") |
| 73 | } |
| 74 | |
| 75 | // Proof of DNS control is the one way in: the claimant publishes a TXT |
| 76 | // record naming its own account, and only then is the existing identity |
| 77 | // adopted. Teardown still never deletes it, because CARLOS did not create |
| 78 | // it. |
| 79 | func TestEnsureDomainAdoptsAForeignIdentityOnlyWithDNSProof(t *testing.T) { |
| 80 | ctx := context.Background() |
| 81 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 82 | p := NewMemProvisioner("shared") |
| 83 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 84 | d := testDeployment() |
| 85 | seedForeignIdentity(p, d.Region, "tito.io") |
| 86 | |
| 87 | // A proof naming a DIFFERENT account proves nothing for this one. |
| 88 | d.Resolver = fakeTXT{ProofRecordName("tito.io"): {ProofRecordValue("bbb")}} |
| 89 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "jelly", "tito.io", now); !errors.Is(err, ErrIdentityNotOwned) { |
| 90 | t.Fatalf("another account's proof was accepted: %v", err) |
| 91 | } |
| 92 | |
| 93 | d.Resolver = fakeTXT{ProofRecordName("tito.io"): {"v=spf1 -all", ProofRecordValue("aaa")}} |
| 94 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "jelly", "tito.io", now) |
| 95 | if err != nil { |
| 96 | t.Fatalf("EnsureDomain with a valid proof: %v", err) |
| 97 | } |
| 98 | st := rec.Regions[d.Region] |
| 99 | if !st.Adopted { |
| 100 | t.Fatal("an adopted identity was not recorded as adopted") |
| 101 | } |
| 102 | if st.DKIMTokens[0] != "classic1" { |
| 103 | t.Fatalf("adoption did not keep the identity's published DKIM tokens: %v", st.DKIMTokens) |
| 104 | } |
| 105 | |
| 106 | // A re-run needs no proof any more: the record is this account's. |
| 107 | d.Resolver = nil |
| 108 | again, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "jelly", "tito.io", now) |
| 109 | if err != nil { |
| 110 | t.Fatalf("re-run after adoption: %v", err) |
| 111 | } |
| 112 | if got := again.Regions[d.Region]; !got.Adopted { |
| 113 | t.Fatal("re-run lost the adopted flag") |
| 114 | } |
| 115 | |
| 116 | if err := Teardown(ctx, s, p, w, d, "aaa", "jelly"); err != nil { |
| 117 | t.Fatalf("teardown: %v", err) |
| 118 | } |
| 119 | if _, ok := p.Identities[identityKey(d.Region, "tito.io")]; !ok { |
| 120 | t.Fatal("teardown deleted an adopted identity") |
| 121 | } |
| 122 | if c, _ := getDomainClaim(ctx, s, "tito.io"); c != nil { |
| 123 | t.Fatalf("teardown left the claim behind: %+v", c) |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | // Identities CARLOS itself created for this account keep working with no |
| 128 | // proof: a re-run, a sibling app, and the crash between EnsureIdentity and |
| 129 | // the record (the claim remembers the create). |
| 130 | func TestEnsureDomainKeepsItsOwnIdentitiesWithoutProof(t *testing.T) { |
| 131 | ctx := context.Background() |
| 132 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 133 | p := NewMemProvisioner("shared") |
| 134 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 135 | d := testDeployment() |
| 136 | |
| 137 | p.FailNext["EnsureConfigSet"] = errors.New("throttle") |
| 138 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app1", "mine.com", now); err == nil { |
| 139 | t.Fatal("expected the injected failure") |
| 140 | } |
| 141 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app1", "mine.com", now) |
| 142 | if err != nil { |
| 143 | t.Fatalf("re-run after a crash past EnsureIdentity: %v", err) |
| 144 | } |
| 145 | if rec.Regions[d.Region].Adopted { |
| 146 | t.Fatal("an identity CARLOS created was recorded as adopted") |
| 147 | } |
| 148 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app2", "mine.com", now); err != nil { |
| 149 | t.Fatalf("sibling app: %v", err) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // A record written before this change (no claim stamp, no Adopted field) |
| 154 | // still re-ensures and verifies. Teardown no longer deletes its identity: |
| 155 | // nothing says CARLOS created it, so it is left in SES. |
| 156 | func TestEnsureDomainHonoursALegacyRecord(t *testing.T) { |
| 157 | ctx := context.Background() |
| 158 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 159 | p := NewMemProvisioner("shared") |
| 160 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 161 | d := testDeployment() |
| 162 | id := seedForeignIdentity(p, d.Region, "old.com") // created by a pre-gate walk |
| 163 | legacy := &DomainRecord{Domain: "old.com", Kind: KindCustom, Apps: []string{"app1"}, CreatedAt: now, |
| 164 | Regions: map[string]*RegionState{d.Region: {IdentityARN: id.ARN, DKIMTokens: id.DKIMTokens, Status: StatusVerified}}} |
| 165 | if _, err := PutDomain(ctx, s, "aaa", legacy, ""); err != nil { |
| 166 | t.Fatal(err) |
| 167 | } |
| 168 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app1", "old.com", now); err != nil { |
| 169 | t.Fatalf("legacy re-ensure: %v", err) |
| 170 | } |
| 171 | if _, err := VerifyDomain(ctx, s, p, d, "aaa", "old.com", d.Region, now); err != nil { |
| 172 | t.Fatalf("legacy verify: %v", err) |
| 173 | } |
| 174 | if err := Teardown(ctx, s, p, w, d, "aaa", "app1"); err != nil { |
| 175 | t.Fatalf("teardown: %v", err) |
| 176 | } |
| 177 | if _, ok := p.Identities[identityKey(d.Region, "old.com")]; !ok { |
| 178 | t.Fatal("teardown deleted a legacy record's identity that nothing says CARLOS created") |
| 179 | } |
| 180 | if c, _ := getDomainClaim(ctx, s, "old.com"); c != nil { |
| 181 | t.Fatalf("teardown left the claim behind: %+v", c) |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | // The reviewer's case: on main, the #216 attack itself left a record that |
| 186 | // adopted a foreign identity, with a claim and no stamp. `domains rm` after |
| 187 | // this change must not delete the real owner's identity. |
| 188 | func TestRemoveDomainSparesALegacyAdoptedIdentity(t *testing.T) { |
| 189 | ctx := context.Background() |
| 190 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 191 | p := NewMemProvisioner("shared") |
| 192 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 193 | d := testDeployment() |
| 194 | id := seedForeignIdentity(p, d.Region, "tito.io") |
| 195 | if err := claimDomain(ctx, s, "tito.io", "aaa", "jelly", now); err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | legacy := &DomainRecord{Domain: "tito.io", Kind: KindCustom, Apps: []string{"jelly"}, CreatedAt: now, |
| 199 | Regions: map[string]*RegionState{d.Region: {IdentityARN: id.ARN, DKIMTokens: id.DKIMTokens, Status: StatusVerified}}} |
| 200 | if _, err := PutDomain(ctx, s, "aaa", legacy, ""); err != nil { |
| 201 | t.Fatal(err) |
| 202 | } |
| 203 | if _, err := RemoveDomain(ctx, s, p, w, d, "aaa", "jelly", "tito.io"); err != nil { |
| 204 | t.Fatalf("RemoveDomain: %v", err) |
| 205 | } |
| 206 | if _, ok := p.Identities[identityKey(d.Region, "tito.io")]; !ok { |
| 207 | t.Fatal("domains rm deleted an identity a pre-fix record had adopted") |
| 208 | } |
| 209 | if c, _ := getDomainClaim(ctx, s, "tito.io"); c != nil { |
| 210 | t.Fatalf("the claim was not released: %+v", c) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // A holder that verifies between the lapse check and the takeover keeps |
| 215 | // its claim and its identity: the verified_at stamp moves the etag. |
| 216 | func TestLapsedTakeoverLosesToAHolderThatVerifies(t *testing.T) { |
| 217 | ctx := context.Background() |
| 218 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 219 | p := NewMemProvisioner("shared") |
| 220 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 221 | d := testDeployment() |
| 222 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app", "race.com", now); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | later := now.Add(ClaimLapse + time.Hour) |
| 226 | // Judge the lapse on the current etag, then let the holder verify. |
| 227 | cur, etag, err := getDomainClaimETag(ctx, s, "race.com") |
| 228 | if err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | if lapsed, err := claimLapsed(ctx, s, p, cur, d.Region, later); err != nil || !lapsed { |
| 232 | t.Fatalf("setup: claim should read lapsed: %v %v", lapsed, err) |
| 233 | } |
| 234 | p.SetIdentityStatus(d.Region, "race.com", Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| 235 | if _, err := VerifyDomain(ctx, s, p, d, "aaa", "race.com", d.Region, later); err != nil { |
| 236 | t.Fatal(err) |
| 237 | } |
| 238 | body := []byte(`{"schema":1,"domain":"race.com","account":"bbb","created_at":"2026-10-11T13:00:00Z"}`) |
| 239 | if _, err := s.Put(ctx, domainClaimKey("race.com"), body, etag); !errors.Is(err, release.ErrCASConflict) { |
| 240 | t.Fatalf("a takeover on the pre-verify etag = %v, want a CAS conflict", err) |
| 241 | } |
| 242 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "app", "race.com", later); !errors.Is(err, ErrDomainClaimed) { |
| 243 | t.Fatalf("takeover after the holder verified = %v, want ErrDomainClaimed", err) |
| 244 | } |
| 245 | if _, ok := p.Identities[identityKey(d.Region, "race.com")]; !ok { |
| 246 | t.Fatal("the verified holder lost its identity") |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | // Finding 2: an unverified claim lapses after ClaimLapse and another |
| 251 | // account may take the domain. The squatter's half-made identity goes with |
| 252 | // it, so the new claimant starts fresh, with DKIM tokens of its own. |
| 253 | func TestUnverifiedClaimLapses(t *testing.T) { |
| 254 | ctx := context.Background() |
| 255 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 256 | p := NewMemProvisioner("shared") |
| 257 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 258 | d := testDeployment() |
| 259 | |
| 260 | first, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "squat", "victim.com", now) |
| 261 | if err != nil { |
| 262 | t.Fatal(err) |
| 263 | } |
| 264 | squatTokens := first.Regions[d.Region].DKIMTokens |
| 265 | |
| 266 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "real", "victim.com", now.Add(ClaimLapse-time.Hour)); !errors.Is(err, ErrDomainClaimed) { |
| 267 | t.Fatalf("a claim inside its lapse window was taken: %v", err) |
| 268 | } |
| 269 | rec, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "real", "victim.com", now.Add(ClaimLapse+time.Hour)) |
| 270 | if err != nil { |
| 271 | t.Fatalf("a lapsed claim was not released to another account: %v", err) |
| 272 | } |
| 273 | if got := rec.Regions[d.Region].DKIMTokens; got[0] == squatTokens[0] { |
| 274 | t.Fatalf("the new holder inherited the lapsed holder's identity: %v", got) |
| 275 | } |
| 276 | if c, _ := getDomainClaim(ctx, s, "victim.com"); c == nil || c.Account != "bbb" { |
| 277 | t.Fatalf("claim after lapse = %+v, want bbb's", c) |
| 278 | } |
| 279 | |
| 280 | // The lapsed holder can neither verify its stale record into a grant |
| 281 | // nor tear down the identity that is now bbb's. |
| 282 | p.SetIdentityStatus(d.Region, "victim.com", Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| 283 | if _, err := VerifyDomain(ctx, s, p, d, "aaa", "victim.com", d.Region, now.Add(ClaimLapse+2*time.Hour)); !errors.Is(err, ErrDomainClaimed) { |
| 284 | t.Fatalf("lapsed holder's verify = %v, want ErrDomainClaimed", err) |
| 285 | } |
| 286 | if old, _, _ := GetDomain(ctx, s, "aaa", "victim.com"); old == nil || old.Regions[d.Region].Status == StatusVerified { |
| 287 | t.Fatalf("lapsed holder's record reads verified: %+v", old) |
| 288 | } |
| 289 | if err := Teardown(ctx, s, p, w, d, "aaa", "squat"); err != nil { |
| 290 | t.Fatalf("lapsed holder's teardown: %v", err) |
| 291 | } |
| 292 | if _, ok := p.Identities[identityKey(d.Region, "victim.com")]; !ok { |
| 293 | t.Fatal("the lapsed holder's teardown deleted the new holder's identity") |
| 294 | } |
| 295 | if c, _ := getDomainClaim(ctx, s, "victim.com"); c == nil || c.Account != "bbb" { |
| 296 | t.Fatalf("the lapsed holder's teardown disturbed bbb's claim: %+v", c) |
| 297 | } |
| 298 | } |
| 299 | |
| 300 | // A verified claim never lapses, even if its record later reads failed — |
| 301 | // and neither does one SES has verified while nobody ran verify. |
| 302 | func TestVerifiedClaimDoesNotLapse(t *testing.T) { |
| 303 | ctx := context.Background() |
| 304 | s, w := release.NewDirStore(t.TempDir()), dnsrec.NewMemWriter() |
| 305 | p := NewMemProvisioner("shared") |
| 306 | now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| 307 | d := testDeployment() |
| 308 | |
| 309 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app", "kept.com", now); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | p.SetIdentityStatus(d.Region, "kept.com", Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| 313 | if _, err := VerifyDomain(ctx, s, p, d, "aaa", "kept.com", d.Region, now); err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | p.SetIdentityStatus(d.Region, "kept.com", Status{DKIM: "FAILED", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| 317 | if _, err := VerifyDomain(ctx, s, p, d, "aaa", "kept.com", d.Region, now); err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "app", "kept.com", now.Add(10*ClaimLapse)); !errors.Is(err, ErrDomainClaimed) { |
| 321 | t.Fatalf("a once-verified claim lapsed: %v", err) |
| 322 | } |
| 323 | |
| 324 | // Verified at SES, never verified through CARLOS. |
| 325 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "aaa", "app", "quiet.com", now); err != nil { |
| 326 | t.Fatal(err) |
| 327 | } |
| 328 | p.SetIdentityStatus(d.Region, "quiet.com", Status{DKIM: "SUCCESS", MailFrom: "SUCCESS", Sending: "SUCCESS"}) |
| 329 | if _, _, err := EnsureDomain(ctx, s, p, w, d, "bbb", "app", "quiet.com", now.Add(10*ClaimLapse)); !errors.Is(err, ErrDomainClaimed) { |
| 330 | t.Fatalf("a claim SES had verified lapsed: %v", err) |
| 331 | } |
| 332 | } |
| 333 | |