| 1 | package 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 | |
| 14 | import ( |
| 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. |
| 45 | var 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. |
| 50 | func 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. |
| 65 | func 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. |
| 84 | func 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. |
| 136 | func 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. |
| 154 | func 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. |
| 171 | func 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. |
| 189 | func 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. |
| 210 | func 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. |
| 241 | func 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. |
| 260 | func 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. |
| 310 | func 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. |
| 329 | func 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. |
| 343 | func 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. |
| 360 | func 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. |
| 388 | func 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. |
| 416 | func 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. |
| 436 | func 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. |
| 454 | func 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. |
| 470 | func 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 | |
| 491 | func 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 | |
| 513 | func 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 | |
| 530 | func 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 | |
| 546 | func 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. |
| 575 | func 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. |
| 612 | func 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. |
| 634 | func 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. |
| 650 | func 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. |
| 671 | func 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. |
| 687 | func 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 | |
| 714 | func 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 | |
| 721 | func 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 | |
| 737 | func 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. |
| 750 | func 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. |
| 792 | func 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 | |