carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The SES event ingest endpoint (tenant-email spec §9): SNS delivers send,
2// bounce and complaint notifications here, and they become the per-tenant
3// counts that enforce a cap and trip an automatic pause (Task 16).
4//
5// UNAUTHENTICATED, UNLIKE EVERY OTHER DOOR IN THIS PACKAGE. SNS is not a
6// signed-in member — it cannot present a session cookie, a CSRF token, or
7// prove account ownership — so handleSESEvents has none of those gates.
8// What replaces them, in the order they run below, is the door's entire
9// security model: bound the body, refuse a topic that isn't ours, validate
10// where the verifying certificate may come from, and verify the signature
11// before trusting any field the message carries.
12//
13// THE TRUST MODEL, IN ONE PARAGRAPH (read this before touching anything
14// below — four review rounds of history follow it, and the history is
15// there so the model doesn't get "corrected" back to something that
16// already failed): this door does NOT verify a certificate chain for SNS's
17// signing certificate, and that is deliberate. Trust instead comes from
18// THREE things together — the request is HTTPS (so the fetch itself rides
19// on a TLS connection whose OWN chain the Go http client already validates
20// against system roots — that is the chain check, done at the transport
21// layer, which is the only layer where it can actually succeed); the host
22// is PINNED to this deployment's own sns.<region>.amazonaws.com (or a
23// region-shaped fallback), so the TLS-validated connection is going
24// somewhere that is provably SNS's own infrastructure and not a stranger's;
25// and REDIRECTS are refused, so a 3xx response from that pinned, validated
26// host cannot hand control to a different, unvalidated one. On top of that,
27// the downloaded certificate's own subject is checked against
28// snsCertDNSName as a sanity check (catches a misconfigured or
29// wrong-endpoint response), NOT as a trust decision — there is no chain
30// verification of the certificate BODY at all, because (see Round 4 below)
31// there is no chain a client can build for it.
32//
33// SECURITY: validSigningCertURL and fetchVerifiedCertKey TOGETHER are the
34// entirety of SNS signature security. SNS tells the receiver where to fetch
35// the certificate that verifies the message — so an unvalidated URL, or an
36// unvalidated certificate, lets anyone who can reach this endpoint host
37// their own certificate, sign their own payload, and have it verify. The
38// consequence here is not merely bad data: forged bounce events trip the
39// automatic pause, so it is a remote off-switch for any tenant's email.
40//
41// A host allowlist is NOT a signer identity by itself, and a parsed
42// certificate is NOT a verified one — but ALSO, a certificate chain is not
43// always buildable, and code that insists on building one anyway can fail
44// closed on every legitimate message. Four review rounds' worth of fixes
45// live in this file because each of those was true in turn:
46//
47// - Round 1: the original check accepted any *.amazonaws.com host, which
48// is every AWS customer's identity, not Amazon's.
49// - Round 2 (C1-residual): pinning only the "sns." label and leaving the
50// region label free still accepted "sns.s3.amazonaws.com" — a real,
51// registered S3 bucket — the exact shape of the documented
52// sns-validator bypass. validSigningCertURL now pins to this
53// deployment's OWN region exactly when it is known, and falls back to
54// a region-SHAPED pattern (never a bare service name) only when it is
55// not.
56// - Round 2 (C2): the certificate was parsed and its public key trusted
57// with no chain verification and no subject check at all — a
58// self-signed certificate would have verified.
59// - Round 3 (C2 fixed-closed bug, attempt 1): the FIRST attempt at C2
60// asserted DNSName against the FETCH host (e.g.
61// sns.eu-west-1.amazonaws.com) and decoded only the first PEM block,
62// discarding any intermediate — but a genuine SNS signing certificate
63// names "sns.amazonaws.com" (one identity for the whole service, not
64// one per region), so this failed closed on every real message.
65// - Round 3 (redirect guard): both outbound fetches used
66// http.DefaultClient, which follows redirects — for the SubscribeURL
67// fetch specifically, a redirect target is never re-validated against
68// the host pin, so following one defeats it outright. Both fetches now
69// use snsFetchClient, which refuses to follow a redirect at all. (This
70// fix stands; it is not touched by Round 4.)
71// - Round 4 (C2 fixed-closed bug, attempt 2 — the SECOND reversal on this
72// paragraph, and the one that stuck): attempt 1's fix DID assert the
73// right subject, but it also tried to build an Intermediates pool from
74// the response and verify the chain against system roots. THIS ALSO
75// FAILS CLOSED, for a completely different reason than attempt 1's:
76// AWS serves EXACTLY ONE certificate in a real SigningCertURL response
77// — a bare leaf, no bundled intermediate (verified: `grep -c 'BEGIN
78// CERTIFICATE'` on a real captured SimpleNotificationService-*.pem is
79// 1). That leaf's issuer is an intermediate CA (e.g. "Amazon RSA 2048
80// M04") distributed via the leaf's AIA (Authority Information Access)
81// extension — which Go's x509.Verify never fetches; it does not chase
82// AIA. System root stores hold only the top-level Amazon Root CA
83// certificates, several hops above that intermediate. So an
84// Intermediates pool built only from what the response contains is
85// ALWAYS empty against a real message, and cert.Verify against system
86// roots always returns "certificate signed by unknown authority" —
87// proven directly against a real captured certificate, with
88// CurrentTime set inside its validity window so expiry could not mask
89// the result. Every reference implementation agrees there is no chain
90// to build here: aws-sdk-ruby's message_verifier.rb, robbiet480/go.sns,
91// and AWS's own documented `openssl x509 -pubkey -noout` procedure all
92// decode exactly ONE certificate block and verify no chain at all.
93// fetchVerifiedCertKey now does the same: decode one leaf, assert its
94// subject, done — no x509.CertPool, no Intermediates, no cert.Verify.
95// DO NOT reintroduce a chain build here without re-deriving why it was
96// removed twice.
97package console
98
99import (
100 "context"
101 "crypto"
102 "crypto/rsa"
103 "crypto/sha1"
104 "crypto/sha256"
105 "crypto/x509"
106 "encoding/base64"
107 "encoding/json"
108 "encoding/pem"
109 "errors"
110 "fmt"
111 "io"
112 "log"
113 "net/http"
114 "net/url"
115 "regexp"
116 "strings"
117 "sync"
118 "time"
119
120 "github.com/carlosframework/platform/internal/mail"
121)
122
123// sesEventMaxBody bounds the request body this door reads. SNS messages are
124// small (an SES notification is at most a few KB); an unbounded read on an
125// unauthenticated endpoint is memory exhaustion anyone can trigger.
126const sesEventMaxBody = 256 << 10
127
128// sesCertMaxBody bounds the signing certificate this door fetches (and the
129// SubscribeURL response body it discards). AWS's SNS certificates are a few
130// KB, even bundled with an intermediate; anything vastly larger than that
131// is not a certificate response this code needs to finish reading.
132const sesCertMaxBody = 64 << 10
133
134// sesFetchTimeout bounds both outbound calls this door makes — fetching the
135// signing certificate and confirming a subscription. Neither should ever
136// approach it against a healthy AWS endpoint; it exists so a wedged
137// far end costs one request this long, not a goroutine forever.
138const sesFetchTimeout = 10 * time.Second
139
140// snsFetchClient is used for both outbound fetches this door makes — the
141// signing certificate and the SubscribeURL confirmation.
142//
143// CheckRedirect refuses to follow. For the SubscribeURL fetch this is not
144// optional hardening: validSigningCertURL only ever inspects the ORIGINAL
145// URL, so a 3xx response pointing anywhere else would defeat the host pin
146// outright — the far end could serve a redirect to any host it likes and
147// this client would have followed it there. http.ErrUseLastResponse is the
148// documented way to say "hand me the redirect response itself, don't chase
149// it" — the caller then sees a non-2xx status and refuses it the same way
150// it refuses any other failure. Precedent: internal/edge/prober.go's client
151// uses the identical idiom for the identical reason — a redirect is not the
152// thing that was asked for.
153var snsFetchClient = &http.Client{
154 CheckRedirect: func(*http.Request, []*http.Request) error {
155 return http.ErrUseLastResponse
156 },
157}
158
159// snsHostPattern is the FALLBACK shape check, used only when this
160// deployment's own region is unknown: "sns." plus a REGION-SHAPED middle
161// label — "<2-letter-country>[-gov]-<name>-<digit>", e.g. "eu-west-1",
162// "us-gov-west-1", "ap-southeast-4" — plus ".amazonaws.com", and nothing
163// looser than that. A bare service name like "s3" or "s3-us-west-2" does
164// not have this shape, which is what closes the C1-residual bypass
165// described below.
166var snsHostPattern = regexp.MustCompile(`^sns\.[a-z]{2}(-gov)?-[a-z]+-\d+\.amazonaws\.com$`)
167
168// validSigningCertURL refuses anything that is not an https URL on an SNS
169// host — and, when region is non-empty, anything that is not THIS
170// deployment's own SNS regional host exactly. It is also reused, unchanged,
171// to validate SubscribeURL before that URL is ever fetched (round-2
172// IMPORTANT-1 fix): a verified signature only proves AWS put some string in
173// that field, not that the string points somewhere safe to GET, and a real
174// SubscribeURL is itself always an SNS regional URL, so the same check is
175// the right one for both.
176//
177// C1-RESIDUAL FIX (round 2 review): the previous pattern,
178// ^sns\.[a-z0-9-]+\.amazonaws\.com$, pins the FIRST label ("sns.") but
179// leaves the SECOND label completely unconstrained — which is the exact
180// shape of the documented sns-validator bypass:
181// "https://sns.s3.amazonaws.com/x.pem" matches it, and "sns" is a real,
182// registered AWS bucket name (the review confirmed it returns 403
183// AccessDenied — owned by someone, reachable, and NOT SNS). When this
184// deployment knows its own region (MailDeployment.Region, threaded in by
185// the caller), the host must equal "sns.<region>.amazonaws.com" EXACTLY —
186// no pattern, no label left free to be a bucket name. Only when the region
187// is unknown does this fall back to snsRegionPattern, which is at least
188// narrow enough to refuse a customer-chosen name: it requires the middle
189// label to have the SHAPE of an actual AWS region code, which "s3" and
190// "s3-us-west-2" do not.
191//
192// The host check is also LABEL-AWARE against the suffix-lookalike class of
193// bug: a bare strings.HasSuffix(host, "amazonaws.com") would accept
194// "evilamazonaws.com" — it genuinely ends with the string "amazonaws.com",
195// with no dot separating the two, so it is a completely different
196// registered domain that merely happens to end the right way. Anchoring the
197// whole pattern (or the whole exact-match string) is what refuses that, and
198// also refuses names like "sns.eu-west-1.amazonaws.com.evil.com" that
199// merely CONTAIN the right substring in the middle.
200func validSigningCertURL(raw, region string) error {
201 u, err := url.Parse(raw)
202 if err != nil {
203 return fmt.Errorf("sns: unparseable SigningCertURL")
204 }
205 if u.Scheme != "https" {
206 return fmt.Errorf("sns: SigningCertURL is not https")
207 }
208 host := strings.ToLower(u.Hostname())
209 region = strings.ToLower(strings.TrimSpace(region))
210 switch {
211 case region != "":
212 if host != "sns."+region+".amazonaws.com" {
213 return fmt.Errorf("sns: SigningCertURL host %q is not this deployment's SNS host (sns.%s.amazonaws.com)", host, region)
214 }
215 case !snsHostPattern.MatchString(host):
216 return fmt.Errorf("sns: SigningCertURL host %q is not an SNS host", host)
217 }
218 // A traversal in the path can walk off the certificate route on a host
219 // that serves more than certificates.
220 if strings.Contains(u.Path, "..") {
221 return fmt.Errorf("sns: SigningCertURL path contains a traversal")
222 }
223 return nil
224}
225
226// snsEnvelope is the outer SNS message shape — the fields this door reads
227// off of every delivery, notification or confirmation alike. Field names
228// and casing match SNS's own JSON exactly; there is no tolerance for a
229// renamed field because these are also exactly the fields the signature
230// covers (canonicalString below).
231type snsEnvelope struct {
232 Type string `json:"Type"`
233 MessageID string `json:"MessageId"`
234 Token string `json:"Token,omitempty"`
235 TopicArn string `json:"TopicArn"`
236 Subject string `json:"Subject,omitempty"`
237 Message string `json:"Message"`
238 SubscribeURL string `json:"SubscribeURL,omitempty"`
239 Timestamp string `json:"Timestamp"`
240 SignatureVersion string `json:"SignatureVersion"`
241 Signature string `json:"Signature"`
242 SigningCertURL string `json:"SigningCertURL"`
243}
244
245// canonicalString builds the exact byte string SNS signs, per AWS's
246// documented format: alternating field name and value, each followed by a
247// newline, fields in ALPHABETICAL order, and only the fields that apply to
248// this message's Type. Subject is included only when the message actually
249// carries one (SNS omits it from the signed string otherwise);
250// SubscribeURL/Token belong to confirmation messages only.
251func (e snsEnvelope) canonicalString() string {
252 var b strings.Builder
253 add := func(k, v string) {
254 b.WriteString(k)
255 b.WriteByte('\n')
256 b.WriteString(v)
257 b.WriteByte('\n')
258 }
259 add("Message", e.Message)
260 add("MessageId", e.MessageID)
261 switch e.Type {
262 case "SubscriptionConfirmation", "UnsubscribeConfirmation":
263 add("SubscribeURL", e.SubscribeURL)
264 add("Timestamp", e.Timestamp)
265 add("Token", e.Token)
266 add("TopicArn", e.TopicArn)
267 add("Type", e.Type)
268 default:
269 if e.Subject != "" {
270 add("Subject", e.Subject)
271 }
272 add("Timestamp", e.Timestamp)
273 add("TopicArn", e.TopicArn)
274 add("Type", e.Type)
275 }
276 return b.String()
277}
278
279// handleSESEvents is where SNS delivers every SES send, bounce and
280// complaint notification for this deployment.
281//
282// The checks below run in this exact order, and the order is the whole
283// point:
284//
285// 1. Bound the body. Nothing about an unauthenticated endpoint should read
286// an unbounded amount of attacker-controlled data before anything else
287// has a chance to refuse it.
288// 2. Refuse a foreign TopicArn. This runs before ANY network call this
289// handler might make (the certificate fetch in step 4, the subscribe
290// GET in dispatch) — otherwise a stranger's POST decides who this
291// console makes outbound requests to, which is SSRF wearing an
292// AWS-shaped alibi.
293// 3. Validate SigningCertURL's shape — https, an SNS host (this
294// deployment's own region exactly, when known), no path traversal —
295// before it is ever fetched.
296// 4. Verify the signature over SNS's canonical string, which includes
297// fetching the certificate and checking ITS chain and subject too.
298// Nothing past this point trusts a single field of the message,
299// including SubscribeURL, until this has succeeded.
300// 5. Dispatch: confirm a subscription (after running its SubscribeURL
301// through the SAME host check as step 3), or parse a notification.
302// Anything this deployment deliberately does not act on still answers
303// 200 — SNS retries non-2xx, so refusing an unknown case with a
304// failure status would turn one unrecognised field into an unbounded
305// retry flood against the console.
306func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
307 // 1. Bound the body first.
308 r.Body = http.MaxBytesReader(w, r.Body, sesEventMaxBody)
309 body, err := io.ReadAll(r.Body)
310 if err != nil {
311 var tooLarge *http.MaxBytesError
312 if errors.As(err, &tooLarge) {
313 jsonError(w, http.StatusRequestEntityTooLarge, "request body too large")
314 return
315 }
316 jsonError(w, http.StatusBadRequest, "could not read request body")
317 return
318 }
319
320 var env snsEnvelope
321 if err := json.Unmarshal(body, &env); err != nil {
322 jsonError(w, http.StatusBadRequest, "malformed SNS envelope")
323 return
324 }
325
326 // 2. Refuse a foreign TopicArn before fetching anything. An unconfigured
327 // SNSTopicARN ("") never matches — a deployment with no topic set
328 // accepts nothing, rather than accepting whatever a caller who also
329 // sends "" happens to send.
330 if s.cfg.MailDeployment.SNSTopicARN == "" || env.TopicArn != s.cfg.MailDeployment.SNSTopicARN {
331 s.logRefusedTopic(env.TopicArn)
332 jsonError(w, http.StatusForbidden, "unrecognised SNS topic")
333 return
334 }
335
336 // 3. Validate SigningCertURL before it is ever fetched.
337 region := s.cfg.MailDeployment.Region
338 if err := validSigningCertURL(env.SigningCertURL, region); err != nil {
339 jsonError(w, http.StatusBadRequest, err.Error())
340 return
341 }
342
343 // 4. Verify the signature before acting on ANY field, including
344 // SubscribeURL. snsVerify is the test seam — nil means "do the real
345 // verification" (verifySNSSignature, which fetches the certificate over
346 // the network). The seam is signature verification ONLY: both checks
347 // above already ran unconditionally, for real, regardless of the seam.
348 //
349 // The real path is wrapped in a closure so it can carry the request's
350 // own context (matching confirmSNSSubscription below) rather than
351 // context.Background() — without changing the seam's own type, which
352 // stays exactly func(snsEnvelope) error so a test's assignment to
353 // s.snsVerify is unaffected.
354 verify := s.snsVerify
355 if verify == nil {
356 verify = func(e snsEnvelope) error { return s.verifySNSSignature(r.Context(), e) }
357 }
358 if err := verify(env); err != nil {
359 log.Printf("console: ses events: signature verification failed: %v", err)
360 jsonError(w, http.StatusForbidden, "signature verification failed")
361 return
362 }
363
364 // 5. Dispatch.
365 switch env.Type {
366 case "SubscriptionConfirmation", "UnsubscribeConfirmation":
367 // IMPORTANT-1 fix: SubscribeURL gets the SAME pinned-host check as
368 // SigningCertURL before it is fetched. A verified signature only
369 // proves AWS put some string in this field — it says nothing about
370 // where that string points — so without this an authentic message
371 // can still carry a SubscribeURL aimed at an arbitrary host,
372 // including link-local metadata endpoints.
373 if err := validSigningCertURL(env.SubscribeURL, region); err != nil {
374 log.Printf("console: ses events: refusing SubscribeURL: %v", err)
375 jsonError(w, http.StatusBadRequest, "invalid SubscribeURL")
376 return
377 }
378 // UnsubscribeConfirmation's SubscribeURL is, by SNS's own naming,
379 // actually a RE-subscribe link: fetching it here means an operator
380 // who unsubscribes this topic from the SNS console finds the
381 // endpoint re-subscribed within seconds, the next time SNS sends an
382 // UnsubscribeConfirmation. Defensible — this deployment always
383 // wants to be subscribed to its own mail-events topic, so an
384 // unsubscribe is far more likely to be SNS housekeeping or an
385 // operator mistake than an intentional retirement — but it is not
386 // what "Unsubscribe" reads as, so it is named here rather than left
387 // to be discovered.
388 ctx, cancel := context.WithTimeout(r.Context(), sesFetchTimeout)
389 defer cancel()
390 if err := s.confirmSNSSubscription(ctx, env.SubscribeURL); err != nil {
391 log.Printf("console: ses events: subscription confirm: %v", err)
392 jsonError(w, http.StatusBadGateway, "could not confirm the SNS subscription")
393 return
394 }
395 w.WriteHeader(http.StatusOK)
396 case "Notification":
397 ev, err := mail.ParseEvent([]byte(env.Message))
398 if err != nil {
399 // Logged and dropped, not retried: an unparseable or
400 // unattributable message will never become parseable by SNS
401 // resending the identical bytes, so a non-2xx here would only
402 // buy a permanent retry loop.
403 log.Printf("console: ses events: parse notification: %v", err)
404 w.WriteHeader(http.StatusOK)
405 return
406 }
407 if ev.Incomplete != "" {
408 // Counted all the same; only the log is missing something.
409 log.Printf("console: ses events: %s notification %s read in part: %s", ev.Type, env.MessageID, ev.Incomplete)
410 }
411 s.recordAndEnforceMail(r.Context(), ev, env.MessageID)
412 w.WriteHeader(http.StatusOK)
413 default:
414 // An SES event type this deployment does not act on (SES has more
415 // types than the five this package names). Deliberately ignored,
416 // so it is a 200 — see the dispatch comment on the handler above.
417 w.WriteHeader(http.StatusOK)
418 }
419}
420
421// domainMatchesEvent reports whether ev's domain attributes to d — either
422// the identity's own domain (the sender-supplied From SES usually reports
423// as mail.source) or, for ANY region, d's recorded custom MAIL FROM
424// subdomain (I6, review follow-up). SES may report the REWRITTEN envelope
425// — mail-<region>.<domain>, the custom MAIL FROM MX target (spec §7) —
426// rather than the sender-supplied From, and when it does, a match against
427// d.Domain alone attributes the event to nothing: every count for that
428// send silently drops, which is a second, independent way the whole of
429// spec §9 (caps, the abuse pause) stops working. Matched against RECORDED
430// values only, never a prefix-strip heuristic — MailFromDomain's shape
431// could change, and this must not assume it.
432func domainMatchesEvent(d *mail.DomainRecord, domain string) bool {
433 if d.Domain == domain {
434 return true
435 }
436 for _, st := range d.Regions {
437 if st != nil && st.MailFrom != "" && st.MailFrom == domain {
438 return true
439 }
440 }
441 return false
442}
443
444// recordAndEnforceMail resolves ev.Domain to the account(s)/app(s) that own
445// it and, for each, records the count and re-evaluates the cap and rate
446// thresholds (Task 16, mailcounts.go). Best-effort throughout: a lookup or
447// storage failure here is logged and swallowed, never turned into a non-2xx
448// — the caller has already committed to answering 200 for every recognised
449// notification, retried or not, and losing one count must not become an
450// unbounded SNS retry flood.
451//
452// Domain identity (mail.DomainRecord) is ACCOUNT-scoped, and there is no
453// reverse index from a bare domain to the account that owns it — so every
454// live console account is walked. Console deployments run a small,
455// bounded number of accounts; this is the same shape ListDomains' own
456// callers (mail.Teardown, mail.grantedDomains) already accept for a single
457// account, one level up.
458//
459// Each notification is also kept in the per-message log (mailevents.go),
460// under notice — SNS's own id for it, and, since senders (design §8), the
461// counts follow the same rule: where the caller identity resolves to a
462// sender CARLOS minted, that sender alone is counted, once, and the
463// domain-wide loop below is skipped entirely for it. Only when the caller
464// resolves to nothing does the count fall back to every app on the matching
465// domain, against its app sender, which is the whole of what this endpoint
466// did before senders existed.
467func (s *Server) recordAndEnforceMail(ctx context.Context, ev mail.Event, notice string) {
468 accts, err := s.store.Accounts()
469 if err != nil {
470 log.Printf("console: ses events: listing accounts for %s: %v", ev.Domain, err)
471 return
472 }
473 // The SEND decides which day's counters an event lands in — never the
474 // bounce, the complaint or the delivery, which can arrive hours or days
475 // later. The rate limiter divides a day's bounces and complaints by that
476 // same day's sends, so an event must count against the day its message
477 // was sent: a bounce at 00:05 for a message sent at 23:50 counted on the
478 // new day reads as a bounce rate over a handful of sends, and pauses a
479 // healthy tenant. (ev.At is the event's own time, for the log; ev.SentAt
480 // is mail.timestamp.) A missing send time (mail.ParseEvent leaves it
481 // zero when SES omits or mis-shapes it) falls back to now rather than
482 // bucketing into 0001-01-01. The SAME instant is then reused as
483 // enforceMailLimits' "now", so the cap/rate check reads the same day the
484 // count just landed in — using time.Now() there instead could evaluate a
485 // different UTC day than the one just written, right at midnight.
486 at := ev.SentAt
487 if at.IsZero() {
488 at = time.Now()
489 }
490 day := at.UTC().Format("2006-01-02")
491 now := time.Now()
492 key := mailNoticeKey(notice, ev, now)
493
494 // ATTRIBUTION BY CALLER (senders design §8). Every sender has its own
495 // IAM user and SES stamps its name on Send, Bounce, Complaint, Delivery
496 // and Reject, so where the sending domain can only name every app that
497 // shares it, the caller names the one sender that actually sent. The
498 // resolver parses before it reads, so an app enabled before senders
499 // existed still resolves with no index entry of its own.
500 callerAcct, callerApp, callerSender, named := mail.SenderOfCaller(ctx, s.cfg.Releases, ev.Caller)
501 if named {
502 // The COUNT is gated on the app being live, exactly as the log row
503 // already is (recordMailMessage, sesevents.go:558). A caller name
504 // can parse into an account and an app that is not a live app —
505 // mail.UserName(acct, "not-an-app") is a well-formed name for an
506 // app that was trashed, or never existed — and counting it would
507 // insert a mail_sender_counts row keyed on a name no app answers
508 // to, which nothing would ever read or reap. Returning rather than
509 // falling through is deliberate: SES named a sender, so the
510 // domain-wide loop is not the right answer for it either.
511 //
512 // But that only holds for a CONFIRMED absence. A store ERROR here
513 // is not proof the app is gone, and treating it as one would drop
514 // the event outright: no count, no cap check, and no domain
515 // fallback either, with SNS still seeing a 200 so nothing ever
516 // retries it. The fallback loop below applies exactly the opposite
517 // rule to its own read errors ("A read failure is not a reason to
518 // drop the event: fall through and charge") for the same reason, so
519 // a transient error here falls through to it too rather than being
520 // swallowed.
521 live, err := s.mailAppLiveErr(callerAcct, callerApp)
522 if err != nil {
523 log.Printf("console: ses events: checking %s/%s live: %v; falling back to domain attribution", callerAcct, callerApp, err)
524 } else if !live {
525 return
526 } else {
527 s.recordMailMessage(ctx, callerAcct, callerApp, key, ev, true, now)
528 // Counted ONCE, against the sender SES named, and the domain loop
529 // below is skipped entirely. This is the visible change in the roll
530 // note: a sibling app on the same domain is no longer charged for
531 // this send, which is the intent, but a tenant watching its own
532 // numbers will see them move.
533 if err := s.store.RecordMailEvent(ctx, callerAcct, callerApp, callerSender, day, ev.Type); err != nil {
534 log.Printf("console: ses events: recording %s for %s/%s/%s -> %v", ev.Type, callerAcct, callerApp, callerSender, err)
535 return
536 }
537 if _, reason, err := s.enforceMailLimits(ctx, callerAcct, callerApp, callerSender, at); err != nil {
538 log.Printf("console: ses events: enforcing limits for %s/%s/%s -> %v", callerAcct, callerApp, callerSender, err)
539 } else if reason != "" {
540 log.Printf("console: ses events: paused %s/%s/%s -> %s", callerAcct, callerApp, callerSender, reason)
541 }
542 return
543 }
544 }
545
546 // The fallback, unchanged: SES named nobody CARLOS minted, so the
547 // sending domain is all there is to go on and every app on it is
548 // charged, against its app sender.
549 for _, a := range accts {
550 domains, err := mail.ListDomains(ctx, s.cfg.Releases, a.Sqid)
551 if err != nil {
552 log.Printf("console: ses events: listing domains for %s: %v", a.Sqid, err)
553 continue
554 }
555 for _, d := range domains {
556 if !domainMatchesEvent(d, ev.Domain) {
557 continue
558 }
559 // The log's fallback asks more of the match than the counts do:
560 // only a domain this account actually verified gets recipients'
561 // addresses. The domain claim refuses a second account the same
562 // name, but not another account's MAIL FROM host claimed as a
563 // domain of its own and never verified.
564 logHere := domainVerifiedForEvent(d, ev.Domain)
565 for _, app := range d.Apps {
566 // An app in per-instance mode is skipped (design §8). Its
567 // app sender is deliberately dead — no instance sends
568 // through it — so charging it for a domain-wide event it
569 // cannot have caused would pause a credential nobody holds
570 // and hide the instance that actually sent. A read failure
571 // is not a reason to drop the event: fall through and
572 // charge, which is today's behaviour.
573 if sn, _, err := mail.GetSender(ctx, s.cfg.Releases, a.Sqid, app, mail.SenderAppID); err != nil {
574 log.Printf("console: ses events: reading the app sender for %s/%s -> %v", a.Sqid, app, err)
575 } else if sn != nil && sn.PerInstance {
576 continue
577 }
578 if logHere {
579 s.recordMailMessage(ctx, a.Sqid, app, key, ev, false, now)
580 }
581 if err := s.store.RecordMailEvent(ctx, a.Sqid, app, mail.SenderAppID, day, ev.Type); err != nil {
582 log.Printf("console: ses events: recording %s for %s/%s: %v", ev.Type, a.Sqid, app, err)
583 continue
584 }
585 if _, reason, err := s.enforceMailLimits(ctx, a.Sqid, app, mail.SenderAppID, at); err != nil {
586 log.Printf("console: ses events: enforcing limits for %s/%s: %v", a.Sqid, app, err)
587 } else if reason != "" {
588 log.Printf("console: ses events: paused %s/%s: %s", a.Sqid, app, reason)
589 }
590 }
591 }
592 }
593}
594
595// domainVerifiedForEvent is domainMatchesEvent restricted to what this
596// record has proved: the identity domain in a region SES verified it in, or
597// the MAIL FROM host of a verified region.
598func domainVerifiedForEvent(d *mail.DomainRecord, domain string) bool {
599 for _, st := range d.Regions {
600 if st == nil || st.Status != mail.StatusVerified {
601 continue
602 }
603 if d.Domain == domain || (st.MailFrom != "" && st.MailFrom == domain) {
604 return true
605 }
606 }
607 return false
608}
609
610// recordMailMessage keeps one notification in one app's log, if that app is
611// live. A trashed app keeps nothing new: its name is not a member-facing app
612// any more, and what it is sent is not somebody else's to read after a
613// restore or a claim. Best effort, like the rest of the ingest: a failure
614// is logged and costs neither the count nor the 200.
615func (s *Server) recordMailMessage(ctx context.Context, acct, app, key string, ev mail.Event, bySender bool, now time.Time) {
616 if !s.mailAppLive(acct, app) {
617 return
618 }
619 if err := s.store.RecordMailMessageEvent(ctx, acct, app, key, ev, bySender, now); err != nil {
620 log.Printf("console: ses events: logging %s for %s/%s: %v", ev.Type, acct, app, err)
621 }
622 // Pushed to the app only when SES itself named it as the sender: a
623 // domain-attributed event names a recipient who may be another app's
624 // (mailpush.go).
625 if bySender {
626 s.queueMailEventPush(ctx, acct, app, key, ev, now)
627 }
628}
629
630// mailAppLive reports whether acct (a sqid) holds app as a live, untrashed
631// app right now. It collapses a store error into false, like an absent app,
632// which is fine for its one caller (recordMailMessage's best-effort log
633// guard) but wrong for anything that treats "not live" as a reason to stop
634// processing an event entirely — use mailAppLiveErr there instead.
635func (s *Server) mailAppLive(acct, app string) bool {
636 live, err := s.mailAppLiveErr(acct, app)
637 return err == nil && live
638}
639
640// mailAppLiveErr is mailAppLive with the error kept apart from a confirmed
641// absence: err != nil means the store read itself failed, not that the app
642// is gone. A caller deciding whether to drop an event needs that
643// distinction — an errored read is not evidence of anything.
644func (s *Server) mailAppLiveErr(acct, app string) (bool, error) {
645 a, err := s.store.AccountBySqid(acct)
646 if err != nil {
647 return false, err
648 }
649 if a == nil {
650 return false, nil
651 }
652 return s.store.HasAppIn(a.ID, app)
653}
654
655// confirmSNSSubscription GETs the verified, host-checked SubscribeURL. It is
656// reached only after the enclosing message's signature has verified AND
657// SubscribeURL has passed validSigningCertURL — the signature proves AWS
658// put the string there, the host check proves the string points at SNS
659// itself rather than somewhere this console should never fetch on a
660// stranger's behalf. It uses snsFetchClient (never http.DefaultClient) so a
661// redirect from that already-verified host cannot hand control to a
662// DIFFERENT, unverified one — see snsFetchClient's doc comment.
663func (s *Server) confirmSNSSubscription(ctx context.Context, subscribeURL string) error {
664 req, err := http.NewRequestWithContext(ctx, http.MethodGet, subscribeURL, nil)
665 if err != nil {
666 return fmt.Errorf("sns: building subscribe request: %w", err)
667 }
668 resp, err := snsFetchClient.Do(req)
669 if err != nil {
670 return fmt.Errorf("sns: fetching SubscribeURL: %w", err)
671 }
672 defer resp.Body.Close()
673 io.Copy(io.Discard, io.LimitReader(resp.Body, sesCertMaxBody))
674 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
675 return fmt.Errorf("sns: SubscribeURL confirm status %d", resp.StatusCode)
676 }
677 return nil
678}
679
680// snsCertCache caches a signing certificate's verified RSA public key,
681// keyed on its (already pinned-host-validated) URL — IMPORTANT-2 fix.
682// Without it, every request bearing the deployment's TopicArn — which is
683// not a secret, it appears in IaC and logs — triggers a fresh outbound
684// HTTPS fetch and a full certificate-chain verification: unauthenticated
685// outbound amplification, one connection out per request in. The AWS SDKs
686// cache verification certs by URL for the same reason.
687//
688// A value type with a nil-safe zero value on purpose: Server embeds one
689// directly (no constructor call needed), the same way ceClientOnce
690// (server.go) needs no init.
691type snsCertCache struct {
692 mu sync.Mutex
693 keys map[string]*rsa.PublicKey
694}
695
696func (c *snsCertCache) get(certURL string) (*rsa.PublicKey, bool) {
697 c.mu.Lock()
698 defer c.mu.Unlock()
699 pub, ok := c.keys[certURL]
700 return pub, ok
701}
702
703func (c *snsCertCache) put(certURL string, pub *rsa.PublicKey) {
704 c.mu.Lock()
705 defer c.mu.Unlock()
706 if c.keys == nil {
707 c.keys = map[string]*rsa.PublicKey{}
708 }
709 c.keys[certURL] = pub
710}
711
712// verifiedCertKey returns the RSA public key for a signing certificate URL
713// — already pinned to an SNS host by the caller (validSigningCertURL) —
714// using the cache on a hit, and fetching plus fully verifying the
715// certificate on a miss.
716func (s *Server) verifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, error) {
717 if pub, ok := s.snsCerts.get(certURL); ok {
718 return pub, nil
719 }
720 pub, err := fetchVerifiedCertKey(ctx, certURL)
721 if err != nil {
722 return nil, err
723 }
724 s.snsCerts.put(certURL, pub)
725 return pub, nil
726}
727
728// snsCertDNSName is the hostname a genuine SNS message-signing certificate
729// actually carries in its subject/SAN — NOT the regional fetch host (e.g.
730// sns.eu-west-1.amazonaws.com). SNS mints one signing identity for the
731// whole service, not one per region. Verified against a genuine SNS
732// signing certificate the round-2 review pulled from AWS's own SDK test
733// fixture (aws-sdk-ruby, gems/aws-sdk-sns/spec/message_verifier_spec.rb):
734// subject=CN=sns.amazonaws.com, SAN=[sns.amazonaws.com],
735// issuer=CN=Amazon,OU=Server CA 1B,O=Amazon,C=US. That fixture is old and
736// is not treated as sufficient evidence that today's live certificate is
737// identical — see sesevents.go's package comment and the task report for
738// what remains unproven until a real message arrives.
739const snsCertDNSName = "sns.amazonaws.com"
740
741// fetchVerifiedCertKey downloads the certificate at certURL and returns its
742// RSA public key. certURL is assumed already pinned (validSigningCertURL)
743// and fetched with redirects refused (snsFetchClient) — those two, plus the
744// TLS chain validation the http client performs automatically as part of
745// making an HTTPS request, are what make the BYTES this function receives
746// trustworthy. See the package comment's "TRUST MODEL" paragraph for the
747// full picture.
748//
749// NO CHAIN IS BUILT OR VERIFIED FOR THE CERTIFICATE ITSELF, DELIBERATELY.
750// This reverses an EARLIER fix in this same file — twice (see the package
751// comment, "Round 4") — and the removal is deliberate, not an oversight to
752// "fix" back:
753//
754// - AWS serves EXACTLY ONE certificate in a real SigningCertURL response
755// — a bare leaf, with no bundled intermediate.
756// - That leaf's issuer is an intermediate CA distributed via the leaf's
757// AIA extension, which Go's x509.Verify never fetches (no AIA
758// chasing). System root stores hold only the top-level Amazon Root CA
759// certificates, several hops above that intermediate.
760// - So a chain built ONLY from what the response contains is ALWAYS
761// empty against a real message, and verifying it against system roots
762// ALWAYS fails with "certificate signed by unknown authority" —
763// proven directly against a real captured certificate. Every
764// reference implementation (aws-sdk-ruby's message_verifier.rb,
765// robbiet480/go.sns, AWS's own documented `openssl x509 -pubkey
766// -noout` procedure) agrees: decode one leaf, verify no chain.
767//
768// What remains is the ONE check that both means something and can actually
769// succeed: the certificate's own subject must be snsCertDNSName — SNS's
770// fixed identity, not the regional host it happened to be fetched from.
771// This is a SANITY check, not a trust decision on its own — it catches a
772// misconfigured or wrong-endpoint response, and it is deliberately paired
773// with the host pin and redirect guard elsewhere, which are what make the
774// FETCH itself trustworthy in the first place.
775func fetchVerifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, error) {
776 fctx, cancel := context.WithTimeout(ctx, sesFetchTimeout)
777 defer cancel()
778 req, err := http.NewRequestWithContext(fctx, http.MethodGet, certURL, nil)
779 if err != nil {
780 return nil, fmt.Errorf("sns: building cert request: %w", err)
781 }
782 resp, err := snsFetchClient.Do(req)
783 if err != nil {
784 return nil, fmt.Errorf("sns: fetching signing cert: %w", err)
785 }
786 defer resp.Body.Close()
787 if resp.StatusCode != http.StatusOK {
788 return nil, fmt.Errorf("sns: signing cert fetch status %d", resp.StatusCode)
789 }
790 certPEM, err := io.ReadAll(io.LimitReader(resp.Body, sesCertMaxBody))
791 if err != nil {
792 return nil, fmt.Errorf("sns: reading signing cert: %w", err)
793 }
794 block, _ := pem.Decode(certPEM)
795 if block == nil {
796 return nil, fmt.Errorf("sns: signing cert is not PEM")
797 }
798 leaf, err := x509.ParseCertificate(block.Bytes)
799 if err != nil {
800 return nil, fmt.Errorf("sns: parsing signing cert: %w", err)
801 }
802 if err := leaf.VerifyHostname(snsCertDNSName); err != nil {
803 return nil, fmt.Errorf("sns: signing cert subject is not %s: %w", snsCertDNSName, err)
804 }
805 pub, ok := leaf.PublicKey.(*rsa.PublicKey)
806 if !ok {
807 return nil, fmt.Errorf("sns: signing cert public key is not RSA")
808 }
809 return pub, nil
810}
811
812// verifySNSSignature is the real (non-test-seam) verification path: resolve
813// the certificate's verified public key (cached; fetched and chain-checked
814// on a miss) and check the signature over the message's own canonical
815// string.
816func (s *Server) verifySNSSignature(ctx context.Context, env snsEnvelope) error {
817 pub, err := s.verifiedCertKey(ctx, env.SigningCertURL)
818 if err != nil {
819 return err
820 }
821 return verifySNSSignatureBytes(pub, []byte(env.canonicalString()), env.SignatureVersion, env.Signature)
822}
823
824// verifySNSSignatureBytes checks a base64 signature against canon using pub,
825// per SignatureVersion. Split out from verifySNSSignature so this — the
826// actual cryptographic check — can be tested with a plain in-memory RSA
827// keypair: no network, no certificate, no trust store involved.
828//
829// SNS signs with SHA1withRSA under SignatureVersion "1" (the historical
830// default, and what SNS still sends unless a topic opts into version "2",
831// SHA256withRSA). Both are verified here; an unrecognised version is
832// refused rather than guessed at.
833func verifySNSSignatureBytes(pub *rsa.PublicKey, canon []byte, sigVersion, sigB64 string) error {
834 sig, err := base64.StdEncoding.DecodeString(sigB64)
835 if err != nil {
836 return fmt.Errorf("sns: signature is not base64: %w", err)
837 }
838 switch sigVersion {
839 case "", "1":
840 sum := sha1.Sum(canon)
841 if err := rsa.VerifyPKCS1v15(pub, crypto.SHA1, sum[:], sig); err != nil {
842 return fmt.Errorf("sns: signature verification failed: %w", err)
843 }
844 case "2":
845 sum := sha256.Sum256(canon)
846 if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig); err != nil {
847 return fmt.Errorf("sns: signature verification failed: %w", err)
848 }
849 default:
850 return fmt.Errorf("sns: unsupported SignatureVersion %q", sigVersion)
851 }
852 return nil
853}
854
855// refusedTopicWindow is how long one distinct refused ARN stays quiet after
856// being logged, and refusedTopicMax how many distinct ARNs are remembered.
857const (
858 refusedTopicWindow = time.Hour
859 refusedTopicMax = 8
860)
861
862// logRefusedTopic names a rejected TopicArn in the log, at most once per
863// hour per distinct ARN.
864//
865// WHY LOG AT ALL. The TopicArn allowlist is this door's first real gate, and
866// a stale or mistyped CARLOS_MAIL_SNS_TOPIC_ARN is the single most likely
867// misconfiguration in the whole event path. Before this it left NO trace:
868// 403s at a door nobody watches, and counts that never move. "Events stopped
869// arriving and nothing said so" is precisely the silence this is for. The
870// ARN is not a secret — it is published in terraform and read off the box —
871// so naming it costs nothing.
872//
873// WHY BOUNDED. This door is UNAUTHENTICATED and internet-reachable by
874// design (SNS has no credential to present), so an unbounded log line here
875// is a stranger's write access to the disk. Bounding answers that without
876// giving up the diagnostic: the misconfiguration this exists to catch
877// produces exactly ONE distinct ARN, so one line an hour says everything,
878// while a flood of fabricated ARNs costs at most refusedTopicMax lines an
879// hour and a map of the same size.
880//
881// The 403 body is deliberately unchanged. The external answer stays
882// constant and uninformative; the operator's answer goes to the log. They
883// are different readers and only one of them is trusted.
884func (s *Server) logRefusedTopic(got string) {
885 // %q escapes control bytes, so a caller cannot forge log lines; the
886 // truncation bounds the damage a very long value can do to the line. A
887 // real ARN is about 100 bytes.
888 if len(got) > 256 {
889 got = got[:256]
890 }
891 want := s.cfg.MailDeployment.SNSTopicARN
892 wantStr := fmt.Sprintf("%q", want)
893 if want == "" {
894 // Its own misconfiguration signal, and a different one: the
895 // deployment believes email events are off while SNS is actively
896 // delivering to it.
897 wantStr = "(none configured)"
898 }
899
900 s.refusedTopicsMu.Lock()
901 now := time.Now()
902 if s.refusedTopics == nil {
903 s.refusedTopics = map[string]time.Time{}
904 }
905 if last, seen := s.refusedTopics[got]; seen && now.Sub(last) < refusedTopicWindow {
906 s.refusedTopicsMu.Unlock()
907 return
908 }
909 if _, seen := s.refusedTopics[got]; !seen && len(s.refusedTopics) >= refusedTopicMax {
910 // Evict the oldest rather than refusing to record: a genuine new
911 // misconfiguration arriving after a flood must still be logged.
912 oldestK, oldestT := "", now
913 for k, t := range s.refusedTopics {
914 if t.Before(oldestT) || oldestK == "" {
915 oldestK, oldestT = k, t
916 }
917 }
918 delete(s.refusedTopics, oldestK)
919 }
920 s.refusedTopics[got] = now
921 s.refusedTopicsMu.Unlock()
922
923 log.Printf("console: ses events: refused topic %q — this deployment expects %s", got, wantStr)
924}
925