carlos / platform Public

console: SES ingest review fixes — pin the SNS host, verify the cert chain

Review found the SigningCertURL/SubscribeURL allowlist accepted any
amazonaws.com host — evilamazonaws.com is the actual bare-HasSuffix
lookalike (not the sns.eu-west-1.amazonaws.com.evil.com string the original
brief named, which a bare HasSuffix already rejects) — including S3 buckets
and API Gateway stages any AWS customer can stand up. validSigningCertURL
now pins to sns.<region>.amazonaws.com specifically, and fetchVerifiedCertKey
separately verifies the downloaded certificate's chain against system roots
and its hostname: neither check alone is sufficient, both are now required.

SubscribeURL gets the same pinned-host check before it is ever fetched —
otherwise a verified signature only proves AWS put a string there, not that
the string is safe to GET, and it was reachable as a blind SSRF to
link-local metadata. Verified certificate keys are cached by URL so a known
TopicArn can no longer trigger a fresh outbound fetch and chain verification
per request. The console's listener now sets ReadHeaderTimeout and
ReadTimeout (cmd/console/main.go), since this task added the console's
first unauthenticated body-reading route and a slow-body connection held a
goroutine open indefinitely.

Three of the five original mutation-tested assertions turned out to be
vacuous: the brief's foreign-topic, unknown-type and oversize-body tests
were each already refused earlier for an unrelated reason, asserting
nothing about the check they named. Isolating tests replace them. An
offline self-signed certificate fixture now exercises the chain-verification
fix and the SHA-1/SHA-256 signature branches directly, with no network.
Paul Campbell pushed by paul@keymail.dev 7470c1fc21e33cd86335d1ecb5f84698b8cd16ea parent c3f2fd4
4 files changed, +433 −69
  • cmd/console/main.go +22 −1
  • internal/console/server.go +5 −0
  • internal/console/sesevents.go +205 −54
  • internal/console/sesevents_test.go +201 −14
diff --git a/cmd/console/main.go b/cmd/console/main.go
index e10202bca..75131c327 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -61,6 +61,7 @@ import (
"os"
"sort"
"strings"
+ "time"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
@@ -273,7 +274,27 @@ func serve() {
go srv.StartEconomicsPass(context.Background())
addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090")
log.Printf("console: %s listening on %s", origin, addr)
- log.Fatal(http.ListenAndServe(addr, srv))
+ // ReadHeaderTimeout guards against Slowloris-style slow-header connection
+ // exhaustion — same value and reasoning as internal/edge's public
+ // listeners (edge.go). This console holds no long-lived proxied
+ // connections (no WebSockets), unlike the edge, so unlike the edge it
+ // also sets ReadTimeout: a body-size cap (sesEventMaxBody,
+ // apiShipMaxBytes, …) bounds BYTES, not TIME, and a connection trickling
+ // one byte at a time never trips it. Task 15 added the console's first
+ // unauthenticated body-reading route (sesevents.go) — the SES ingest
+ // endpoint has no login to slow an attacker down before they reach it —
+ // which is what makes a slow-body hold worth closing off here rather
+ // than leaving it as a theoretical gap. 5 minutes is generous enough for
+ // the largest `carlos ship` upload (apiShipMaxBytes, 256 MiB) over a
+ // genuinely slow connection, while still bounding a connection that
+ // would otherwise sit open forever.
+ httpSrv := &http.Server{
+ Addr: addr,
+ Handler: srv,
+ ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: 5 * time.Minute,
+ }
+ log.Fatal(httpSrv.ListenAndServe())
}
func openConsoleDB() *console.Store {
diff --git a/internal/console/server.go b/internal/console/server.go
index 3f48519cb..1299d112f 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -611,6 +611,11 @@ type Server struct {
// ONLY: it never substitutes for the TopicArn or SigningCertURL checks,
// which every caller — production and test alike — runs for real.
snsVerify func(snsEnvelope) error
+ // snsCerts caches a verified signing certificate's RSA public key by URL
+ // (sesevents.go) — otherwise every request bearing the deployment's
+ // TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch
+ // and full chain verification. Zero value is ready to use.
+ snsCerts snsCertCache
}
// normalizeDelegationZone lowercases, trims, and shape-validates raw (from
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go
index ef9b56ec0..74653fbd8 100644
--- a/internal/console/sesevents.go
+++ b/internal/console/sesevents.go
@@ -16,6 +16,16 @@
// 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.
package console
import (
@@ -34,7 +44,9 @@ import (
"log"
"net/http"
"net/url"
+ "regexp"
"strings"
+ "sync"
"time"
"github.com/carlosframework/platform/internal/mail"
@@ -45,9 +57,10 @@ import (
// unauthenticated endpoint is memory exhaustion anyone can trigger.
const sesEventMaxBody = 256 << 10
-// sesCertMaxBody bounds the signing certificate this door fetches. AWS's
-// SNS certificates are a few KB; anything vastly larger than that is not a
-// certificate this code needs to finish reading.
+// 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.
const sesCertMaxBody = 64 << 10
// sesFetchTimeout bounds both outbound calls this door makes — fetching the
@@ -56,18 +69,34 @@ const sesCertMaxBody = 64 << 10
// far end costs one request this long, not a goroutine forever.
const sesFetchTimeout = 10 * time.Second
-// snsCertHost is the only host family a signing certificate may come from.
-const snsCertHost = "amazonaws.com"
+// 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$`)
-// validSigningCertURL refuses anything that is not an https URL on an
-// amazonaws.com host.
+// 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.
//
-// The suffix check is LABEL-AWARE. A bare strings.HasSuffix would accept
-// "evilamazonaws.com" — it genuinely ends with the string "amazonaws.com",
-// with no dot separating the two, so it is a completely different
-// registered domain that merely happens to end the right way. Requiring
-// either an exact match or a "."-prefixed suffix is what tells a real AWS
-// subdomain apart from that lookalike.
+// 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 {
u, err := url.Parse(raw)
if err != nil {
@@ -77,8 +106,8 @@ func validSigningCertURL(raw string) error {
return fmt.Errorf("sns: SigningCertURL is not https")
}
host := strings.ToLower(u.Hostname())
- if host != snsCertHost && !strings.HasSuffix(host, "."+snsCertHost) {
- return fmt.Errorf("sns: SigningCertURL host %q is not an AWS host", host)
+ if !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
// that serves more than certificates.
@@ -155,16 +184,18 @@ 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 amazonaws.com host,
+// 3. Validate SigningCertURL's shape — https, an SNS regional host,
// label-aware, no path traversal — before it is ever fetched.
-// 4. Verify the signature over SNS's canonical string. Nothing past this
-// point trusts a single field of the message, including SubscribeURL,
-// until this has succeeded.
-// 5. Dispatch: confirm a subscription, or parse a notification. Anything
-// this deployment deliberately does not act on still answers 200 —
-// SNS retries non-2xx, so refusing an unknown case with a failure
-// status would turn one unrecognised field into an unbounded retry
-// flood against the console.
+// 4. Verify the signature over SNS's canonical string, which includes
+// fetching the certificate and checking ITS chain and hostname 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
+// through the SAME host check as step 3), or parse a notification.
+// Anything this deployment deliberately does not act on still answers
+// 200 — SNS retries non-2xx, so refusing an unknown case with a
+// failure status would turn one unrecognised field into an unbounded
+// retry flood against the console.
func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
// 1. Bound the body first.
r.Body = http.MaxBytesReader(w, r.Body, sesEventMaxBody)
@@ -205,9 +236,15 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
// verification" (verifySNSSignature, which fetches the certificate over
// the network). The seam is signature verification ONLY: both checks
// above already ran unconditionally, for real, regardless of the seam.
+ //
+ // The real path is wrapped in a closure so it can carry the request's
+ // own context (matching confirmSNSSubscription below) rather than
+ // context.Background() — without changing the seam's own type, which
+ // stays exactly func(snsEnvelope) error so a test's assignment to
+ // s.snsVerify is unaffected.
verify := s.snsVerify
if verify == nil {
- verify = s.verifySNSSignature
+ verify = func(e snsEnvelope) error { return s.verifySNSSignature(r.Context(), e) }
}
if err := verify(env); err != nil {
log.Printf("console: ses events: signature verification failed: %v", err)
@@ -218,8 +255,27 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
// 5. Dispatch.
switch env.Type {
case "SubscriptionConfirmation", "UnsubscribeConfirmation":
- // Handled, not ignored: without confirming, SNS never delivers
- // another event to this endpoint.
+ // IMPORTANT-1 fix: SubscribeURL gets the SAME pinned-host check as
+ // SigningCertURL before it is fetched. A verified signature only
+ // proves AWS put some string in this field — it says nothing about
+ // 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 {
+ log.Printf("console: ses events: refusing SubscribeURL: %v", err)
+ jsonError(w, http.StatusBadRequest, "invalid SubscribeURL")
+ return
+ }
+ // UnsubscribeConfirmation's SubscribeURL is, by SNS's own naming,
+ // actually a RE-subscribe link: fetching it here means an operator
+ // who unsubscribes this topic from the SNS console finds the
+ // endpoint re-subscribed within seconds, the next time SNS sends an
+ // UnsubscribeConfirmation. Defensible — this deployment always
+ // wants to be subscribed to its own mail-events topic, so an
+ // unsubscribe is far more likely to be SNS housekeeping or an
+ // operator mistake than an intentional retirement — but it is not
+ // what "Unsubscribe" reads as, so it is named here rather than left
+ // to be discovered.
ctx, cancel := context.WithTimeout(r.Context(), sesFetchTimeout)
defer cancel()
if err := s.confirmSNSSubscription(ctx, env.SubscribeURL); err != nil {
@@ -252,11 +308,12 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) {
}
}
-// confirmSNSSubscription GETs the verified SubscribeURL. It is reached only
-// after the enclosing message's signature has verified, which is what makes
-// trusting this URL safe: SubscribeURL is itself one of the fields the
-// signature covers, so an authentic signature over it means AWS is the one
-// who put it there.
+// confirmSNSSubscription GETs the verified, host-checked SubscribeURL. It is
+// reached only after the enclosing message's signature has verified AND
+// 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.
func (s *Server) confirmSNSSubscription(ctx context.Context, subscribeURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, subscribeURL, nil)
if err != nil {
@@ -274,52 +331,146 @@ func (s *Server) confirmSNSSubscription(ctx context.Context, subscribeURL string
return nil
}
-// verifySNSSignature is the real (non-test-seam) verification path: fetch
-// the certificate SigningCertURL names — already validated as an
-// amazonaws.com https URL by the caller — parse its RSA public key, and
-// check the signature over the message's own canonical string.
+// snsCertCache caches a signing certificate's verified RSA public key,
+// keyed on its (already pinned-host-validated) URL — IMPORTANT-2 fix.
+// Without it, every request bearing the deployment's TopicArn — which is
+// not a secret, it appears in IaC and logs — triggers a fresh outbound
+// HTTPS fetch and a full certificate-chain verification: unauthenticated
+// outbound amplification, one connection out per request in. The AWS SDKs
+// cache verification certs by URL for the same reason.
//
-// SNS signs with SHA1withRSA under SignatureVersion "1" (the historical
-// default, and what SNS still sends unless a topic opts into version "2",
-// SHA256withRSA). Both are verified here; an unrecognised version is
-// refused rather than guessed at.
-func (s *Server) verifySNSSignature(env snsEnvelope) error {
- ctx, cancel := context.WithTimeout(context.Background(), sesFetchTimeout)
+// A value type with a nil-safe zero value on purpose: Server embeds one
+// directly (no constructor call needed), the same way ceClientOnce
+// (server.go) needs no init.
+type snsCertCache struct {
+ mu sync.Mutex
+ keys map[string]*rsa.PublicKey
+}
+
+func (c *snsCertCache) get(certURL string) (*rsa.PublicKey, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ pub, ok := c.keys[certURL]
+ return pub, ok
+}
+
+func (c *snsCertCache) put(certURL string, pub *rsa.PublicKey) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.keys == nil {
+ c.keys = map[string]*rsa.PublicKey{}
+ }
+ c.keys[certURL] = pub
+}
+
+// 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
+// certificate on a miss.
+func (s *Server) verifiedCertKey(ctx context.Context, certURL string) (*rsa.PublicKey, error) {
+ if pub, ok := s.snsCerts.get(certURL); ok {
+ return pub, nil
+ }
+ pub, err := fetchVerifiedCertKey(ctx, certURL)
+ if err != nil {
+ return nil, err
+ }
+ s.snsCerts.put(certURL, pub)
+ 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:
+//
+// - 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
+// 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".
+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(ctx, http.MethodGet, env.SigningCertURL, nil)
+ req, err := http.NewRequestWithContext(fctx, http.MethodGet, certURL, nil)
if err != nil {
- return fmt.Errorf("sns: building cert request: %w", err)
+ return nil, fmt.Errorf("sns: building cert request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
- return fmt.Errorf("sns: fetching signing cert: %w", err)
+ return nil, fmt.Errorf("sns: fetching signing cert: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("sns: signing cert fetch status %d", resp.StatusCode)
+ return nil, fmt.Errorf("sns: signing cert fetch status %d", resp.StatusCode)
}
certPEM, err := io.ReadAll(io.LimitReader(resp.Body, sesCertMaxBody))
if err != nil {
- return fmt.Errorf("sns: reading signing cert: %w", err)
+ return nil, fmt.Errorf("sns: reading signing cert: %w", err)
}
block, _ := pem.Decode(certPEM)
if block == nil {
- return fmt.Errorf("sns: signing cert is not PEM")
+ return nil, fmt.Errorf("sns: signing cert is not PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
- return fmt.Errorf("sns: parsing signing cert: %w", err)
+ return nil, fmt.Errorf("sns: parsing signing cert: %w", 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)
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
- return fmt.Errorf("sns: signing cert public key is not RSA")
+ return nil, fmt.Errorf("sns: signing cert public key is not RSA")
+ }
+ return pub, nil
+}
+
+// verifySNSSignature is the real (non-test-seam) verification path: resolve
+// the certificate's verified public key (cached; fetched and chain-checked
+// on a miss) and check the signature over the message's own canonical
+// string.
+func (s *Server) verifySNSSignature(ctx context.Context, env snsEnvelope) error {
+ pub, err := s.verifiedCertKey(ctx, env.SigningCertURL)
+ if err != nil {
+ return err
}
- sig, err := base64.StdEncoding.DecodeString(env.Signature)
+ return verifySNSSignatureBytes(pub, []byte(env.canonicalString()), env.SignatureVersion, env.Signature)
+}
+
+// verifySNSSignatureBytes checks a base64 signature against canon using pub,
+// per SignatureVersion. Split out from verifySNSSignature so this — the
+// actual cryptographic check — can be tested with a plain in-memory RSA
+// keypair: no network, no certificate, no trust store involved.
+//
+// SNS signs with SHA1withRSA under SignatureVersion "1" (the historical
+// default, and what SNS still sends unless a topic opts into version "2",
+// SHA256withRSA). Both are verified here; an unrecognised version is
+// refused rather than guessed at.
+func verifySNSSignatureBytes(pub *rsa.PublicKey, canon []byte, sigVersion, sigB64 string) error {
+ sig, err := base64.StdEncoding.DecodeString(sigB64)
if err != nil {
return fmt.Errorf("sns: signature is not base64: %w", err)
}
- canon := []byte(env.canonicalString())
- switch env.SignatureVersion {
+ switch sigVersion {
case "", "1":
sum := sha1.Sum(canon)
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA1, sum[:], sig); err != nil {
@@ -331,7 +482,7 @@ func (s *Server) verifySNSSignature(env snsEnvelope) error {
return fmt.Errorf("sns: signature verification failed: %w", err)
}
default:
- return fmt.Errorf("sns: unsupported SignatureVersion %q", env.SignatureVersion)
+ return fmt.Errorf("sns: unsupported SignatureVersion %q", sigVersion)
}
return nil
}
diff --git a/internal/console/sesevents_test.go b/internal/console/sesevents_test.go
index e98a49ca8..8ef5f8501 100644
--- a/internal/console/sesevents_test.go
+++ b/internal/console/sesevents_test.go
@@ -4,15 +4,28 @@ package console
// complaint notifications here. It is unauthenticated — SNS is not a signed-
// in member — so what stands between a forged bounce and an automatic pause
// on a stranger's say-so is exactly what these tests pin: the TopicArn
-// allowlist, the SigningCertURL host check, and the ordering that runs both
-// before any network call.
+// allowlist, the SigningCertURL/SubscribeURL host check, certificate chain
+// verification, and the ordering that runs all of it before any network call
+// or trusted field.
import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/sha256"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/base64"
+ "encoding/pem"
"errors"
+ "math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
+ "time"
)
// errFakeSignature stands in for a real crypto failure in the seam tests —
@@ -38,6 +51,12 @@ func newConfirmServer(t *testing.T, confirmed chan struct{}) *httptest.Server {
// attacker can point it at a host they control, they sign their own message
// with their own certificate and it verifies — which means forging the
// bounce events that trigger an automatic pause, on any tenant.
+//
+// The host pattern is pinned to SNS's own regional endpoint shape
+// ("sns.<region>.amazonaws.com"), not the wider amazonaws.com family: that
+// wider family is every AWS customer's identity, not Amazon's — an S3
+// bucket or an API Gateway stage can serve a genuine, publicly-trusted
+// certificate from a host that satisfies a bare "ends in amazonaws.com".
func TestSigningCertURLMustBeAmazon(t *testing.T) {
ok := []string{
"https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-abc.pem",
@@ -57,6 +76,13 @@ func TestSigningCertURLMustBeAmazon(t *testing.T) {
{"https://sns.eu-west-1.amazonaws.com/../x", "path traversal"},
{"", "empty"},
{"not a url", "unparseable"},
+ // A bare "*.amazonaws.com" allowlist accepts these: they are real,
+ // currently-issuable AWS hosts belonging to OTHER services, not SNS.
+ // Any AWS customer — free tier, ten minutes — can stand one up and
+ // get a genuine, publicly-trusted certificate for it.
+ {"https://evil-bucket.s3.amazonaws.com/x.pem", "S3 bucket, not SNS"},
+ {"https://evil-bucket.s3.eu-west-1.amazonaws.com/attacker.pem", "regional S3 bucket, not SNS"},
+ {"https://xyz.execute-api.eu-west-1.amazonaws.com/p/x.pem", "API Gateway stage, not SNS"},
}
for _, c := range bad {
if err := validSigningCertURL(c.url); err == nil {
@@ -180,24 +206,41 @@ func TestSESEventsRefusesAnOversizeBodyEvenWhenOtherwiseValid(t *testing.T) {
}
}
-// A SubscriptionConfirmation is handled, not ignored: without confirming,
-// SNS never delivers another event to this endpoint. The test server stands
-// in for AWS at the far end of SubscribeURL.
-func TestSESEventsConfirmsSubscription(t *testing.T) {
+// A verified signature only proves AWS put a string in SubscribeURL — not
+// that the string points somewhere safe to GET. Without a host check on it
+// this door is a blind SSRF dispatcher: an authentic message can carry a
+// SubscribeURL aimed at anything reachable from the console, including
+// link-local instance-metadata endpoints. This must be refused before any
+// fetch of SubscribeURL is attempted, which is also why no confirm server is
+// stood up here — one being reached at all would be the failure.
+func TestSESEventsRefusesASubscribeURLOffAWS(t *testing.T) {
fx := newConsoleFixture(t)
fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
-
- confirmed := make(chan struct{}, 1)
- confirmSrv := newConfirmServer(t, confirmed)
- defer confirmSrv.Close()
-
resp := sesPost(t, fx, `{"Type":"SubscriptionConfirmation","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+
- `"Message":"You have chosen to subscribe...","SubscribeURL":"`+confirmSrv.URL+`",`+
+ `"Message":"You have chosen to subscribe...",`+
+ `"SubscribeURL":"http://169.254.169.254/latest/meta-data/iam/security-credentials/",`+
`"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`)
defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("status = %d, want 200", resp.StatusCode)
+ if resp.StatusCode == http.StatusOK {
+ t.Fatal("a SubscribeURL off an SNS host was accepted")
+ }
+}
+
+// The mechanical half of subscription handling: confirmSNSSubscription
+// itself — reached only after both the signature and the SubscribeURL host
+// check have passed — must actually GET the URL. A SubscriptionConfirmation
+// silently dropped means SNS never delivers another event to this endpoint.
+// Tested directly (below the host-check layer) because a real SubscribeURL
+// is always an sns.<region>.amazonaws.com URL, which nothing in this test
+// suite can stand a fake server up as.
+func TestConfirmSNSSubscriptionFetchesTheURL(t *testing.T) {
+ fx := newConsoleFixture(t)
+ confirmed := make(chan struct{}, 1)
+ ts := newConfirmServer(t, confirmed)
+ defer ts.Close()
+ if err := fx.srv.confirmSNSSubscription(context.Background(), ts.URL); err != nil {
+ t.Fatalf("confirmSNSSubscription: %v", err)
}
select {
case <-confirmed:
@@ -220,3 +263,147 @@ func TestSESEventsRefusesABadSignature(t *testing.T) {
t.Fatal("a message that failed signature verification was accepted")
}
}
+
+// ---- verifySNSSignatureBytes: the pure cryptographic check --------------
+//
+// Split out from verifySNSSignature specifically so the SHA-1/SHA-256
+// branches can be exercised with a plain in-memory RSA keypair — no
+// network, no certificate, no trust store. This is the piece the
+// snsVerify seam always bypasses in the tests above; here it is tested
+// directly instead.
+
+func TestVerifySNSSignatureBytesAcceptsAValidSHA1Signature(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ canon := []byte("Message\nhi\nMessageId\n1\n")
+ sum := sha1.Sum(canon)
+ sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ sigB64 := base64.StdEncoding.EncodeToString(sig)
+ if err := verifySNSSignatureBytes(&key.PublicKey, canon, "1", sigB64); err != nil {
+ t.Fatalf("valid SHA1 signature refused: %v", err)
+ }
+ // SignatureVersion "" (unset) is SNS's historical default and must be
+ // treated identically to "1".
+ if err := verifySNSSignatureBytes(&key.PublicKey, canon, "", sigB64); err != nil {
+ t.Fatalf("valid SHA1 signature refused with unset SignatureVersion: %v", err)
+ }
+}
+
+func TestVerifySNSSignatureBytesAcceptsAValidSHA256Signature(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ canon := []byte("Message\nhi\nMessageId\n1\n")
+ sum := sha256.Sum256(canon)
+ sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ sigB64 := base64.StdEncoding.EncodeToString(sig)
+ if err := verifySNSSignatureBytes(&key.PublicKey, canon, "2", sigB64); err != nil {
+ t.Fatalf("valid SHA256 signature refused: %v", err)
+ }
+}
+
+func TestVerifySNSSignatureBytesRefusesATamperedMessage(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha1.Sum([]byte("Message\nhi\nMessageId\n1\n"))
+ sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ sigB64 := base64.StdEncoding.EncodeToString(sig)
+ if err := verifySNSSignatureBytes(&key.PublicKey, []byte("Message\nbye\nMessageId\n1\n"), "1", sigB64); err == nil {
+ t.Fatal("a signature over a different message verified")
+ }
+}
+
+func TestVerifySNSSignatureBytesRefusesAnUnknownVersion(t *testing.T) {
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sigB64 := base64.StdEncoding.EncodeToString([]byte("whatever"))
+ if err := verifySNSSignatureBytes(&key.PublicKey, []byte("x"), "3", sigB64); err == nil {
+ t.Fatal("an unsupported SignatureVersion was accepted")
+ }
+}
+
+// ---- fetchVerifiedCertKey: the certificate chain check -------------------
+
+// selfSignedTestCertPEM builds a certificate that is internally well-formed
+// — it parses, it has a matching DNSName — but is signed by nothing anyone
+// trusts: exactly what an attacker running `openssl req -x509` on their own
+// laptop produces.
+func selfSignedTestCertPEM(t *testing.T, cn string) []byte {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: cn},
+ DNSNames: []string{cn},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(time.Hour),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+ BasicConstraintsValid: true,
+ IsCA: true,
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+}
+
+// CRITICAL-2 regression test: a certificate that parses fine and even names
+// the right host must still be REFUSED if nothing trusted signed it. Without
+// this check, an attacker who can point SigningCertURL at their own
+// (host-pinned-lookalike-proof) server — or who can otherwise get bytes onto
+// a URL that passes the host check — could serve a self-signed certificate
+// and have it accepted as SNS's own.
+func TestFetchVerifiedCertKeyRefusesASelfSignedCert(t *testing.T) {
+ certPEM := selfSignedTestCertPEM(t, "sns.eu-west-1.amazonaws.com")
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Write(certPEM)
+ }))
+ defer ts.Close()
+
+ _, err := fetchVerifiedCertKey(context.Background(), ts.URL)
+ if err == nil {
+ t.Fatal("a self-signed certificate passed chain verification")
+ }
+}
+
+// The cache sits in front of fetchVerifiedCertKey (verifiedCertKey), so a
+// second call for the SAME already-cached URL must not re-fetch — proven
+// here by pointing the cache at a URL nothing is listening on: a cache miss
+// would fail with a connection error, a hit returns the seeded key.
+func TestVerifiedCertKeyServesFromCache(t *testing.T) {
+ fx := newConsoleFixture(t)
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const certURL = "https://sns.eu-west-1.amazonaws.com/nothing-listens-here.pem"
+ fx.srv.snsCerts.put(certURL, &key.PublicKey)
+
+ pub, err := fx.srv.verifiedCertKey(context.Background(), certURL)
+ if err != nil {
+ t.Fatalf("verifiedCertKey did not serve from cache: %v", err)
+ }
+ if pub.N.Cmp(key.PublicKey.N) != 0 {
+ t.Fatal("verifiedCertKey returned a different key than the one cached")
+ }
+}