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.
3 files changed,
+585
−0
internal/console/server.go+26 −0internal/console/sesevents.go+337 −0internal/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") |
| + } |
| +} |