carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// The SNS ingest door (tenant-email spec §9): SNS delivers send, bounce and
4// complaint notifications here. It is unauthenticated — SNS is not a signed-
5// in member — so what stands between a forged bounce and an automatic pause
6// on a stranger's say-so is exactly what these tests pin: the TopicArn
7// allowlist, the SigningCertURL/SubscribeURL host check (pinned to this
8// deployment's own region when known), HTTPS to that pinned host with
9// redirects refused on both outbound fetches, the certificate's subject
10// assertion — NOT a chain build; sesevents.go's file comment records why
11// there is no chain to build for SNS's own signing certificate — and the
12// ordering that runs all of it before any network call or trusted field.
13
14import (
15 "bytes"
16 "context"
17 "crypto"
18 "crypto/rand"
19 "crypto/rsa"
20 "crypto/sha1"
21 "crypto/sha256"
22 "crypto/tls"
23 "crypto/x509"
24 "crypto/x509/pkix"
25 "encoding/base64"
26 "encoding/pem"
27 "errors"
28 "log"
29 "math/big"
30 "net"
31 "net/http"
32 "net/http/httptest"
33 "os"
34 "strings"
35 "testing"
36 "time"
37
38 "github.com/carlosframework/platform/internal/mail"
39 "github.com/carlosframework/platform/internal/release"
40)
41
42// errFakeSignature stands in for a real crypto failure in the seam tests —
43// the specific error doesn't matter, only that a non-nil one from snsVerify
44// stops the handler before it dispatches.
45var errFakeSignature = errors.New("fake: signature does not verify")
46
47// newConfirmServer stands in for AWS at the far end of a SubscribeURL: a GET
48// landing on it proves the handler actually fetched the confirmation link
49// rather than silently dropping SubscriptionConfirmation on the floor.
50func newConfirmServer(t *testing.T, confirmed chan struct{}) *httptest.Server {
51 t.Helper()
52 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53 select {
54 case confirmed <- struct{}{}:
55 default:
56 }
57 w.WriteHeader(http.StatusOK)
58 }))
59}
60
61// notReached fails the test if its handler is ever invoked. Used where a
62// status code alone is not evidence — a timeout and an active refusal can
63// both produce a non-2xx, and only one of them proves the far end was never
64// asked.
65func notReached(t *testing.T) *httptest.Server {
66 t.Helper()
67 return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
68 t.Errorf("hostile server was reached: %s %s", r.Method, r.URL)
69 w.WriteHeader(http.StatusOK)
70 }))
71}
72
73// The SigningCertURL check is the ENTIRETY of SNS signature security — half
74// of it, along with fetchVerifiedCertKey's subject check (no chain build —
75// sesevents.go's file comment records why there is none to build for SNS's
76// own signing certificate). If an
77// attacker can point it at a host they control, they sign their own message
78// with their own certificate and it verifies — which means forging the
79// bounce events that trigger an automatic pause, on any tenant.
80//
81// This test exercises the FALLBACK shape check (region == ""): a
82// region-shaped middle label, never a bare service name. The stricter
83// exact-region pin is TestSigningCertURLPinsToConfiguredRegionExactly.
84func TestSigningCertURLMustBeAmazon(t *testing.T) {
85 ok := []string{
86 "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem",
87 "https://sns.us-east-1.amazonaws.com/x.pem",
88 "https://sns.us-gov-west-1.amazonaws.com/x.pem",
89 "https://sns.ap-southeast-4.amazonaws.com/x.pem",
90 // The real SubscribeURL shape: a query string, not a bare path —
91 // validSigningCertURL is reused unchanged to check SubscribeURL too.
92 "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:eu-west-1:1:carlos-mail&Token=abc123",
93 }
94 for _, u := range ok {
95 if err := validSigningCertURL(u, ""); err != nil {
96 t.Fatalf("validSigningCertURL(%q, \"\") = %v, want nil", u, err)
97 }
98 }
99 bad := []struct{ url, why string }{
100 {"http://sns.eu-west-1.amazonaws.com/x.pem", "plain http"},
101 {"https://evil.com/x.pem", "not amazon at all"},
102 {"https://sns.eu-west-1.amazonaws.com.evil.com/x.pem", "suffix lookalike"},
103 {"https://amazonaws.com.evil.com/x.pem", "suffix lookalike"},
104 {"https://evilamazonaws.com/x.pem", "no dot before the suffix"},
105 {"https://sns.eu-west-1.amazonaws.com/../x", "path traversal"},
106 {"", "empty"},
107 {"not a url", "unparseable"},
108 // A bare "*.amazonaws.com" allowlist accepts these: they are real,
109 // currently-issuable AWS hosts belonging to OTHER services, not SNS.
110 // Any AWS customer — free tier, ten minutes — can stand one up and
111 // get a genuine, publicly-trusted certificate for it.
112 {"https://evil-bucket.s3.amazonaws.com/x.pem", "S3 bucket, not SNS"},
113 {"https://evil-bucket.s3.eu-west-1.amazonaws.com/attacker.pem", "regional S3 bucket, not SNS"},
114 {"https://xyz.execute-api.eu-west-1.amazonaws.com/p/x.pem", "API Gateway stage, not SNS"},
115 // C1-RESIDUAL: pinning only the "sns." label and leaving the second
116 // label free is the documented sns-validator bypass shape — "sns"
117 // itself is a real, registered (and reachable — 403 AccessDenied,
118 // i.e. owned by someone) S3 bucket name.
119 {"https://sns.s3.amazonaws.com/x.pem", "sns-named S3 bucket — the documented sns-validator bypass"},
120 {"https://sns.s3-us-west-2.amazonaws.com/x.pem", "sns-named regional S3 bucket — same bypass shape"},
121 }
122 for _, c := range bad {
123 if err := validSigningCertURL(c.url, ""); err == nil {
124 t.Fatalf("validSigningCertURL(%q, \"\") accepted — %s", c.url, c.why)
125 }
126 }
127}
128
129// When this deployment's own region is known, the host must equal
130// sns.<region>.amazonaws.com EXACTLY — not merely "look like" a region.
131// Without this, a deployment in eu-west-1 would still accept a validly-
132// shaped certificate URL for an entirely different region, which is more
133// host than any single deployment's SNS topic ever needs to trust, and the
134// bucket-name bypass shape (C1-residual) is refused a second, independent
135// way.
136func TestSigningCertURLPinsToConfiguredRegionExactly(t *testing.T) {
137 if err := validSigningCertURL("https://sns.eu-west-1.amazonaws.com/x.pem", "eu-west-1"); err != nil {
138 t.Fatalf("the deployment's own region was refused: %v", err)
139 }
140 bad := []struct{ url, why string }{
141 {"https://sns.us-east-1.amazonaws.com/x.pem", "a different, but validly-shaped, region"},
142 {"https://sns.s3.amazonaws.com/x.pem", "the bypass shape, now doubly refused"},
143 {"https://sns.eu-west-1.amazonaws.com.evil.com/x.pem", "suffix lookalike"},
144 {"http://sns.eu-west-1.amazonaws.com/x.pem", "plain http"},
145 }
146 for _, c := range bad {
147 if err := validSigningCertURL(c.url, "eu-west-1"); err == nil {
148 t.Fatalf("validSigningCertURL(%q, \"eu-west-1\") accepted — %s", c.url, c.why)
149 }
150 }
151}
152
153// sesPost drives the ingest endpoint the way SNS would.
154func sesPost(t *testing.T, fx *consoleFixture, body string) *http.Response {
155 t.Helper()
156 req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/ses/events", strings.NewReader(body))
157 if err != nil {
158 t.Fatal(err)
159 }
160 req.Header.Set("Content-Type", "text/plain")
161 resp, err := fx.ts.Client().Do(req)
162 if err != nil {
163 t.Fatal(err)
164 }
165 return resp
166}
167
168// A message whose TopicArn is not ours is refused BEFORE any certificate is
169// fetched — otherwise this endpoint makes outbound requests on a stranger's
170// say-so, which is an SSRF with an AWS-shaped alibi.
171func TestSESEventsRefusesForeignTopic(t *testing.T) {
172 fx := newConsoleFixture(t)
173 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
174 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",`+
175 `"Message":"{}","SigningCertURL":"https://evil.com/x.pem","Signature":"x"}`)
176 defer resp.Body.Close()
177 if resp.StatusCode == http.StatusOK {
178 t.Fatal("a foreign TopicArn was accepted")
179 }
180}
181
182// TestSESEventsRefusesForeignTopic above pairs its bad TopicArn with an
183// invalid SigningCertURL, so a broken TopicArn check would still be masked
184// by the cert-host check that runs after it — the request would be refused
185// either way, for the wrong reason. This test isolates the TopicArn check
186// by giving it an otherwise-VALID cert URL and a signature seam that always
187// succeeds: nothing but the TopicArn comparison stands between this request
188// and a 200.
189func TestSESEventsRefusesForeignTopicEvenWithAnOtherwiseValidRequest(t *testing.T) {
190 fx := newConsoleFixture(t)
191 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
192 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } // isolate: not a signature test
193 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",`+
194 `"Message":"{\"eventType\":\"Send\",\"mail\":{\"source\":\"a@b.com\"}}",`+
195 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
196 defer resp.Body.Close()
197 if resp.StatusCode == http.StatusOK {
198 t.Fatal("a foreign TopicArn was accepted once every other check would have passed")
199 }
200}
201
202// TestSESEventsLogsARefusedTopicOnceAnHour is ruling 2c. A stale or mistyped
203// CARLOS_MAIL_SNS_TOPIC_ARN is the likeliest misconfiguration in the whole
204// event path, and before this it left NO trace: 403s at a door nobody
205// watches and counts that never move.
206//
207// The bound is the other half of the ruling and is tested with it, because
208// this door is unauthenticated and internet-reachable — an unbounded log
209// line here is a stranger's write access to the operator's disk.
210func TestSESEventsLogsARefusedTopicOnceAnHour(t *testing.T) {
211 fx := newConsoleFixture(t)
212 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
213 var buf bytes.Buffer
214 log.SetOutput(&buf)
215 t.Cleanup(func() { log.SetOutput(os.Stderr) })
216
217 body := `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",` +
218 `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`
219 for i := 0; i < 100; i++ {
220 resp := sesPost(t, fx, body)
221 resp.Body.Close()
222 }
223
224 got := buf.String()
225 if n := strings.Count(got, "refused topic"); n != 1 {
226 t.Errorf("logged %d refusal lines for 100 identical requests, want exactly 1 — "+
227 "an unauthenticated door must not let a stranger fill the log\n%s", n, got)
228 }
229 // Both ARNs must appear: the received one to identify the sender, the
230 // expected one because "refused" is useless without what it wanted.
231 if !strings.Contains(got, "someone-else") || !strings.Contains(got, "carlos-mail") {
232 t.Errorf("the line does not name both the received and expected ARNs:\n%s", got)
233 }
234}
235
236// TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently: a console
237// with no topic configured that is nonetheless receiving SNS deliveries is
238// its own misconfiguration — the deployment believes email events are off
239// while SNS is actively delivering to it — and %q of the empty string would
240// read as a bug rather than as that.
241func TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently(t *testing.T) {
242 fx := newConsoleFixture(t)
243 fx.srv.cfg.MailDeployment.SNSTopicARN = ""
244 var buf bytes.Buffer
245 log.SetOutput(&buf)
246 t.Cleanup(func() { log.SetOutput(os.Stderr) })
247
248 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:whoever",`+
249 `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
250 resp.Body.Close()
251
252 if got := buf.String(); !strings.Contains(got, "(none configured)") {
253 t.Errorf("an unconfigured deployment should say so, not print an empty quoted string:\n%s", got)
254 }
255}
256
257// TestSESEventsCountsADelivery is ruling 2a, through the DOOR rather than
258// against mailCountColumn — the standing lesson of this subsystem is that a
259// mapping can be right while nothing calls it.
260func TestSESEventsCountsADelivery(t *testing.T) {
261 fx := newConsoleFixture(t)
262 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
263 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
264 ctx := context.Background()
265 now := time.Now()
266 if _, err := fx.st.CreateAppIn(OpsAccountID, "seapointish", "paul@keymail.dev", now); err != nil {
267 t.Fatal(err)
268 }
269 rec := &mail.DomainRecord{
270 Domain: "seapointish.bag.oncarlos.com",
271 Kind: mail.KindPlatform,
272 Apps: []string{"seapointish"},
273 Regions: map[string]*mail.RegionState{
274 "eu-west-1": {
275 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com",
276 MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com",
277 Status: mail.StatusVerified,
278 },
279 },
280 CreatedAt: now,
281 }
282 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
283 t.Fatal(err)
284 }
285
286 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
287 `"Message":"{\"eventType\":\"Delivery\",\"mail\":{\"source\":\"noreply@seapointish.bag.oncarlos.com\"}}",`+
288 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
289 defer resp.Body.Close()
290 if resp.StatusCode != http.StatusOK {
291 t.Fatalf("status = %d, want 200", resp.StatusCode)
292 }
293
294 day := now.UTC().Format("2006-01-02")
295 c, err := fx.st.MailCounts(ctx, release.OpsAccount, "seapointish", "", day)
296 if err != nil {
297 t.Fatalf("MailCounts: %v", err)
298 }
299 if c.Deliveries != 1 {
300 t.Errorf("deliveries = %d, want 1 — the ingest already verifies and parses these; dropping them wastes the work", c.Deliveries)
301 }
302 if c.Sends != 0 || c.Bounces != 0 || c.Complaints != 0 || c.Rejects != 0 {
303 t.Errorf("a Delivery moved another column: %+v — the cap and thresholds must be untouched by it", c)
304 }
305}
306
307// An unknown event type is a 200. SNS retries non-2xx, so answering 500
308// turns one unrecognised field into an unbounded retry flood against the
309// console.
310func TestSESEventsAnswers200ForUnknownTypes(t *testing.T) {
311 fx := newConsoleFixture(t)
312 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
313 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } // seam: skip signature in tests
314 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
315 `"Message":"{\"eventType\":\"Subscription\",\"mail\":{\"source\":\"a@b.com\"}}",`+
316 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
317 defer resp.Body.Close()
318 if resp.StatusCode != http.StatusOK {
319 t.Fatalf("status = %d, want 200 — a non-2xx makes SNS retry forever", resp.StatusCode)
320 }
321}
322
323// TestSESEventsAnswers200ForUnknownTypes above sends a recognised SNS
324// envelope Type ("Notification") whose INNER SES eventType is the unknown
325// part — it never reaches the dispatch switch's default case at all. This
326// test isolates that case directly: an SNS envelope Type this deployment
327// does not recognise must still answer 200, or SNS retries a message this
328// handler will never understand forever.
329func TestSESEventsAnswers200ForAnUnknownEnvelopeType(t *testing.T) {
330 fx := newConsoleFixture(t)
331 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
332 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
333 resp := sesPost(t, fx, `{"Type":"SomeFutureSNSType","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
334 `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
335 defer resp.Body.Close()
336 if resp.StatusCode != http.StatusOK {
337 t.Fatalf("status = %d, want 200 for an unrecognised envelope Type", resp.StatusCode)
338 }
339}
340
341// The body is size-limited: an unbounded read on an unauthenticated endpoint
342// is a memory exhaustion anyone can trigger.
343func TestSESEventsRefusesAnOversizeBody(t *testing.T) {
344 fx := newConsoleFixture(t)
345 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
346 resp := sesPost(t, fx, `{"Type":"Notification","Message":"`+strings.Repeat("a", 300<<10)+`"}`)
347 defer resp.Body.Close()
348 if resp.StatusCode == http.StatusOK {
349 t.Fatal("an oversize body was accepted")
350 }
351}
352
353// TestSESEventsRefusesAnOversizeBody above sends an otherwise-empty envelope
354// (no TopicArn, no SigningCertURL), so it is also refused by the TopicArn
355// check — the size limit could be entirely absent and this request would
356// still not get a 200. This test isolates the size limit: every other check
357// would pass (matching TopicArn, a valid cert URL, a signature seam that
358// always succeeds, an event type this door ignores) — the only thing that
359// can still refuse a 300KB body is the limit itself.
360func TestSESEventsRefusesAnOversizeBodyEvenWhenOtherwiseValid(t *testing.T) {
361 fx := newConsoleFixture(t)
362 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
363 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
364 resp := sesPost(t, fx, `{"Type":"SomeFutureSNSType","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
365 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x",`+
366 `"Message":"`+strings.Repeat("a", 300<<10)+`"}`)
367 defer resp.Body.Close()
368 if resp.StatusCode == http.StatusOK {
369 t.Fatal("an oversize body was accepted once every other check would have passed")
370 }
371}
372
373// A verified signature only proves AWS put a string in SubscribeURL — not
374// that the string points somewhere safe to GET. Without a host check on it
375// this door is a blind SSRF dispatcher: an authentic message can carry a
376// SubscribeURL aimed at anything reachable from the console, including
377// link-local instance-metadata endpoints.
378//
379// ROUND-3 FIX: the previous version of this test pointed SubscribeURL at
380// 169.254.169.254 and asserted on the STATUS CODE alone. That is vacuous —
381// removing the host-check constraint entirely still leaves this test green,
382// because the fetch to that address simply times out into a 502 in this
383// sandbox, which looks identical to a deliberate refusal. This version
384// stands up a REAL local server as the hostile target and asserts its
385// handler is NEVER invoked — the only way to prove the request was refused
386// BEFORE any fetch was attempted, not merely that some fetch eventually
387// failed.
388func TestSESEventsRefusesASubscribeURLOffAWS(t *testing.T) {
389 fx := newConsoleFixture(t)
390 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
391 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
392
393 hostile := notReached(t)
394 defer hostile.Close()
395
396 resp := sesPost(t, fx, `{"Type":"SubscriptionConfirmation","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
397 `"Message":"You have chosen to subscribe...",`+
398 `"SubscribeURL":"`+hostile.URL+`",`+
399 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
400 defer resp.Body.Close()
401 if resp.StatusCode == http.StatusOK {
402 t.Fatal("a SubscribeURL off an SNS host was accepted")
403 }
404 // notReached's own handler calls t.Errorf if it is ever invoked — by the
405 // time sesPost has returned, the request (refusal or fetch) is already
406 // complete, so no further synchronization is needed here.
407}
408
409// The mechanical half of subscription handling: confirmSNSSubscription
410// itself — reached only after both the signature and the SubscribeURL host
411// check have passed — must actually GET the URL. A SubscriptionConfirmation
412// silently dropped means SNS never delivers another event to this endpoint.
413// Tested directly (below the host-check layer) because a real SubscribeURL
414// is always an sns.<region>.amazonaws.com URL, which nothing in this test
415// suite can stand a fake server up as.
416func TestConfirmSNSSubscriptionFetchesTheURL(t *testing.T) {
417 fx := newConsoleFixture(t)
418 confirmed := make(chan struct{}, 1)
419 ts := newConfirmServer(t, confirmed)
420 defer ts.Close()
421 if err := fx.srv.confirmSNSSubscription(context.Background(), ts.URL); err != nil {
422 t.Fatalf("confirmSNSSubscription: %v", err)
423 }
424 select {
425 case <-confirmed:
426 default:
427 t.Fatal("the SubscribeURL was never fetched — SNS will never deliver another event")
428 }
429}
430
431// IMPORTANT fix: neither outbound fetch this door makes may follow a
432// redirect. For SubscribeURL specifically, validSigningCertURL only ever
433// inspects the URL SNS's own signed message named — a redirect response
434// from that (now-verified) host is a completely different, UNvalidated
435// target, so following it defeats the host pin outright.
436func TestConfirmSNSSubscriptionDoesNotFollowARedirect(t *testing.T) {
437 hostile := notReached(t)
438 defer hostile.Close()
439
440 redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
441 http.Redirect(w, r, hostile.URL, http.StatusFound)
442 }))
443 defer redirector.Close()
444
445 fx := newConsoleFixture(t)
446 if err := fx.srv.confirmSNSSubscription(context.Background(), redirector.URL); err == nil {
447 t.Fatal("a redirect response was treated as a successful confirmation")
448 }
449}
450
451// Same guard, same reasoning, for the certificate fetch: a redirect there
452// would hand an attacker-controlled host the chance to serve the bytes this
453// door treats as SNS's own signing certificate.
454func TestFetchVerifiedCertKeyDoesNotFollowARedirect(t *testing.T) {
455 hostile := notReached(t)
456 defer hostile.Close()
457
458 redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
459 http.Redirect(w, r, hostile.URL, http.StatusFound)
460 }))
461 defer redirector.Close()
462
463 if _, err := fetchVerifiedCertKey(context.Background(), redirector.URL); err == nil {
464 t.Fatal("a redirect response was treated as a successful cert fetch")
465 }
466}
467
468// A signature that does not verify must never reach the dispatch switch —
469// proof the seam only bypasses the crypto, not the rest of the pipeline.
470func TestSESEventsRefusesABadSignature(t *testing.T) {
471 fx := newConsoleFixture(t)
472 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
473 fx.srv.snsVerify = func(_ snsEnvelope) error { return errFakeSignature }
474 resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
475 `"Message":"{\"eventType\":\"Send\",\"mail\":{\"source\":\"a@b.com\"}}",`+
476 `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
477 defer resp.Body.Close()
478 if resp.StatusCode == http.StatusOK {
479 t.Fatal("a message that failed signature verification was accepted")
480 }
481}
482
483// ---- verifySNSSignatureBytes: the pure cryptographic check --------------
484//
485// Split out from verifySNSSignature specifically so the SHA-1/SHA-256
486// branches can be exercised with a plain in-memory RSA keypair — no
487// network, no certificate, no trust store. This is the piece the
488// snsVerify seam always bypasses in the tests above; here it is tested
489// directly instead.
490
491func TestVerifySNSSignatureBytesAcceptsAValidSHA1Signature(t *testing.T) {
492 key, err := rsa.GenerateKey(rand.Reader, 2048)
493 if err != nil {
494 t.Fatal(err)
495 }
496 canon := []byte("Message\nhi\nMessageId\n1\n")
497 sum := sha1.Sum(canon)
498 sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:])
499 if err != nil {
500 t.Fatal(err)
501 }
502 sigB64 := base64.StdEncoding.EncodeToString(sig)
503 if err := verifySNSSignatureBytes(&key.PublicKey, canon, "1", sigB64); err != nil {
504 t.Fatalf("valid SHA1 signature refused: %v", err)
505 }
506 // SignatureVersion "" (unset) is SNS's historical default and must be
507 // treated identically to "1".
508 if err := verifySNSSignatureBytes(&key.PublicKey, canon, "", sigB64); err != nil {
509 t.Fatalf("valid SHA1 signature refused with unset SignatureVersion: %v", err)
510 }
511}
512
513func TestVerifySNSSignatureBytesAcceptsAValidSHA256Signature(t *testing.T) {
514 key, err := rsa.GenerateKey(rand.Reader, 2048)
515 if err != nil {
516 t.Fatal(err)
517 }
518 canon := []byte("Message\nhi\nMessageId\n1\n")
519 sum := sha256.Sum256(canon)
520 sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
521 if err != nil {
522 t.Fatal(err)
523 }
524 sigB64 := base64.StdEncoding.EncodeToString(sig)
525 if err := verifySNSSignatureBytes(&key.PublicKey, canon, "2", sigB64); err != nil {
526 t.Fatalf("valid SHA256 signature refused: %v", err)
527 }
528}
529
530func TestVerifySNSSignatureBytesRefusesATamperedMessage(t *testing.T) {
531 key, err := rsa.GenerateKey(rand.Reader, 2048)
532 if err != nil {
533 t.Fatal(err)
534 }
535 sum := sha1.Sum([]byte("Message\nhi\nMessageId\n1\n"))
536 sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:])
537 if err != nil {
538 t.Fatal(err)
539 }
540 sigB64 := base64.StdEncoding.EncodeToString(sig)
541 if err := verifySNSSignatureBytes(&key.PublicKey, []byte("Message\nbye\nMessageId\n1\n"), "1", sigB64); err == nil {
542 t.Fatal("a signature over a different message verified")
543 }
544}
545
546func TestVerifySNSSignatureBytesRefusesAnUnknownVersion(t *testing.T) {
547 key, err := rsa.GenerateKey(rand.Reader, 2048)
548 if err != nil {
549 t.Fatal(err)
550 }
551 sigB64 := base64.StdEncoding.EncodeToString([]byte("whatever"))
552 if err := verifySNSSignatureBytes(&key.PublicKey, []byte("x"), "3", sigB64); err == nil {
553 t.Fatal("an unsupported SignatureVersion was accepted")
554 }
555}
556
557// ---- fetchVerifiedCertKey: the trust model that survived four rounds ----
558//
559// There is no chain to verify for SNS's own signing certificate (see
560// sesevents.go's package comment, "Round 4") — what's left is: fetched over
561// HTTPS (TLS chain validated by the http client itself), from a pinned
562// host, with no redirect followed, and the certificate's own subject
563// checked against snsCertDNSName. The tests below exercise exactly that
564// combination through fetchVerifiedCertKey ITSELF — not through a
565// hand-rolled x509.Verify call with its own options, which is what let
566// three mutations (dropping the Intermediates pool, asserting the regional
567// host, and fully reverting the round-2 cert fix) all pass unnoticed in an
568// earlier round: nothing exercised the real function on a happy path, so a
569// broken happy path had nothing to break loudly.
570
571// genLeafCert builds a single self-signed leaf certificate for cn — usable
572// as BOTH a TLS server certificate and as the body fetchVerifiedCertKey
573// parses — mirroring AWS's actual wire format exactly: one bare leaf, never
574// an intermediate.
575func genLeafCert(t *testing.T, cn string) (der []byte, cert *x509.Certificate, key *rsa.PrivateKey) {
576 t.Helper()
577 key, err := rsa.GenerateKey(rand.Reader, 2048)
578 if err != nil {
579 t.Fatal(err)
580 }
581 tmpl := &x509.Certificate{
582 SerialNumber: big.NewInt(1),
583 Subject: pkix.Name{CommonName: cn},
584 DNSNames: []string{cn},
585 NotBefore: time.Now().Add(-time.Hour),
586 NotAfter: time.Now().Add(time.Hour),
587 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
588 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
589 BasicConstraintsValid: true,
590 IsCA: true,
591 }
592 der, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
593 if err != nil {
594 t.Fatal(err)
595 }
596 cert, err = x509.ParseCertificate(der)
597 if err != nil {
598 t.Fatal(err)
599 }
600 return der, cert, key
601}
602
603// hijackFetchClient points the package-level snsFetchClient at a local TLS
604// listener for the duration of the calling test: a custom Transport whose
605// DialContext ignores the requested address entirely and always dials
606// listenerAddr, while trusting ONLY trust (added directly to a fresh
607// RootCAs pool as its own anchor). The request's URL host is left
608// untouched, so Go's normal TLS hostname verification still runs for real —
609// against the certificate the local listener actually presents — the same
610// as it would over a genuine network path to the genuine pinned host.
611// Restored automatically via t.Cleanup.
612func hijackFetchClient(t *testing.T, listenerAddr string, trust *x509.Certificate) {
613 t.Helper()
614 roots := x509.NewCertPool()
615 roots.AddCert(trust)
616 orig := snsFetchClient
617 snsFetchClient = &http.Client{
618 CheckRedirect: orig.CheckRedirect,
619 Transport: &http.Transport{
620 DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
621 return (&net.Dialer{}).DialContext(ctx, network, listenerAddr)
622 },
623 TLSClientConfig: &tls.Config{RootCAs: roots},
624 },
625 }
626 t.Cleanup(func() { snsFetchClient = orig })
627}
628
629// newHijackedCertServer starts a local httptest TLS server presenting
630// serverDER as its own TLS certificate, whose handler serves certPEM as the
631// response body — modelling a real SigningCertURL fetch, where the TLS
632// certificate the far end presents and the PEM content it serves need not
633// be (and for this fixture, are) the same bytes.
634func newHijackedCertServer(t *testing.T, der []byte, key *rsa.PrivateKey, certPEM []byte) *httptest.Server {
635 t.Helper()
636 ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
637 w.Write(certPEM)
638 }))
639 ts.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}
640 ts.StartTLS()
641 t.Cleanup(ts.Close)
642 return ts
643}
644
645// THE HAPPY PATH, missing until this round: a certificate shaped exactly
646// like AWS's real one — a single bare leaf, subject sns.amazonaws.com, no
647// intermediate, fetched over a TLS connection whose chain the http client
648// validates itself — must be ACCEPTED by fetchVerifiedCertKey, and the
649// public key it returns must be the fixture's own.
650func TestFetchVerifiedCertKeyAcceptsTheRealShape(t *testing.T) {
651 der, leaf, key := genLeafCert(t, snsCertDNSName)
652 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
653 ts := newHijackedCertServer(t, der, key, certPEM)
654 hijackFetchClient(t, ts.Listener.Addr().String(), leaf)
655
656 pub, err := fetchVerifiedCertKey(context.Background(), "https://"+snsCertDNSName+"/SimpleNotificationService.pem")
657 if err != nil {
658 t.Fatalf("a real-shaped SNS certificate (single bare leaf, subject %s, no intermediate) was refused: %v", snsCertDNSName, err)
659 }
660 if pub.N.Cmp(key.PublicKey.N) != 0 {
661 t.Fatal("fetchVerifiedCertKey returned a different key than the fixture's own")
662 }
663}
664
665// The subject assertion is now the ONLY check standing between an
666// authentically-fetched (valid TLS, pinned dial target, no redirect) but
667// WRONG-content certificate and treating it as SNS's own — there is no
668// chain check left to catch this (see fetchVerifiedCertKey's doc comment).
669// A certificate whose subject is NOT sns.amazonaws.com must still be
670// refused even though everything about how it was fetched is legitimate.
671func TestFetchVerifiedCertKeyRefusesAWrongSubject(t *testing.T) {
672 const wrongCN = "not-sns.example.com"
673 der, leaf, key := genLeafCert(t, wrongCN)
674 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
675 ts := newHijackedCertServer(t, der, key, certPEM)
676 hijackFetchClient(t, ts.Listener.Addr().String(), leaf)
677
678 if _, err := fetchVerifiedCertKey(context.Background(), "https://"+wrongCN+"/x.pem"); err == nil {
679 t.Fatal("a certificate with the wrong subject was accepted")
680 }
681}
682
683// The cache sits in front of fetchVerifiedCertKey (verifiedCertKey), so a
684// second call for the SAME already-cached URL must not re-fetch — proven
685// here by pointing the cache at a URL nothing is listening on: a cache miss
686// would fail with a connection error, a hit returns the seeded key.
687func TestVerifiedCertKeyServesFromCache(t *testing.T) {
688 fx := newConsoleFixture(t)
689 key, err := rsa.GenerateKey(rand.Reader, 2048)
690 if err != nil {
691 t.Fatal(err)
692 }
693 const certURL = "https://sns.eu-west-1.amazonaws.com/nothing-listens-here.pem"
694 fx.srv.snsCerts.put(certURL, &key.PublicKey)
695
696 pub, err := fx.srv.verifiedCertKey(context.Background(), certURL)
697 if err != nil {
698 t.Fatalf("verifiedCertKey did not serve from cache: %v", err)
699 }
700 if pub.N.Cmp(key.PublicKey.N) != 0 {
701 t.Fatal("verifiedCertKey returned a different key than the one cached")
702 }
703}
704
705// ---- event-to-domain attribution (I6, review follow-up) --------------------
706//
707// ParseEvent attributes an event by the domain of mail.source. If SES
708// reports the REWRITTEN envelope — mail-<region>.<domain>, the custom MAIL
709// FROM MX target (spec §7) — rather than the sender-supplied From, an exact
710// match against DomainRecord.Domain alone attributes the event to nothing,
711// and it is silently dropped from the counts that enforce the cap and trip
712// the abuse pause: a second, independent way spec §9 stops working.
713
714func TestDomainMatchesEventAcceptsTheIdentitysOwnDomain(t *testing.T) {
715 d := &mail.DomainRecord{Domain: "harbourmail.bag.oncarlos.com"}
716 if !domainMatchesEvent(d, "harbourmail.bag.oncarlos.com") {
717 t.Fatal("the identity's own domain must match")
718 }
719}
720
721func TestDomainMatchesEventAcceptsTheRecordedMailFromForAnyRegion(t *testing.T) {
722 d := &mail.DomainRecord{
723 Domain: "harbourmail.bag.oncarlos.com",
724 Regions: map[string]*mail.RegionState{
725 "eu-west-1": {MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com"},
726 "us-east-1": {MailFrom: "mail-us-east-1.harbourmail.bag.oncarlos.com"},
727 },
728 }
729 if !domainMatchesEvent(d, "mail-eu-west-1.harbourmail.bag.oncarlos.com") {
730 t.Fatal("the eu-west-1 MAIL FROM subdomain must match")
731 }
732 if !domainMatchesEvent(d, "mail-us-east-1.harbourmail.bag.oncarlos.com") {
733 t.Fatal("a second region's MAIL FROM subdomain must match too")
734 }
735}
736
737func TestDomainMatchesEventRefusesAnUnrelatedDomain(t *testing.T) {
738 d := &mail.DomainRecord{
739 Domain: "harbourmail.bag.oncarlos.com",
740 Regions: map[string]*mail.RegionState{"eu-west-1": {MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com"}},
741 }
742 if domainMatchesEvent(d, "unrelated.example.com") {
743 t.Fatal("an unrelated domain must not match — this must stay a match against RECORDED values, never a prefix-strip heuristic")
744 }
745}
746
747// TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope is the
748// end-to-end proof: an SES event reporting the rewritten MAIL FROM envelope
749// (not the identity's own domain) must still land in this app's counts.
750func TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope(t *testing.T) {
751 fx := newConsoleFixture(t)
752 ctx := context.Background()
753 now := time.Now()
754 if _, err := fx.st.CreateAppIn(OpsAccountID, "harbourmail", "paul@keymail.dev", now); err != nil {
755 t.Fatal(err)
756 }
757 rec := &mail.DomainRecord{
758 Domain: "harbourmail.bag.oncarlos.com",
759 Kind: mail.KindPlatform,
760 Apps: []string{"harbourmail"},
761 Regions: map[string]*mail.RegionState{
762 "eu-west-1": {
763 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/harbourmail.bag.oncarlos.com",
764 MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com",
765 Status: mail.StatusVerified,
766 },
767 },
768 CreatedAt: now,
769 }
770 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
771 t.Fatal(err)
772 }
773
774 ev := mail.Event{Type: mail.EventSend, Domain: "mail-eu-west-1.harbourmail.bag.oncarlos.com", At: now}
775 fx.srv.recordAndEnforceMail(ctx, ev, "")
776
777 day := now.UTC().Format("2006-01-02")
778 counts, err := fx.srv.store.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day)
779 if err != nil {
780 t.Fatal(err)
781 }
782 if counts.Sends != 1 {
783 t.Fatalf("Sends = %d, want 1 — the rewritten MAIL FROM envelope must still attribute to the app", counts.Sends)
784 }
785}
786
787// The domain fallback skips an app sender in per-instance mode (senders
788// design §8). Nothing sets PerInstance yet, so this hand-sets it: the app
789// sender is deliberately dead once its app moves to per-instance senders,
790// and charging it for a domain-wide event it cannot have sent would pause a
791// credential nobody holds.
792func TestRecordAndEnforceMailSkipsAPerInstanceAppInTheDomainFallback(t *testing.T) {
793 fx := newConsoleFixture(t)
794 ctx := context.Background()
795 now := time.Now()
796 for _, app := range []string{"jb-book", "jb-site"} {
797 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now); err != nil {
798 t.Fatal(err)
799 }
800 }
801 rec := &mail.DomainRecord{
802 Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book", "jb-site"},
803 Regions: map[string]*mail.RegionState{"eu-west-1": {
804 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", MailFrom: "mail-eu-west-1.lgc.li", Status: mail.StatusVerified,
805 }},
806 CreatedAt: now,
807 }
808 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
809 t.Fatal(err)
810 }
811 // Hand-set jb-site's app sender to per-instance mode: nothing in this
812 // release writes PerInstance itself, so the only way to exercise the
813 // skip is to put the sender record directly.
814 perInstance := &mail.Sender{ID: mail.SenderAppID, Scope: mail.ScopeApp, Source: mail.SourceDomain, PerInstance: true}
815 if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "jb-site", perInstance, ""); err != nil {
816 t.Fatal(err)
817 }
818
819 ev := bounceTo("ann@example.com", "m-domain-fallback", now)
820 // An unnamed caller, so this falls back to the domain loop rather than
821 // resolving to a sender directly.
822 fx.srv.recordAndEnforceMail(ctx, ev, "sns-fallback")
823
824 day := now.UTC().Format("2006-01-02")
825 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "", day); c.Bounces != 1 {
826 t.Errorf("jb-book bounces = %d, want 1 — it has no PerInstance app sender", c.Bounces)
827 }
828 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-site", "", day); c.Bounces != 0 {
829 t.Errorf("jb-site bounces = %d, want 0 — its app sender is in per-instance mode and must not be charged", c.Bounces)
830 }
831 if got, _ := fx.st.MailEvents(ctx, release.OpsAccount, "jb-site", MailEventQuery{}, now); len(got) != 0 {
832 t.Errorf("jb-site's log got a row from a per-instance app sender's skipped domain fallback: %+v", got)
833 }
834}
835