carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
4package mail
5
6import (
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.
19func 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.
34func 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.
66type fakeTXT map[string][]string
67
68func (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.
79func 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).
130func 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.
156func 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.
188func 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.
216func 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.
253func 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.
302func 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