| index 74653fbd8..3fbe7611a 100644 |
| --- a/internal/console/sesevents.go |
| +++ b/internal/console/sesevents.go |
| @@ -10,22 +10,46 @@ |
| // where the verifying certificate may come from, and verify the signature |
| // before trusting any field the message carries. |
| // |
| -// SECURITY: validSigningCertURL is the entirety of SNS signature security. |
| -// SNS tells the receiver where to fetch the certificate that verifies the |
| -// message — so an unvalidated URL lets anyone who can reach this endpoint |
| -// host their own certificate, sign their own payload, and have it verify. |
| -// The consequence here is not merely bad data: forged bounce events trip |
| -// the automatic pause, so it is a remote off-switch for any tenant's email. |
| +// SECURITY: validSigningCertURL and fetchVerifiedCertKey TOGETHER are the |
| +// entirety of SNS signature security. SNS tells the receiver where to fetch |
| +// the certificate that verifies the message — so an unvalidated URL, or an |
| +// unverified certificate, lets anyone who can reach this endpoint host |
| +// their own certificate, sign their own payload, and have it verify. The |
| +// consequence here is not merely bad data: forged bounce events trip the |
| +// automatic pause, so it is a remote off-switch for any tenant's email. |
| // |
| -// A host allowlist is NOT a signer identity by itself (post-review fix): the |
| -// original check here accepted any *.amazonaws.com host, which is every AWS |
| -// customer's identity, not Amazon's — a free-tier S3 bucket or API Gateway |
| -// stage can serve a genuine, publicly-trusted certificate from a host that |
| -// satisfies "ends in amazonaws.com". validSigningCertURL now pins to SNS's |
| -// own regional endpoint shape, and verifiedCertKey separately verifies the |
| -// downloaded certificate's chain (against system roots) and hostname — |
| -// two independent checks, both required, because either alone still admits |
| -// a forgery the other closes. |
| +// A host allowlist is NOT a signer identity by itself, and a parsed |
| +// certificate is NOT a verified one — two review rounds' worth of fixes |
| +// live in this file because each of those was true in turn: |
| +// |
| +// - Round 1: the original check accepted any *.amazonaws.com host, which |
| +// is every AWS customer's identity, not Amazon's. |
| +// - Round 2 (C1-residual): pinning only the "sns." label and leaving the |
| +// region label free still accepted "sns.s3.amazonaws.com" — a real, |
| +// registered S3 bucket — the exact shape of the documented |
| +// sns-validator bypass. validSigningCertURL now pins to this |
| +// deployment's OWN region exactly when it is known, and falls back to |
| +// a region-SHAPED pattern (never a bare service name) only when it is |
| +// not. |
| +// - Round 2 (C2): the certificate was parsed and its public key trusted |
| +// with no chain verification and no subject check at all — a |
| +// self-signed certificate would have verified. fetchVerifiedCertKey now |
| +// checks the chain against system roots AND the subject SNS's real |
| +// certificate carries. |
| +// - Round 3 (C2 fixed-closed bug): the FIRST attempt at C2 asserted |
| +// DNSName against the FETCH host (e.g. sns.eu-west-1.amazonaws.com) and |
| +// decoded only the first PEM block, discarding any intermediate — but a |
| +// genuine SNS signing certificate names "sns.amazonaws.com" (one |
| +// identity for the whole service, not one per region) and does not |
| +// chain to a system root without its intermediate. That combination |
| +// failed closed on every real message. fetchVerifiedCertKey now decodes |
| +// every PEM block (leaf plus intermediates) and asserts the FIXED |
| +// subject SNS actually uses, not the fetch host. |
| +// - Round 3 (redirect guard): both outbound fetches used |
| +// http.DefaultClient, which follows redirects — for the SubscribeURL |
| +// fetch specifically, a redirect target is never re-validated against |
| +// the host pin, so following one defeats it outright. Both fetches now |
| +// use a client that refuses to follow a redirect at all. |
| package console |
| |
| import ( |
| @@ -59,8 +83,8 @@ const sesEventMaxBody = 256 << 10 |
| |
| // sesCertMaxBody bounds the signing certificate this door fetches (and the |
| // SubscribeURL response body it discards). AWS's SNS certificates are a few |
| -// KB; anything vastly larger than that is not a certificate this code needs |
| -// to finish reading. |
| +// KB, even bundled with an intermediate; anything vastly larger than that |
| +// is not a certificate response this code needs to finish reading. |
| const sesCertMaxBody = 64 << 10 |
| |
| // sesFetchTimeout bounds both outbound calls this door makes — fetching the |
| @@ -69,35 +93,67 @@ const sesCertMaxBody = 64 << 10 |
| // far end costs one request this long, not a goroutine forever. |
| const sesFetchTimeout = 10 * time.Second |
| |
| -// snsHostPattern is the ONLY host shape a signing certificate — or a |
| -// SubscribeURL — may come from: an SNS regional endpoint, |
| -// "sns.<region>.amazonaws.com". This is deliberately narrower than "any |
| -// amazonaws.com host": that wider family is not Amazon's identity, it is |
| -// every AWS customer's — an S3 bucket (evil-bucket.s3.amazonaws.com), an API |
| -// Gateway stage (xyz.execute-api.eu-west-1.amazonaws.com), or any of the |
| -// hundreds of other services that mint a genuine, publicly-trusted |
| -// certificate on an *.amazonaws.com host that a free-tier account can stand |
| -// up in minutes. Accepting any of those reduces this door's whole security |
| -// model to the secrecy of the topic ARN, which is not a secret — it appears |
| -// in IaC and logs. |
| -var snsHostPattern = regexp.MustCompile(`^sns\.[a-z0-9-]+\.amazonaws\.com$`) |
| +// snsFetchClient is used for both outbound fetches this door makes — the |
| +// signing certificate and the SubscribeURL confirmation. |
| +// |
| +// CheckRedirect refuses to follow. For the SubscribeURL fetch this is not |
| +// optional hardening: validSigningCertURL only ever inspects the ORIGINAL |
| +// URL, so a 3xx response pointing anywhere else would defeat the host pin |
| +// outright — the far end could serve a redirect to any host it likes and |
| +// this client would have followed it there. http.ErrUseLastResponse is the |
| +// documented way to say "hand me the redirect response itself, don't chase |
| +// it" — the caller then sees a non-2xx status and refuses it the same way |
| +// it refuses any other failure. Precedent: internal/edge/prober.go's client |
| +// uses the identical idiom for the identical reason — a redirect is not the |
| +// thing that was asked for. |
| +var snsFetchClient = &http.Client{ |
| + CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return http.ErrUseLastResponse |
| + }, |
| +} |
| + |
| +// snsHostPattern is the FALLBACK shape check, used only when this |
| +// deployment's own region is unknown: "sns." plus a REGION-SHAPED middle |
| +// label — "<2-letter-country>[-gov]-<name>-<digit>", e.g. "eu-west-1", |
| +// "us-gov-west-1", "ap-southeast-4" — plus ".amazonaws.com", and nothing |
| +// looser than that. A bare service name like "s3" or "s3-us-west-2" does |
| +// not have this shape, which is what closes the C1-residual bypass |
| +// described below. |
| +var snsHostPattern = regexp.MustCompile(`^sns\.[a-z]{2}(-gov)?-[a-z]+-\d+\.amazonaws\.com$`) |
| |
| // validSigningCertURL refuses anything that is not an https URL on an SNS |
| -// regional host. It is also reused, unchanged, to validate SubscribeURL |
| -// before that URL is ever fetched (IMPORTANT-1 fix): a verified signature |
| -// only proves AWS put some string in that field, not that the string points |
| -// somewhere safe to GET, and a real SubscribeURL is itself always an SNS |
| -// regional URL, so the same pattern is the right check for both. |
| +// host — and, when region is non-empty, anything that is not THIS |
| +// deployment's own SNS regional host exactly. It is also reused, unchanged, |
| +// to validate SubscribeURL before that URL is ever fetched (round-2 |
| +// IMPORTANT-1 fix): a verified signature only proves AWS put some string in |
| +// that field, not that the string points somewhere safe to GET, and a real |
| +// SubscribeURL is itself always an SNS regional URL, so the same check is |
| +// the right one for both. |
| +// |
| +// C1-RESIDUAL FIX (round 2 review): the previous pattern, |
| +// ^sns\.[a-z0-9-]+\.amazonaws\.com$, pins the FIRST label ("sns.") but |
| +// leaves the SECOND label completely unconstrained — which is the exact |
| +// shape of the documented sns-validator bypass: |
| +// "https://sns.s3.amazonaws.com/x.pem" matches it, and "sns" is a real, |
| +// registered AWS bucket name (the review confirmed it returns 403 |
| +// AccessDenied — owned by someone, reachable, and NOT SNS). When this |
| +// deployment knows its own region (MailDeployment.Region, threaded in by |
| +// the caller), the host must equal "sns.<region>.amazonaws.com" EXACTLY — |
| +// no pattern, no label left free to be a bucket name. Only when the region |
| +// is unknown does this fall back to snsRegionPattern, which is at least |
| +// narrow enough to refuse a customer-chosen name: it requires the middle |
| +// label to have the SHAPE of an actual AWS region code, which "s3" and |
| +// "s3-us-west-2" do not. |
| // |
| -// The host check is LABEL-AWARE. A bare strings.HasSuffix(host, |
| -// "amazonaws.com") would accept "evilamazonaws.com" — it genuinely ends |
| -// with the string "amazonaws.com", with no dot separating the two, so it is |
| -// a completely different registered domain that merely happens to end the |
| -// right way. Anchoring the pattern on both ends (and requiring the "sns." |
| -// label specifically) is what tells a real AWS subdomain apart from that |
| -// lookalike, and from names like "sns.eu-west-1.amazonaws.com.evil.com" |
| -// that merely CONTAIN the right substring in the middle. |
| -func validSigningCertURL(raw string) error { |
| +// The host check is also LABEL-AWARE against the suffix-lookalike class of |
| +// bug: a bare strings.HasSuffix(host, "amazonaws.com") would accept |
| +// "evilamazonaws.com" — it genuinely ends with the string "amazonaws.com", |
| +// with no dot separating the two, so it is a completely different |
| +// registered domain that merely happens to end the right way. Anchoring the |
| +// whole pattern (or the whole exact-match string) is what refuses that, and |
| +// also refuses names like "sns.eu-west-1.amazonaws.com.evil.com" that |
| +// merely CONTAIN the right substring in the middle. |
| +func validSigningCertURL(raw, region string) error { |
| u, err := url.Parse(raw) |
| if err != nil { |
| return fmt.Errorf("sns: unparseable SigningCertURL") |
| @@ -106,7 +162,13 @@ func validSigningCertURL(raw string) error { |
| return fmt.Errorf("sns: SigningCertURL is not https") |
| } |
| host := strings.ToLower(u.Hostname()) |
| - if !snsHostPattern.MatchString(host) { |
| + region = strings.ToLower(strings.TrimSpace(region)) |
| + switch { |
| + case region != "": |
| + if host != "sns."+region+".amazonaws.com" { |
| + return fmt.Errorf("sns: SigningCertURL host %q is not this deployment's SNS host (sns.%s.amazonaws.com)", host, region) |
| + } |
| + case !snsHostPattern.MatchString(host): |
| return fmt.Errorf("sns: SigningCertURL host %q is not an SNS host", host) |
| } |
| // A traversal in the path can walk off the certificate route on a host |
| @@ -184,10 +246,11 @@ func (e snsEnvelope) canonicalString() string { |
| // GET in dispatch) — otherwise a stranger's POST decides who this |
| // console makes outbound requests to, which is SSRF wearing an |
| // AWS-shaped alibi. |
| -// 3. Validate SigningCertURL's shape — https, an SNS regional host, |
| -// label-aware, no path traversal — before it is ever fetched. |
| +// 3. Validate SigningCertURL's shape — https, an SNS host (this |
| +// deployment's own region exactly, when known), no path traversal — |
| +// before it is ever fetched. |
| // 4. Verify the signature over SNS's canonical string, which includes |
| -// fetching the certificate and checking ITS chain and hostname too. |
| +// fetching the certificate and checking ITS chain and subject too. |
| // Nothing past this point trusts a single field of the message, |
| // including SubscribeURL, until this has succeeded. |
| // 5. Dispatch: confirm a subscription (after running its SubscribeURL |
| @@ -226,7 +289,8 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) { |
| } |
| |
| // 3. Validate SigningCertURL before it is ever fetched. |
| - if err := validSigningCertURL(env.SigningCertURL); err != nil { |
| + region := s.cfg.MailDeployment.Region |
| + if err := validSigningCertURL(env.SigningCertURL, region); err != nil { |
| jsonError(w, http.StatusBadRequest, err.Error()) |
| return |
| } |
| @@ -261,7 +325,7 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) { |
| // where that string points — so without this an authentic message |
| // can still carry a SubscribeURL aimed at an arbitrary host, |
| // including link-local metadata endpoints. |
| - if err := validSigningCertURL(env.SubscribeURL); err != nil { |
| + if err := validSigningCertURL(env.SubscribeURL, region); err != nil { |
| log.Printf("console: ses events: refusing SubscribeURL: %v", err) |
| jsonError(w, http.StatusBadRequest, "invalid SubscribeURL") |
| return |
| @@ -313,13 +377,15 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) { |
| // SubscribeURL has passed validSigningCertURL — the signature proves AWS |
| // put the string there, the host check proves the string points at SNS |
| // itself rather than somewhere this console should never fetch on a |
| -// stranger's behalf. |
| +// stranger's behalf. It uses snsFetchClient (never http.DefaultClient) so a |
| +// redirect from that already-verified host cannot hand control to a |
| +// DIFFERENT, unverified one — see snsFetchClient's doc comment. |
| func (s *Server) confirmSNSSubscription(ctx context.Context, subscribeURL string) error { |
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, subscribeURL, nil) |
| if err != nil { |
| return fmt.Errorf("sns: building subscribe request: %w", err) |
| } |
| - resp, err := http.DefaultClient.Do(req) |
| + resp, err := snsFetchClient.Do(req) |
| if err != nil { |
| return fmt.Errorf("sns: fetching SubscribeURL: %w", err) |
| } |
| @@ -364,8 +430,8 @@ func (c *snsCertCache) put(certURL string, pub *rsa.PublicKey) { |
| } |
| |
| // verifiedCertKey returns the RSA public key for a signing certificate URL |
| -// — already pinned to an SNS regional host by the caller (validSigningCertURL) |
| -// — using the cache on a hit, and fetching plus fully verifying the |
| +// — already pinned to an SNS host by the caller (validSigningCertURL) — |
| +// using the cache on a hit, and fetching plus fully verifying the |
| // certificate on a miss. |
| func (s *Server) verifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, error) { |
| if pub, ok := s.snsCerts.get(certURL); ok { |
| @@ -379,39 +445,90 @@ func (s *Server) verifiedCertKey(ctx context.Context, certURL string) (*rsa.Publ |
| return pub, nil |
| } |
| |
| -// fetchVerifiedCertKey downloads the certificate at certURL and returns its |
| -// RSA public key, but only once the certificate has passed BOTH of two |
| -// independent checks (CRITICAL-2 fix) — neither is sufficient alone: |
| +// snsCertDNSName is the hostname a genuine SNS message-signing certificate |
| +// actually carries in its subject/SAN — NOT the regional fetch host (e.g. |
| +// sns.eu-west-1.amazonaws.com). SNS mints one signing identity for the |
| +// whole service, not one per region. Verified against a genuine SNS |
| +// signing certificate the round-2 review pulled from AWS's own SDK test |
| +// fixture (aws-sdk-ruby, gems/aws-sdk-sns/spec/message_verifier_spec.rb): |
| +// subject=CN=sns.amazonaws.com, SAN=[sns.amazonaws.com], |
| +// issuer=CN=Amazon,OU=Server CA 1B,O=Amazon,C=US. That fixture is old and |
| +// is not treated as sufficient evidence that today's live certificate is |
| +// identical — see sesevents.go's package comment and the task report for |
| +// what remains unproven until a real message arrives. |
| +const snsCertDNSName = "sns.amazonaws.com" |
| + |
| +// decodeCertChain splits a PEM-encoded response body into its leaf |
| +// certificate (the first CERTIFICATE block) and every subsequent |
| +// CERTIFICATE block as an intermediates pool. |
| // |
| -// - Its chain verifies against the system root pool. Without this, a |
| -// certificate that merely PARSES is trusted — a self-signed certificate |
| -// generated on an attacker's laptop, or any other certificate not |
| -// actually issued by a CA anyone trusts, would verify a forged |
| -// signature. |
| -// - It is valid for the EXACT hostname certURL named. Without this, a |
| -// validly-issued certificate for a completely different (but still |
| +// ROUND-3 FIX: the previous version decoded only the FIRST PEM block and |
| +// discarded anything after it. A genuine Amazon leaf certificate does not |
| +// chain to a system root on its own — proven live, during the round-2 |
| +// review, against the actual TLS leaf of sns.eu-west-1.amazonaws.com, which |
| +// fails chain verification until its intermediate ("Amazon RSA 2048 M01") |
| +// is supplied — so a signing-cert response that legitimately bundles an |
| +// intermediate was being silently truncated to just the part that can never |
| +// verify alone. This is what fixes-closed-on-every-real-message meant in |
| +// the round-3 review. |
| +func decodeCertChain(certPEM []byte) (leaf *x509.Certificate, intermediates *x509.CertPool, err error) { |
| + intermediates = x509.NewCertPool() |
| + rest := certPEM |
| + for { |
| + var block *pem.Block |
| + block, rest = pem.Decode(rest) |
| + if block == nil { |
| + break |
| + } |
| + if block.Type != "CERTIFICATE" { |
| + continue |
| + } |
| + c, perr := x509.ParseCertificate(block.Bytes) |
| + if perr != nil { |
| + return nil, nil, fmt.Errorf("sns: parsing signing cert: %w", perr) |
| + } |
| + if leaf == nil { |
| + leaf = c |
| + } else { |
| + intermediates.AddCert(c) |
| + } |
| + } |
| + if leaf == nil { |
| + return nil, nil, fmt.Errorf("sns: signing cert response contained no PEM certificate") |
| + } |
| + return leaf, intermediates, nil |
| +} |
| + |
| +// fetchVerifiedCertKey downloads the certificate (and any bundled |
| +// intermediates) at certURL and returns its RSA public key, but only once |
| +// it has passed BOTH of two independent checks (CRITICAL-2 fix) — neither |
| +// is sufficient alone: |
| +// |
| +// - Its chain verifies against the system root pool, using any |
| +// intermediate certificates the response bundled alongside the leaf. |
| +// Without this, a certificate that merely PARSES is trusted — a |
| +// self-signed certificate generated on an attacker's laptop, or any |
| +// other certificate not actually issued by a CA anyone trusts, would |
| +// verify a forged signature. |
| +// - It is valid for snsCertDNSName — the fixed subject every genuine SNS |
| +// signing certificate carries, NOT the regional host certURL was |
| +// fetched from (that was round 2's C2 fix, and it was wrong: SNS names |
| +// one identity for the whole service). Without this, a validly-issued |
| +// certificate for a completely different (but still |
| // amazonaws.com-hosted) service could be swapped in for SNS's own — |
| // chain verification alone says "some CA vouches for this cert", not |
| // "this cert speaks for SNS". |
| // |
| -// certURL is assumed already checked by validSigningCertURL — this function |
| -// re-derives the hostname from it rather than trusting a caller-supplied |
| -// one, so there is exactly one source of truth for "which host this |
| -// certificate must be valid for". |
| +// Uses snsFetchClient, never http.DefaultClient, so this fetch cannot be |
| +// redirected to an unpinned host either. |
| func fetchVerifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, error) { |
| - u, err := url.Parse(certURL) |
| - if err != nil { |
| - return nil, fmt.Errorf("sns: unparseable SigningCertURL: %w", err) |
| - } |
| - hostname := u.Hostname() |
| - |
| fctx, cancel := context.WithTimeout(ctx, sesFetchTimeout) |
| defer cancel() |
| req, err := http.NewRequestWithContext(fctx, http.MethodGet, certURL, nil) |
| if err != nil { |
| return nil, fmt.Errorf("sns: building cert request: %w", err) |
| } |
| - resp, err := http.DefaultClient.Do(req) |
| + resp, err := snsFetchClient.Do(req) |
| if err != nil { |
| return nil, fmt.Errorf("sns: fetching signing cert: %w", err) |
| } |
| @@ -423,21 +540,17 @@ func fetchVerifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, |
| if err != nil { |
| return nil, fmt.Errorf("sns: reading signing cert: %w", err) |
| } |
| - block, _ := pem.Decode(certPEM) |
| - if block == nil { |
| - return nil, fmt.Errorf("sns: signing cert is not PEM") |
| - } |
| - cert, err := x509.ParseCertificate(block.Bytes) |
| + leaf, intermediates, err := decodeCertChain(certPEM) |
| if err != nil { |
| - return nil, fmt.Errorf("sns: parsing signing cert: %w", err) |
| + return nil, err |
| } |
| - // Chain (system roots) and hostname, in one call: nil Roots defaults to |
| - // the system pool, and DNSName additionally requires the cert to be |
| - // valid for this exact host. |
| - if _, err := cert.Verify(x509.VerifyOptions{DNSName: hostname}); err != nil { |
| - return nil, fmt.Errorf("sns: signing cert failed chain/hostname verification: %w", err) |
| + // Chain (system roots, plus any bundled intermediates) and subject, in |
| + // one call: nil Roots defaults to the system pool, and DNSName |
| + // additionally requires the leaf to be valid for SNS's own identity. |
| + if _, err := leaf.Verify(x509.VerifyOptions{DNSName: snsCertDNSName, Intermediates: intermediates}); err != nil { |
| + return nil, fmt.Errorf("sns: signing cert failed chain/subject verification: %w", err) |
| } |
| - pub, ok := cert.PublicKey.(*rsa.PublicKey) |
| + pub, ok := leaf.PublicKey.(*rsa.PublicKey) |
| if !ok { |
| return nil, fmt.Errorf("sns: signing cert public key is not RSA") |
| } |