carlos / platform Public

console: SES event ingest, with the SigningCertURL check that matters

An unvalidated SigningCertURL lets anyone reaching this endpoint host their
own certificate and forge every event — and since forged bounces trip the
automatic pause, that is a remote off-switch for any tenant's email. The
suffix check is label-aware, because the bare HasSuffix form accepts
sns.eu-west-1.amazonaws.com.evil.com. Deliberate ignores answer 200: SNS
retries non-2xx, so a 500 on an unknown event type is a self-inflicted flood.
Paul Campbell pushed by paul@keymail.dev 10fa186feb5d2bf2303af231b3844e732c33d566 parent 253d1c4
3 files changed, +585 −0
  • internal/console/server.go +26 −0
  • internal/console/sesevents.go +337 −0
  • internal/console/sesevents_test.go +222 −0
diff --git a/internal/console/server.go b/internal/console/server.go
index c9adc66c4..3f48519cb 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -28,8 +28,10 @@ import (
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/dnsrec"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/economics"
+ "github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
@@ -515,6 +517,17 @@ type Config struct {
// guessing. Normalized (lowercase, trimmed) into Server.delegationZone at
// construction, same as RedirectHosts is normalized into `redirects`.
DelegationZone string
+ // MailProvisioner is the backend the email doors provision against. nil
+ // disables every email door — a deployment with no SES estate is a valid
+ // deployment, and the doors must 404 rather than panic.
+ MailProvisioner mail.Provisioner
+ // DNSWriter publishes durable records for platform sending domains. nil
+ // means platform domains behave like custom ones: records are emitted for
+ // the member instead of published.
+ DNSWriter dnsrec.Writer
+ // MailDeployment carries the region, the platform suffix, the DMARC rua
+ // and the SNS topic.
+ MailDeployment mail.Deployment
}
// Server is the console's http.Handler.
@@ -592,6 +605,12 @@ type Server struct {
// post-construction.
delegationZone string
lookupCNAME func(ctx context.Context, host string) (string, error)
+ // snsVerify is the SES ingest door's test seam (sesevents.go) — nil
+ // means "do the real signature verification", which fetches the signing
+ // certificate over the network. The seam covers signature verification
+ // ONLY: it never substitutes for the TopicArn or SigningCertURL checks,
+ // which every caller — production and test alike — runs for real.
+ snsVerify func(snsEnvelope) error
}
// normalizeDelegationZone lowercases, trims, and shape-validates raw (from
@@ -773,6 +792,13 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
+ // SES event ingest (sesevents.go, tenant-email spec §9): UNAUTHENTICATED,
+ // like healthz above — SNS is not a signed-in member, so there is no
+ // session, no CSRF and no owner check ahead of it. Its whole security
+ // model lives inside handleSESEvents: a TopicArn allowlist, a
+ // label-aware SigningCertURL host check, and a verified signature,
+ // all run before any field of the message is trusted.
+ s.mux.HandleFunc("POST /api/ses/events", s.handleSESEvents)
// Catch up on accounts minted outside this database — `carlos accounts
// create` writes the bucket manifest and the registry row and never opens
// console state. Startup is the natural place: the account switcher, the
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go
new file mode 100644
index 000000000..ef9b56ec0
--- /dev/null
+++ b/internal/console/sesevents.go
@@ -0,0 +1,337 @@
+// The SES event ingest endpoint (tenant-email spec §9): SNS delivers send,
+// bounce and complaint notifications here, and they become the per-tenant
+// counts that enforce a cap and trip an automatic pause (Task 16).
+//
+// UNAUTHENTICATED, UNLIKE EVERY OTHER DOOR IN THIS PACKAGE. SNS is not a
+// signed-in member — it cannot present a session cookie, a CSRF token, or
+// prove account ownership — so handleSESEvents has none of those gates.
+// What replaces them, in the order they run below, is the door's entire
+// security model: bound the body, refuse a topic that isn't ours, validate
+// where the verifying certificate may come from, and verify the signature
+// before trusting any field the message carries.
+//
+// SECURITY: validSigningCertURL is the entirety of SNS signature security.
+// SNS tells the receiver where to fetch the certificate that verifies the
+// message — so an unvalidated URL lets anyone who can reach this endpoint
+// host their own certificate, sign their own payload, and have it verify.
+// The consequence here is not merely bad data: forged bounce events trip
+// the automatic pause, so it is a remote off-switch for any tenant's email.
+package console
+
+import (
+ "context"
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "encoding/pem"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/mail"
+)
+
+// sesEventMaxBody bounds the request body this door reads. SNS messages are
+// small (an SES notification is at most a few KB); an unbounded read on an
+// unauthenticated endpoint is memory exhaustion anyone can trigger.
+const sesEventMaxBody = 256 << 10
+
+// sesCertMaxBody bounds the signing certificate this door fetches. AWS's
+// SNS certificates are a few KB; anything vastly larger than that is not a
+// certificate this code needs to finish reading.
+const sesCertMaxBody = 64 << 10
+
+// sesFetchTimeout bounds both outbound calls this door makes — fetching the
+// signing certificate and confirming a subscription. Neither should ever
+// approach it against a healthy AWS endpoint; it exists so a wedged
+// far end costs one request this long, not a goroutine forever.
+const sesFetchTimeout = 10 * time.Second
+
+// snsCertHost is the only host family a signing certificate may come from.
+const snsCertHost = "amazonaws.com"
+
+// validSigningCertURL refuses anything that is not an https URL on an
+// amazonaws.com host.
+//
+// The suffix check is LABEL-AWARE. A bare strings.HasSuffix would accept
+// "evilamazonaws.com" — it genuinely ends with the string "amazonaws.com",
+// with no dot separating the two, so it is a completely different
+// registered domain that merely happens to end the right way. Requiring
+// either an exact match or a "."-prefixed suffix is what tells a real AWS
+// subdomain apart from that lookalike.
+func validSigningCertURL(raw string) error {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return fmt.Errorf("sns: unparseable SigningCertURL")
+ }
+ if u.Scheme != "https" {
+ return fmt.Errorf("sns: SigningCertURL is not https")
+ }
+ host := strings.ToLower(u.Hostname())
+ if host != snsCertHost && !strings.HasSuffix(host, "."+snsCertHost) {
+ return fmt.Errorf("sns: SigningCertURL host %q is not an AWS host", host)
+ }
+ // A traversal in the path can walk off the certificate route on a host
+ // that serves more than certificates.
+ if strings.Contains(u.Path, "..") {
+ return fmt.Errorf("sns: SigningCertURL path contains a traversal")
+ }
+ return nil
+}
+
+// snsEnvelope is the outer SNS message shape — the fields this door reads
+// off of every delivery, notification or confirmation alike. Field names
+// and casing match SNS's own JSON exactly; there is no tolerance for a
+// renamed field because these are also exactly the fields the signature
+// covers (canonicalString below).
+type snsEnvelope struct {
+ Type string `json:"Type"`
+ MessageID string `json:"MessageId"`
+ Token string `json:"Token,omitempty"`
+ TopicArn string `json:"TopicArn"`
+ Subject string `json:"Subject,omitempty"`
+ Message string `json:"Message"`
+ SubscribeURL string `json:"SubscribeURL,omitempty"`
+ Timestamp string `json:"Timestamp"`
+ SignatureVersion string `json:"SignatureVersion"`
+ Signature string `json:"Signature"`
+ SigningCertURL string `json:"SigningCertURL"`
+}
+
+// canonicalString builds the exact byte string SNS signs, per AWS's
+// documented format: alternating field name and value, each followed by a
+// newline, fields in ALPHABETICAL order, and only the fields that apply to
+// this message's Type. Subject is included only when the message actually
+// carries one (SNS omits it from the signed string otherwise);
+// SubscribeURL/Token belong to confirmation messages only.
+func (e snsEnvelope) canonicalString() string {
+ var b strings.Builder
+ add := func(k, v string) {
+ b.WriteString(k)
+ b.WriteByte('\n')
+ b.WriteString(v)
+ b.WriteByte('\n')
+ }
+ add("Message", e.Message)
+ add("MessageId", e.MessageID)
+ switch e.Type {
+ case "SubscriptionConfirmation", "UnsubscribeConfirmation":
+ add("SubscribeURL", e.SubscribeURL)
+ add("Timestamp", e.Timestamp)
+ add("Token", e.Token)
+ add("TopicArn", e.TopicArn)
+ add("Type", e.Type)
+ default:
+ if e.Subject != "" {
+ add("Subject", e.Subject)
+ }
+ add("Timestamp", e.Timestamp)
+ add("TopicArn", e.TopicArn)
+ add("Type", e.Type)
+ }
+ return b.String()
+}
+
+// handleSESEvents is where SNS delivers every SES send, bounce and
+// complaint notification for this deployment.
+//
+// The checks below run in this exact order, and the order is the whole
+// point:
+//
+// 1. Bound the body. Nothing about an unauthenticated endpoint should read
+// an unbounded amount of attacker-controlled data before anything else
+// has a chance to refuse it.
+// 2. Refuse a foreign TopicArn. This runs before ANY network call this
+// handler might make (the certificate fetch in step 4, the subscribe
+// GET in dispatch) — otherwise a stranger's POST decides who this
+// console makes outbound requests to, which is SSRF wearing an
+// AWS-shaped alibi.
+// 3. Validate SigningCertURL's shape — https, an amazonaws.com host,
+// label-aware, no path traversal — before it is ever fetched.
+// 4. Verify the signature over SNS's canonical string. Nothing past this
+// point trusts a single field of the message, including SubscribeURL,
+// until this has succeeded.
+// 5. Dispatch: confirm a subscription, or parse a notification. Anything
+// this deployment deliberately does not act on still answers 200 —
+// SNS retries non-2xx, so refusing an unknown case with a failure
+// status would turn one unrecognised field into an unbounded retry
+// flood against the console.
+func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
+ // 1. Bound the body first.
+ r.Body = http.MaxBytesReader(w, r.Body, sesEventMaxBody)
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ var tooLarge *http.MaxBytesError
+ if errors.As(err, &tooLarge) {
+ jsonError(w, http.StatusRequestEntityTooLarge, "request body too large")
+ return
+ }
+ jsonError(w, http.StatusBadRequest, "could not read request body")
+ return
+ }
+
+ var env snsEnvelope
+ if err := json.Unmarshal(body, &env); err != nil {
+ jsonError(w, http.StatusBadRequest, "malformed SNS envelope")
+ return
+ }
+
+ // 2. Refuse a foreign TopicArn before fetching anything. An unconfigured
+ // SNSTopicARN ("") never matches — a deployment with no topic set
+ // accepts nothing, rather than accepting whatever a caller who also
+ // sends "" happens to send.
+ if s.cfg.MailDeployment.SNSTopicARN == "" || env.TopicArn != s.cfg.MailDeployment.SNSTopicARN {
+ jsonError(w, http.StatusForbidden, "unrecognised SNS topic")
+ return
+ }
+
+ // 3. Validate SigningCertURL before it is ever fetched.
+ if err := validSigningCertURL(env.SigningCertURL); err != nil {
+ jsonError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+
+ // 4. Verify the signature before acting on ANY field, including
+ // SubscribeURL. snsVerify is the test seam — nil means "do the real
+ // verification" (verifySNSSignature, which fetches the certificate over
+ // the network). The seam is signature verification ONLY: both checks
+ // above already ran unconditionally, for real, regardless of the seam.
+ verify := s.snsVerify
+ if verify == nil {
+ verify = s.verifySNSSignature
+ }
+ if err := verify(env); err != nil {
+ log.Printf("console: ses events: signature verification failed: %v", err)
+ jsonError(w, http.StatusForbidden, "signature verification failed")
+ return
+ }
+
+ // 5. Dispatch.
+ switch env.Type {
+ case "SubscriptionConfirmation", "UnsubscribeConfirmation":
+ // Handled, not ignored: without confirming, SNS never delivers
+ // another event to this endpoint.
+ ctx, cancel := context.WithTimeout(r.Context(), sesFetchTimeout)
+ defer cancel()
+ if err := s.confirmSNSSubscription(ctx, env.SubscribeURL); err != nil {
+ log.Printf("console: ses events: subscription confirm: %v", err)
+ jsonError(w, http.StatusBadGateway, "could not confirm the SNS subscription")
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ case "Notification":
+ ev, err := mail.ParseEvent([]byte(env.Message))
+ if err != nil {
+ // Logged and dropped, not retried: an unparseable or
+ // unattributable message will never become parseable by SNS
+ // resending the identical bytes, so a non-2xx here would only
+ // buy a permanent retry loop.
+ log.Printf("console: ses events: parse notification: %v", err)
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ // Recording the per-tenant counts and enforcing the cap/pause is
+ // Task 16 (mailcounts.go); this door's job ends at having proven
+ // the event is authentic and parsed it.
+ _ = ev
+ w.WriteHeader(http.StatusOK)
+ default:
+ // An SES event type this deployment does not act on (SES has more
+ // types than the five this package names). Deliberately ignored,
+ // so it is a 200 — see the dispatch comment on the handler above.
+ w.WriteHeader(http.StatusOK)
+ }
+}
+
+// confirmSNSSubscription GETs the verified SubscribeURL. It is reached only
+// after the enclosing message's signature has verified, which is what makes
+// trusting this URL safe: SubscribeURL is itself one of the fields the
+// signature covers, so an authentic signature over it means AWS is the one
+// who put it there.
+func (s *Server) confirmSNSSubscription(ctx context.Context, subscribeURL string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, subscribeURL, nil)
+ if err != nil {
+ return fmt.Errorf("sns: building subscribe request: %w", err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("sns: fetching SubscribeURL: %w", err)
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, sesCertMaxBody))
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return fmt.Errorf("sns: SubscribeURL confirm status %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// verifySNSSignature is the real (non-test-seam) verification path: fetch
+// the certificate SigningCertURL names — already validated as an
+// amazonaws.com https URL by the caller — parse its RSA public key, and
+// check the signature over the message's own canonical string.
+//
+// SNS signs with SHA1withRSA under SignatureVersion "1" (the historical
+// default, and what SNS still sends unless a topic opts into version "2",
+// SHA256withRSA). Both are verified here; an unrecognised version is
+// refused rather than guessed at.
+func (s *Server) verifySNSSignature(env snsEnvelope) error {
+ ctx, cancel := context.WithTimeout(context.Background(), sesFetchTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, env.SigningCertURL, nil)
+ if err != nil {
+ return fmt.Errorf("sns: building cert request: %w", err)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("sns: fetching signing cert: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("sns: signing cert fetch status %d", resp.StatusCode)
+ }
+ certPEM, err := io.ReadAll(io.LimitReader(resp.Body, sesCertMaxBody))
+ if err != nil {
+ return fmt.Errorf("sns: reading signing cert: %w", err)
+ }
+ block, _ := pem.Decode(certPEM)
+ if block == nil {
+ return fmt.Errorf("sns: signing cert is not PEM")
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ return fmt.Errorf("sns: parsing signing cert: %w", err)
+ }
+ pub, ok := cert.PublicKey.(*rsa.PublicKey)
+ if !ok {
+ return fmt.Errorf("sns: signing cert public key is not RSA")
+ }
+ sig, err := base64.StdEncoding.DecodeString(env.Signature)
+ if err != nil {
+ return fmt.Errorf("sns: signature is not base64: %w", err)
+ }
+ canon := []byte(env.canonicalString())
+ switch env.SignatureVersion {
+ case "", "1":
+ sum := sha1.Sum(canon)
+ if err := rsa.VerifyPKCS1v15(pub, crypto.SHA1, sum[:], sig); err != nil {
+ return fmt.Errorf("sns: signature verification failed: %w", err)
+ }
+ case "2":
+ sum := sha256.Sum256(canon)
+ if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil {
+ return fmt.Errorf("sns: signature verification failed: %w", err)
+ }
+ default:
+ return fmt.Errorf("sns: unsupported SignatureVersion %q", env.SignatureVersion)
+ }
+ return nil
+}
diff --git a/internal/console/sesevents_test.go b/internal/console/sesevents_test.go
new file mode 100644
index 000000000..e98a49ca8
--- /dev/null
+++ b/internal/console/sesevents_test.go
@@ -0,0 +1,222 @@
+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 host check, and the ordering that runs both
+// before any network call.
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// 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)
+ }))
+}
+
+// The SigningCertURL check is the ENTIRETY of SNS signature security. 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.
+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",
+ }
+ 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"},
+ }
+ for _, c := range bad {
+ if err := validSigningCertURL(c.url); err == nil {
+ t.Fatalf("validSigningCertURL(%q) 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")
+ }
+}
+
+// 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 SubscriptionConfirmation is handled, not ignored: without confirming,
+// SNS never delivers another event to this endpoint. The test server stands
+// in for AWS at the far end of SubscribeURL.
+func TestSESEventsConfirmsSubscription(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 }
+
+ confirmed := make(chan struct{}, 1)
+ confirmSrv := newConfirmServer(t, confirmed)
+ defer confirmSrv.Close()
+
+ resp := sesPost(t, fx, `{"Type":"SubscriptionConfirmation","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
+ `"Message":"You have chosen to subscribe...","SubscribeURL":"`+confirmSrv.URL+`",`+
+ `"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)
+ }
+ select {
+ case <-confirmed:
+ default:
+ t.Fatal("the SubscribeURL was never fetched — SNS will never deliver another event")
+ }
+}
+
+// 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")
+ }
+}