package console // The SNS ingest door (tenant-email spec §9): SNS delivers send, bounce and // complaint notifications here. It is unauthenticated — SNS is not a signed- // in member — so what stands between a forged bounce and an automatic pause // on a stranger's say-so is exactly what these tests pin: the TopicArn // allowlist, the SigningCertURL/SubscribeURL host check (pinned to this // deployment's own region when known), HTTPS to that pinned host with // redirects refused on both outbound fetches, the certificate's subject // assertion — NOT a chain build; sesevents.go's file comment records why // there is no chain to build for SNS's own signing certificate — and the // ordering that runs all of it before any network call or trusted field. import ( "bytes" "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/pem" "errors" "log" "math/big" "net" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/release" ) // errFakeSignature stands in for a real crypto failure in the seam tests — // the specific error doesn't matter, only that a non-nil one from snsVerify // stops the handler before it dispatches. var errFakeSignature = errors.New("fake: signature does not verify") // newConfirmServer stands in for AWS at the far end of a SubscribeURL: a GET // landing on it proves the handler actually fetched the confirmation link // rather than silently dropping SubscriptionConfirmation on the floor. func newConfirmServer(t *testing.T, confirmed chan struct{}) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { select { case confirmed <- struct{}{}: default: } w.WriteHeader(http.StatusOK) })) } // notReached fails the test if its handler is ever invoked. Used where a // status code alone is not evidence — a timeout and an active refusal can // both produce a non-2xx, and only one of them proves the far end was never // asked. func notReached(t *testing.T) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("hostile server was reached: %s %s", r.Method, r.URL) w.WriteHeader(http.StatusOK) })) } // The SigningCertURL check is the ENTIRETY of SNS signature security — half // of it, along with fetchVerifiedCertKey's subject check (no chain build — // sesevents.go's file comment records why there is none to build for SNS's // own signing certificate). If an // attacker can point it at a host they control, they sign their own message // with their own certificate and it verifies — which means forging the // bounce events that trigger an automatic pause, on any tenant. // // This test exercises the FALLBACK shape check (region == ""): a // region-shaped middle label, never a bare service name. The stricter // exact-region pin is TestSigningCertURLPinsToConfiguredRegionExactly. func TestSigningCertURLMustBeAmazon(t *testing.T) { ok := []string{ "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem", "https://sns.us-east-1.amazonaws.com/x.pem", "https://sns.us-gov-west-1.amazonaws.com/x.pem", "https://sns.ap-southeast-4.amazonaws.com/x.pem", // The real SubscribeURL shape: a query string, not a bare path — // validSigningCertURL is reused unchanged to check SubscribeURL too. "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:eu-west-1:1:carlos-mail&Token=abc123", } for _, u := range ok { if err := validSigningCertURL(u, ""); err != nil { t.Fatalf("validSigningCertURL(%q, \"\") = %v, want nil", u, err) } } bad := []struct{ url, why string }{ {"http://sns.eu-west-1.amazonaws.com/x.pem", "plain http"}, {"https://evil.com/x.pem", "not amazon at all"}, {"https://sns.eu-west-1.amazonaws.com.evil.com/x.pem", "suffix lookalike"}, {"https://amazonaws.com.evil.com/x.pem", "suffix lookalike"}, {"https://evilamazonaws.com/x.pem", "no dot before the suffix"}, {"https://sns.eu-west-1.amazonaws.com/../x", "path traversal"}, {"", "empty"}, {"not a url", "unparseable"}, // A bare "*.amazonaws.com" allowlist accepts these: they are real, // currently-issuable AWS hosts belonging to OTHER services, not SNS. // Any AWS customer — free tier, ten minutes — can stand one up and // get a genuine, publicly-trusted certificate for it. {"https://evil-bucket.s3.amazonaws.com/x.pem", "S3 bucket, not SNS"}, {"https://evil-bucket.s3.eu-west-1.amazonaws.com/attacker.pem", "regional S3 bucket, not SNS"}, {"https://xyz.execute-api.eu-west-1.amazonaws.com/p/x.pem", "API Gateway stage, not SNS"}, // C1-RESIDUAL: pinning only the "sns." label and leaving the second // label free is the documented sns-validator bypass shape — "sns" // itself is a real, registered (and reachable — 403 AccessDenied, // i.e. owned by someone) S3 bucket name. {"https://sns.s3.amazonaws.com/x.pem", "sns-named S3 bucket — the documented sns-validator bypass"}, {"https://sns.s3-us-west-2.amazonaws.com/x.pem", "sns-named regional S3 bucket — same bypass shape"}, } for _, c := range bad { if err := validSigningCertURL(c.url, ""); err == nil { t.Fatalf("validSigningCertURL(%q, \"\") accepted — %s", c.url, c.why) } } } // When this deployment's own region is known, the host must equal // sns..amazonaws.com EXACTLY — not merely "look like" a region. // Without this, a deployment in eu-west-1 would still accept a validly- // shaped certificate URL for an entirely different region, which is more // host than any single deployment's SNS topic ever needs to trust, and the // bucket-name bypass shape (C1-residual) is refused a second, independent // way. func TestSigningCertURLPinsToConfiguredRegionExactly(t *testing.T) { if err := validSigningCertURL("https://sns.eu-west-1.amazonaws.com/x.pem", "eu-west-1"); err != nil { t.Fatalf("the deployment's own region was refused: %v", err) } bad := []struct{ url, why string }{ {"https://sns.us-east-1.amazonaws.com/x.pem", "a different, but validly-shaped, region"}, {"https://sns.s3.amazonaws.com/x.pem", "the bypass shape, now doubly refused"}, {"https://sns.eu-west-1.amazonaws.com.evil.com/x.pem", "suffix lookalike"}, {"http://sns.eu-west-1.amazonaws.com/x.pem", "plain http"}, } for _, c := range bad { if err := validSigningCertURL(c.url, "eu-west-1"); err == nil { t.Fatalf("validSigningCertURL(%q, \"eu-west-1\") accepted — %s", c.url, c.why) } } } // sesPost drives the ingest endpoint the way SNS would. func sesPost(t *testing.T, fx *consoleFixture, body string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/ses/events", strings.NewReader(body)) if err != nil { t.Fatal(err) } req.Header.Set("Content-Type", "text/plain") resp, err := fx.ts.Client().Do(req) if err != nil { t.Fatal(err) } return resp } // A message whose TopicArn is not ours is refused BEFORE any certificate is // fetched — otherwise this endpoint makes outbound requests on a stranger's // say-so, which is an SSRF with an AWS-shaped alibi. func TestSESEventsRefusesForeignTopic(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",`+ `"Message":"{}","SigningCertURL":"https://evil.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("a foreign TopicArn was accepted") } } // TestSESEventsRefusesForeignTopic above pairs its bad TopicArn with an // invalid SigningCertURL, so a broken TopicArn check would still be masked // by the cert-host check that runs after it — the request would be refused // either way, for the wrong reason. This test isolates the TopicArn check // by giving it an otherwise-VALID cert URL and a signature seam that always // succeeds: nothing but the TopicArn comparison stands between this request // and a 200. func TestSESEventsRefusesForeignTopicEvenWithAnOtherwiseValidRequest(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } // isolate: not a signature test resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",`+ `"Message":"{\"eventType\":\"Send\",\"mail\":{\"source\":\"a@b.com\"}}",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("a foreign TopicArn was accepted once every other check would have passed") } } // TestSESEventsLogsARefusedTopicOnceAnHour is ruling 2c. A stale or mistyped // CARLOS_MAIL_SNS_TOPIC_ARN is the likeliest misconfiguration in the whole // event path, and before this it left NO trace: 403s at a door nobody // watches and counts that never move. // // The bound is the other half of the ruling and is tested with it, because // this door is unauthenticated and internet-reachable — an unbounded log // line here is a stranger's write access to the operator's disk. func TestSESEventsLogsARefusedTopicOnceAnHour(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) body := `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",` + `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}` for i := 0; i < 100; i++ { resp := sesPost(t, fx, body) resp.Body.Close() } got := buf.String() if n := strings.Count(got, "refused topic"); n != 1 { t.Errorf("logged %d refusal lines for 100 identical requests, want exactly 1 — "+ "an unauthenticated door must not let a stranger fill the log\n%s", n, got) } // Both ARNs must appear: the received one to identify the sender, the // expected one because "refused" is useless without what it wanted. if !strings.Contains(got, "someone-else") || !strings.Contains(got, "carlos-mail") { t.Errorf("the line does not name both the received and expected ARNs:\n%s", got) } } // TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently: a console // with no topic configured that is nonetheless receiving SNS deliveries is // its own misconfiguration — the deployment believes email events are off // while SNS is actively delivering to it — and %q of the empty string would // read as a bug rather than as that. func TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "" var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:whoever",`+ `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) resp.Body.Close() if got := buf.String(); !strings.Contains(got, "(none configured)") { t.Errorf("an unconfigured deployment should say so, not print an empty quoted string:\n%s", got) } } // TestSESEventsCountsADelivery is ruling 2a, through the DOOR rather than // against mailCountColumn — the standing lesson of this subsystem is that a // mapping can be right while nothing calls it. func TestSESEventsCountsADelivery(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } ctx := context.Background() now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "seapointish", "paul@keymail.dev", now); err != nil { t.Fatal(err) } rec := &mail.DomainRecord{ Domain: "seapointish.bag.oncarlos.com", Kind: mail.KindPlatform, Apps: []string{"seapointish"}, Regions: map[string]*mail.RegionState{ "eu-west-1": { IdentityARN: "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com", MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com", Status: mail.StatusVerified, }, }, CreatedAt: now, } if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { t.Fatal(err) } resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"Message":"{\"eventType\":\"Delivery\",\"mail\":{\"source\":\"noreply@seapointish.bag.oncarlos.com\"}}",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } day := now.UTC().Format("2006-01-02") c, err := fx.st.MailCounts(ctx, release.OpsAccount, "seapointish", "", day) if err != nil { t.Fatalf("MailCounts: %v", err) } if c.Deliveries != 1 { t.Errorf("deliveries = %d, want 1 — the ingest already verifies and parses these; dropping them wastes the work", c.Deliveries) } if c.Sends != 0 || c.Bounces != 0 || c.Complaints != 0 || c.Rejects != 0 { t.Errorf("a Delivery moved another column: %+v — the cap and thresholds must be untouched by it", c) } } // An unknown event type is a 200. SNS retries non-2xx, so answering 500 // turns one unrecognised field into an unbounded retry flood against the // console. func TestSESEventsAnswers200ForUnknownTypes(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } // seam: skip signature in tests resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"Message":"{\"eventType\":\"Subscription\",\"mail\":{\"source\":\"a@b.com\"}}",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 — a non-2xx makes SNS retry forever", resp.StatusCode) } } // TestSESEventsAnswers200ForUnknownTypes above sends a recognised SNS // envelope Type ("Notification") whose INNER SES eventType is the unknown // part — it never reaches the dispatch switch's default case at all. This // test isolates that case directly: an SNS envelope Type this deployment // does not recognise must still answer 200, or SNS retries a message this // handler will never understand forever. func TestSESEventsAnswers200ForAnUnknownEnvelopeType(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } resp := sesPost(t, fx, `{"Type":"SomeFutureSNSType","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 for an unrecognised envelope Type", resp.StatusCode) } } // The body is size-limited: an unbounded read on an unauthenticated endpoint // is a memory exhaustion anyone can trigger. func TestSESEventsRefusesAnOversizeBody(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" resp := sesPost(t, fx, `{"Type":"Notification","Message":"`+strings.Repeat("a", 300<<10)+`"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("an oversize body was accepted") } } // TestSESEventsRefusesAnOversizeBody above sends an otherwise-empty envelope // (no TopicArn, no SigningCertURL), so it is also refused by the TopicArn // check — the size limit could be entirely absent and this request would // still not get a 200. This test isolates the size limit: every other check // would pass (matching TopicArn, a valid cert URL, a signature seam that // always succeeds, an event type this door ignores) — the only thing that // can still refuse a 300KB body is the limit itself. func TestSESEventsRefusesAnOversizeBodyEvenWhenOtherwiseValid(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } resp := sesPost(t, fx, `{"Type":"SomeFutureSNSType","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x",`+ `"Message":"`+strings.Repeat("a", 300<<10)+`"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("an oversize body was accepted once every other check would have passed") } } // A verified signature only proves AWS put a string in SubscribeURL — not // that the string points somewhere safe to GET. Without a host check on it // this door is a blind SSRF dispatcher: an authentic message can carry a // SubscribeURL aimed at anything reachable from the console, including // link-local instance-metadata endpoints. // // ROUND-3 FIX: the previous version of this test pointed SubscribeURL at // 169.254.169.254 and asserted on the STATUS CODE alone. That is vacuous — // removing the host-check constraint entirely still leaves this test green, // because the fetch to that address simply times out into a 502 in this // sandbox, which looks identical to a deliberate refusal. This version // stands up a REAL local server as the hostile target and asserts its // handler is NEVER invoked — the only way to prove the request was refused // BEFORE any fetch was attempted, not merely that some fetch eventually // failed. func TestSESEventsRefusesASubscribeURLOffAWS(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } hostile := notReached(t) defer hostile.Close() resp := sesPost(t, fx, `{"Type":"SubscriptionConfirmation","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"Message":"You have chosen to subscribe...",`+ `"SubscribeURL":"`+hostile.URL+`",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("a SubscribeURL off an SNS host was accepted") } // notReached's own handler calls t.Errorf if it is ever invoked — by the // time sesPost has returned, the request (refusal or fetch) is already // complete, so no further synchronization is needed here. } // The mechanical half of subscription handling: confirmSNSSubscription // itself — reached only after both the signature and the SubscribeURL host // check have passed — must actually GET the URL. A SubscriptionConfirmation // silently dropped means SNS never delivers another event to this endpoint. // Tested directly (below the host-check layer) because a real SubscribeURL // is always an sns..amazonaws.com URL, which nothing in this test // suite can stand a fake server up as. func TestConfirmSNSSubscriptionFetchesTheURL(t *testing.T) { fx := newConsoleFixture(t) confirmed := make(chan struct{}, 1) ts := newConfirmServer(t, confirmed) defer ts.Close() if err := fx.srv.confirmSNSSubscription(context.Background(), ts.URL); err != nil { t.Fatalf("confirmSNSSubscription: %v", err) } select { case <-confirmed: default: t.Fatal("the SubscribeURL was never fetched — SNS will never deliver another event") } } // IMPORTANT fix: neither outbound fetch this door makes may follow a // redirect. For SubscribeURL specifically, validSigningCertURL only ever // inspects the URL SNS's own signed message named — a redirect response // from that (now-verified) host is a completely different, UNvalidated // target, so following it defeats the host pin outright. func TestConfirmSNSSubscriptionDoesNotFollowARedirect(t *testing.T) { hostile := notReached(t) defer hostile.Close() redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, hostile.URL, http.StatusFound) })) defer redirector.Close() fx := newConsoleFixture(t) if err := fx.srv.confirmSNSSubscription(context.Background(), redirector.URL); err == nil { t.Fatal("a redirect response was treated as a successful confirmation") } } // Same guard, same reasoning, for the certificate fetch: a redirect there // would hand an attacker-controlled host the chance to serve the bytes this // door treats as SNS's own signing certificate. func TestFetchVerifiedCertKeyDoesNotFollowARedirect(t *testing.T) { hostile := notReached(t) defer hostile.Close() redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, hostile.URL, http.StatusFound) })) defer redirector.Close() if _, err := fetchVerifiedCertKey(context.Background(), redirector.URL); err == nil { t.Fatal("a redirect response was treated as a successful cert fetch") } } // A signature that does not verify must never reach the dispatch switch — // proof the seam only bypasses the crypto, not the rest of the pipeline. func TestSESEventsRefusesABadSignature(t *testing.T) { fx := newConsoleFixture(t) fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" fx.srv.snsVerify = func(_ snsEnvelope) error { return errFakeSignature } resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ `"Message":"{\"eventType\":\"Send\",\"mail\":{\"source\":\"a@b.com\"}}",`+ `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) defer resp.Body.Close() if resp.StatusCode == http.StatusOK { t.Fatal("a message that failed signature verification was accepted") } } // ---- verifySNSSignatureBytes: the pure cryptographic check -------------- // // Split out from verifySNSSignature specifically so the SHA-1/SHA-256 // branches can be exercised with a plain in-memory RSA keypair — no // network, no certificate, no trust store. This is the piece the // snsVerify seam always bypasses in the tests above; here it is tested // directly instead. func TestVerifySNSSignatureBytesAcceptsAValidSHA1Signature(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } canon := []byte("Message\nhi\nMessageId\n1\n") sum := sha1.Sum(canon) sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:]) if err != nil { t.Fatal(err) } sigB64 := base64.StdEncoding.EncodeToString(sig) if err := verifySNSSignatureBytes(&key.PublicKey, canon, "1", sigB64); err != nil { t.Fatalf("valid SHA1 signature refused: %v", err) } // SignatureVersion "" (unset) is SNS's historical default and must be // treated identically to "1". if err := verifySNSSignatureBytes(&key.PublicKey, canon, "", sigB64); err != nil { t.Fatalf("valid SHA1 signature refused with unset SignatureVersion: %v", err) } } func TestVerifySNSSignatureBytesAcceptsAValidSHA256Signature(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } canon := []byte("Message\nhi\nMessageId\n1\n") sum := sha256.Sum256(canon) sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) if err != nil { t.Fatal(err) } sigB64 := base64.StdEncoding.EncodeToString(sig) if err := verifySNSSignatureBytes(&key.PublicKey, canon, "2", sigB64); err != nil { t.Fatalf("valid SHA256 signature refused: %v", err) } } func TestVerifySNSSignatureBytesRefusesATamperedMessage(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } sum := sha1.Sum([]byte("Message\nhi\nMessageId\n1\n")) sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:]) if err != nil { t.Fatal(err) } sigB64 := base64.StdEncoding.EncodeToString(sig) if err := verifySNSSignatureBytes(&key.PublicKey, []byte("Message\nbye\nMessageId\n1\n"), "1", sigB64); err == nil { t.Fatal("a signature over a different message verified") } } func TestVerifySNSSignatureBytesRefusesAnUnknownVersion(t *testing.T) { key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } sigB64 := base64.StdEncoding.EncodeToString([]byte("whatever")) if err := verifySNSSignatureBytes(&key.PublicKey, []byte("x"), "3", sigB64); err == nil { t.Fatal("an unsupported SignatureVersion was accepted") } } // ---- fetchVerifiedCertKey: the trust model that survived four rounds ---- // // There is no chain to verify for SNS's own signing certificate (see // sesevents.go's package comment, "Round 4") — what's left is: fetched over // HTTPS (TLS chain validated by the http client itself), from a pinned // host, with no redirect followed, and the certificate's own subject // checked against snsCertDNSName. The tests below exercise exactly that // combination through fetchVerifiedCertKey ITSELF — not through a // hand-rolled x509.Verify call with its own options, which is what let // three mutations (dropping the Intermediates pool, asserting the regional // host, and fully reverting the round-2 cert fix) all pass unnoticed in an // earlier round: nothing exercised the real function on a happy path, so a // broken happy path had nothing to break loudly. // genLeafCert builds a single self-signed leaf certificate for cn — usable // as BOTH a TLS server certificate and as the body fetchVerifiedCertKey // parses — mirroring AWS's actual wire format exactly: one bare leaf, never // an intermediate. func genLeafCert(t *testing.T, cn string) (der []byte, cert *x509.Certificate, key *rsa.PrivateKey) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } tmpl := &x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: cn}, DNSNames: []string{cn}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, } der, err = x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) if err != nil { t.Fatal(err) } cert, err = x509.ParseCertificate(der) if err != nil { t.Fatal(err) } return der, cert, key } // hijackFetchClient points the package-level snsFetchClient at a local TLS // listener for the duration of the calling test: a custom Transport whose // DialContext ignores the requested address entirely and always dials // listenerAddr, while trusting ONLY trust (added directly to a fresh // RootCAs pool as its own anchor). The request's URL host is left // untouched, so Go's normal TLS hostname verification still runs for real — // against the certificate the local listener actually presents — the same // as it would over a genuine network path to the genuine pinned host. // Restored automatically via t.Cleanup. func hijackFetchClient(t *testing.T, listenerAddr string, trust *x509.Certificate) { t.Helper() roots := x509.NewCertPool() roots.AddCert(trust) orig := snsFetchClient snsFetchClient = &http.Client{ CheckRedirect: orig.CheckRedirect, Transport: &http.Transport{ DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, network, listenerAddr) }, TLSClientConfig: &tls.Config{RootCAs: roots}, }, } t.Cleanup(func() { snsFetchClient = orig }) } // newHijackedCertServer starts a local httptest TLS server presenting // serverDER as its own TLS certificate, whose handler serves certPEM as the // response body — modelling a real SigningCertURL fetch, where the TLS // certificate the far end presents and the PEM content it serves need not // be (and for this fixture, are) the same bytes. func newHijackedCertServer(t *testing.T, der []byte, key *rsa.PrivateKey, certPEM []byte) *httptest.Server { t.Helper() ts := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write(certPEM) })) ts.TLS = &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}} ts.StartTLS() t.Cleanup(ts.Close) return ts } // THE HAPPY PATH, missing until this round: a certificate shaped exactly // like AWS's real one — a single bare leaf, subject sns.amazonaws.com, no // intermediate, fetched over a TLS connection whose chain the http client // validates itself — must be ACCEPTED by fetchVerifiedCertKey, and the // public key it returns must be the fixture's own. func TestFetchVerifiedCertKeyAcceptsTheRealShape(t *testing.T) { der, leaf, key := genLeafCert(t, snsCertDNSName) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) ts := newHijackedCertServer(t, der, key, certPEM) hijackFetchClient(t, ts.Listener.Addr().String(), leaf) pub, err := fetchVerifiedCertKey(context.Background(), "https://"+snsCertDNSName+"/SimpleNotificationService.pem") if err != nil { t.Fatalf("a real-shaped SNS certificate (single bare leaf, subject %s, no intermediate) was refused: %v", snsCertDNSName, err) } if pub.N.Cmp(key.PublicKey.N) != 0 { t.Fatal("fetchVerifiedCertKey returned a different key than the fixture's own") } } // The subject assertion is now the ONLY check standing between an // authentically-fetched (valid TLS, pinned dial target, no redirect) but // WRONG-content certificate and treating it as SNS's own — there is no // chain check left to catch this (see fetchVerifiedCertKey's doc comment). // A certificate whose subject is NOT sns.amazonaws.com must still be // refused even though everything about how it was fetched is legitimate. func TestFetchVerifiedCertKeyRefusesAWrongSubject(t *testing.T) { const wrongCN = "not-sns.example.com" der, leaf, key := genLeafCert(t, wrongCN) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) ts := newHijackedCertServer(t, der, key, certPEM) hijackFetchClient(t, ts.Listener.Addr().String(), leaf) if _, err := fetchVerifiedCertKey(context.Background(), "https://"+wrongCN+"/x.pem"); err == nil { t.Fatal("a certificate with the wrong subject was accepted") } } // The cache sits in front of fetchVerifiedCertKey (verifiedCertKey), so a // second call for the SAME already-cached URL must not re-fetch — proven // here by pointing the cache at a URL nothing is listening on: a cache miss // would fail with a connection error, a hit returns the seeded key. func TestVerifiedCertKeyServesFromCache(t *testing.T) { fx := newConsoleFixture(t) key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } const certURL = "https://sns.eu-west-1.amazonaws.com/nothing-listens-here.pem" fx.srv.snsCerts.put(certURL, &key.PublicKey) pub, err := fx.srv.verifiedCertKey(context.Background(), certURL) if err != nil { t.Fatalf("verifiedCertKey did not serve from cache: %v", err) } if pub.N.Cmp(key.PublicKey.N) != 0 { t.Fatal("verifiedCertKey returned a different key than the one cached") } } // ---- event-to-domain attribution (I6, review follow-up) -------------------- // // ParseEvent attributes an event by the domain of mail.source. If SES // reports the REWRITTEN envelope — mail-., the custom MAIL // FROM MX target (spec §7) — rather than the sender-supplied From, an exact // match against DomainRecord.Domain alone attributes the event to nothing, // and it is silently dropped from the counts that enforce the cap and trip // the abuse pause: a second, independent way spec §9 stops working. func TestDomainMatchesEventAcceptsTheIdentitysOwnDomain(t *testing.T) { d := &mail.DomainRecord{Domain: "harbourmail.bag.oncarlos.com"} if !domainMatchesEvent(d, "harbourmail.bag.oncarlos.com") { t.Fatal("the identity's own domain must match") } } func TestDomainMatchesEventAcceptsTheRecordedMailFromForAnyRegion(t *testing.T) { d := &mail.DomainRecord{ Domain: "harbourmail.bag.oncarlos.com", Regions: map[string]*mail.RegionState{ "eu-west-1": {MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com"}, "us-east-1": {MailFrom: "mail-us-east-1.harbourmail.bag.oncarlos.com"}, }, } if !domainMatchesEvent(d, "mail-eu-west-1.harbourmail.bag.oncarlos.com") { t.Fatal("the eu-west-1 MAIL FROM subdomain must match") } if !domainMatchesEvent(d, "mail-us-east-1.harbourmail.bag.oncarlos.com") { t.Fatal("a second region's MAIL FROM subdomain must match too") } } func TestDomainMatchesEventRefusesAnUnrelatedDomain(t *testing.T) { d := &mail.DomainRecord{ Domain: "harbourmail.bag.oncarlos.com", Regions: map[string]*mail.RegionState{"eu-west-1": {MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com"}}, } if domainMatchesEvent(d, "unrelated.example.com") { t.Fatal("an unrelated domain must not match — this must stay a match against RECORDED values, never a prefix-strip heuristic") } } // TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope is the // end-to-end proof: an SES event reporting the rewritten MAIL FROM envelope // (not the identity's own domain) must still land in this app's counts. func TestRecordAndEnforceMailAttributesTheRewrittenMailFromEnvelope(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() if _, err := fx.st.CreateAppIn(OpsAccountID, "harbourmail", "paul@keymail.dev", now); err != nil { t.Fatal(err) } rec := &mail.DomainRecord{ Domain: "harbourmail.bag.oncarlos.com", Kind: mail.KindPlatform, Apps: []string{"harbourmail"}, Regions: map[string]*mail.RegionState{ "eu-west-1": { IdentityARN: "arn:aws:ses:eu-west-1:1:identity/harbourmail.bag.oncarlos.com", MailFrom: "mail-eu-west-1.harbourmail.bag.oncarlos.com", Status: mail.StatusVerified, }, }, CreatedAt: now, } if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { t.Fatal(err) } ev := mail.Event{Type: mail.EventSend, Domain: "mail-eu-west-1.harbourmail.bag.oncarlos.com", At: now} fx.srv.recordAndEnforceMail(ctx, ev, "") day := now.UTC().Format("2006-01-02") counts, err := fx.srv.store.MailCounts(ctx, release.OpsAccount, "harbourmail", "", day) if err != nil { t.Fatal(err) } if counts.Sends != 1 { t.Fatalf("Sends = %d, want 1 — the rewritten MAIL FROM envelope must still attribute to the app", counts.Sends) } } // The domain fallback skips an app sender in per-instance mode (senders // design §8). Nothing sets PerInstance yet, so this hand-sets it: the app // sender is deliberately dead once its app moves to per-instance senders, // and charging it for a domain-wide event it cannot have sent would pause a // credential nobody holds. func TestRecordAndEnforceMailSkipsAPerInstanceAppInTheDomainFallback(t *testing.T) { fx := newConsoleFixture(t) ctx := context.Background() now := time.Now() for _, app := range []string{"jb-book", "jb-site"} { if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now); err != nil { t.Fatal(err) } } rec := &mail.DomainRecord{ Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book", "jb-site"}, Regions: map[string]*mail.RegionState{"eu-west-1": { IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", MailFrom: "mail-eu-west-1.lgc.li", Status: mail.StatusVerified, }}, CreatedAt: now, } if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { t.Fatal(err) } // Hand-set jb-site's app sender to per-instance mode: nothing in this // release writes PerInstance itself, so the only way to exercise the // skip is to put the sender record directly. perInstance := &mail.Sender{ID: mail.SenderAppID, Scope: mail.ScopeApp, Source: mail.SourceDomain, PerInstance: true} if _, err := mail.PutSender(ctx, fx.rel, release.OpsAccount, "jb-site", perInstance, ""); err != nil { t.Fatal(err) } ev := bounceTo("ann@example.com", "m-domain-fallback", now) // An unnamed caller, so this falls back to the domain loop rather than // resolving to a sender directly. fx.srv.recordAndEnforceMail(ctx, ev, "sns-fallback") day := now.UTC().Format("2006-01-02") if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "", day); c.Bounces != 1 { t.Errorf("jb-book bounces = %d, want 1 — it has no PerInstance app sender", c.Bounces) } if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-site", "", day); c.Bounces != 0 { t.Errorf("jb-site bounces = %d, want 0 — its app sender is in per-instance mode and must not be charged", c.Bounces) } if got, _ := fx.st.MailEvents(ctx, release.OpsAccount, "jb-site", MailEventQuery{}, now); len(got) != 0 { t.Errorf("jb-site's log got a row from a per-instance app sender's skipped domain fallback: %+v", got) } }