fix(ses): verify AWS SNS signatures on the SES callback
Fixes: useSend SES callback accepts forged SNS events without signature verification
The public SES notification callback trusted any request that named a configured SNS topic ARN, because it never checked the AWS SNS message signature. A forged request could mark email as bounced or complained, unsubscribe contacts that share a recipient address, skew usage counters, and make the server fetch an attacker-controlled URL. The callback now verifies the SNS signature before it processes a message and only follows subscription URLs on the AWS SNS host.
flowchart TD
A["POST /api/ses_callback"] --> B{"checkEventValidity"}
B -->|"TopicArn not configured"| R1["401 rejected"]
B -->|"TopicArn matches"| C{"verifySnsMessageSignature (new)"}
C -->|"invalid or untrusted cert URL"| R1
C -->|"valid signature"| D{"Type == SubscriptionConfirmation"}
D -->|"yes"| E{"isTrustedSnsUrl SubscribeURL (new)"}
E -->|"untrusted"| R2["400 rejected, no fetch"]
E -->|"trusted"| F["fetch SNS confirm URL, update setting"]
D -->|"no"| G["SesHookParser.queue"]
Detection evidence
Affected: int_13ee3c91c001c2c8xfdj06a9
Why this fix
The route is reachable in production and its only gate was a TopicArn membership check, which is an identifier rather than a secret. AWS requires an HTTPS SNS endpoint to verify the message signature before trusting the payload, and the unverified path reached the suppression and unsubscribe logic in ses-hook-parser.ts. The change adds signature verification and host allowlists at the callback entry point. The one remaining item is optional: getTopicArns still returns every configured ARN, so scoping it per team stays open.
Causal chain
- Signal (alert): useSend /api/ses_callback processes forged SNS events without signature verification
- Surfacing site: useSend web app (Railway: useSend) at apps/web/src/app/api/ses_callback/route.ts#POST
- Mechanism: The POST handler reads req.json() and calls checkEventValidity, which only compares message.TopicArn against the configured topic ARNs; the SNS signature is never verified, so a forged body that names a configured topic ARN passes the gate and reaches SesHookParser.queue.
- Producer: useSend web app ses_callback route, instance app.usesend.com, at apps/web/src/app/api/ses_callback/route.ts#checkEventValidity
- Blast radius: unknown other resource(s), unknown other tenant(s); data at risk: Email status, suppression lists, campaign analytics, contact subscriptions, and metering (dailyEmailUsage, cumulatedMetrics) for every tenant sharing the topic ARN set.
- Producer evidence:
- checkEventValidity only checks TopicArn membership and returns true; it never reads Signature/SigningCertURL/SignatureVersion (HEAD daaa435)
- GET https://app.usesend.com/api/ses_callback returns 200 {"data":"Hello"}, endpoint is live
- handleSubscription fetches message.SubscribeURL with no allowlist or redirect restriction
5 files changed (+559/-12)
apps/web/src/app/api/ses_callback/route.api.test.ts: added, +156/-0apps/web/src/app/api/ses_callback/route.ts: modified, +30/-12apps/web/src/server/aws/sns-message-validator.ts: added, +177/-0apps/web/src/server/aws/sns-message-validator.unit.test.ts: added, +194/-0apps/web/src/types/aws-types.ts: modified, +2/-0
Repository lint: pnpm run lint (declared in package.json) fails over the whole repository (the declared target takes no file arguments), so the failure is not attributed to this change and did not block it; run it before merging.
Lint output
: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
@usesend/ui:lint: $ eslint . --max-warnings 0
@usesend/lib:lint: $ eslint . --max-warnings 0
usesend-js:lint: cache miss, executing 2b48c7c6e12d1991
usesend-js:lint: [WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
usesend-js:lint: $ eslint . --max-warnings 0
usesend-js:lint: sh: 1: eslint: not found
usesend-js:lint: [ELIFECYCLE] Command failed.
@usesend/ui:lint: [ELIFECYCLE] Command failed.
Tasks: 1 successful, 3 total
Cached: 0 cached, 3 total
Time: 3.663s
Failed: usesend-js#lint
[ELIFECYCLE] Command failed with exit code 1.
[WARN] The "pnpm" field in package.json is no longer read by pnpm. The following keys were ignored: "pnpm.overrides". See https://pnpm.io/settings for the new home of each setting.
$ turbo lint
Attention:
Turborepo now collects completely anonymous telemetry regarding usage.
This information is used to shape the Turborepo roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://turborepo.dev/docs/telemetry
• turbo 2.10.4
usesend-js#lint: ERROR command (/workspace/usesend/useSend/packages/sdk) /root/.local/share/pnpm/.tools/pnpm/11.11.0/bin/pnpm run lint exited (1)
ERROR run failed: command exited (1)
Generated by Polylane. You can ask follow-ups by mentioning @polylane in a comment.
Summary by cubic
Verifies AWS SNS message signatures on the SES callback route so forged events are no longer trusted. Previously the route only checked that the TopicArn matched a configured value, so any request naming the right topic could trigger bounce/complaint handling, unsubscribe contacts, skew usage metrics, and fetch an attacker-controlled URL. The callback now verifies the SNS signature and only fetches SubscribeURL values pointing at the AWS SNS host for the message's region. Verification is still skipped in development.