| 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. |
| 97 | package console |
| 98 | |
| 99 | import ( |
| 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. |
| 126 | const 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. |
| 132 | const 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. |
| 138 | const 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. |
| 153 | var 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. |
| 166 | var 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. |
| 200 | func 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). |
| 231 | type 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. |
| 251 | func (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. |
| 306 | func (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. |
| 432 | func 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. |
| 467 | func (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. |
| 598 | func 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. |
| 615 | func (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. |
| 635 | func (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. |
| 644 | func (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. |
| 663 | func (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. |
| 691 | type snsCertCache struct { |
| 692 | mu sync.Mutex |
| 693 | keys map[string]*rsa.PublicKey |
| 694 | } |
| 695 | |
| 696 | func (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 | |
| 703 | func (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. |
| 716 | func (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. |
| 739 | const 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. |
| 775 | func 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. |
| 816 | func (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. |
| 833 | func 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. |
| 857 | const ( |
| 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. |
| 884 | func (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 | |