Skip to content

fix(ses): verify AWS SNS signatures on the SES callback - #466

Open
polylane[bot] wants to merge 2 commits into
mainfrom
polylane/autofix/p5ssbljl4nge
Open

polylane[bot] wants to merge 2 commits into
mainfrom
polylane/autofix/p5ssbljl4nge

Conversation

@polylane

@polylane polylane Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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"]
Loading
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/-0
  • apps/web/src/app/api/ses_callback/route.ts: modified, +30/-12
  • apps/web/src/server/aws/sns-message-validator.ts: added, +177/-0
  • apps/web/src/server/aws/sns-message-validator.unit.test.ts: added, +194/-0
  • apps/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)

View thread View autofix


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.

Written for commit 6b8aab8. Summary will update on new commits.

View guided diff Turn on auto-fix

@polylane polylane Bot added polylane severity:high Polylane autofix severity: high labels Oct 15, 2026
@vercel

vercel Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
unsend-marketing Ready Ready Preview Oct 25, 2026 10:39pm UTC

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 21611908-e7ae-4987-9383-2ada9d3403b9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Deploying usesend with  Cloudflare Pages  Cloudflare Pages

Latest commit: 01cffe7
Status: ✅  Deploy successful!
Preview URL: https://6c05eb55.usesend.pages.dev
Branch Preview URL: https://polylane-autofix-p5ssbljl4ng.usesend.pages.dev

View logs

KMKoushik and others added 2 commits October 26, 2026 09:30
Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com>
…n rollout

- add genuine AWS-signed SNS/SES fixtures (SHA1, SHA256, with and without Subject)
- log every rejected SNS message with topic, message id and cert URL
- add SNS_SIGNATURE_VERIFICATION=log-only escape hatch (default enforce)
- reject signing cert URLs with query, port or credentials; bound the cert cache
- time out the signing cert fetch after 5s
@KMKoushik
KMKoushik force-pushed the polylane/autofix/p5ssbljl4nge branch from 6b8aab8 to 01cffe7 Compare October 25, 2026 22:38
@KMKoushik

Copy link
Copy Markdown
Member

Verification review

The canonical string is correct, and it accepts genuine AWS signatures. It builds key\nvalue\n for every field, including the final newline. The field order is Message, MessageId, Subject (only if present), Timestamp, TopicArn, Type for notifications, and Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, Type for (un)subscription confirmations. SignatureVersion 1 uses RSA-SHA1 and 2 uses RSA-SHA256. The #440 bug (.join("\n") with no trailing newline) is not present here.

The original tests were self-confirming: they signed with the same canonical builder they were testing. The new commit adds 6 real AWS-signed messages, all signed by AWS's CN=sns.amazonaws.com certificate (SHA-256 fingerprint D0:38:29:B4…):

  • 4 from AWS's own aws-php-sns-message-validator fixtures (also in aws-sdk-ruby): SHA1 and SHA256, each with and without Subject.
  • 2 real SES notifications from django-ses: an event-publishing Complaint with Subject: "Amazon SES Email Event Notification" (the shape our configuration sets produce) and a Bounce without a Subject.

All 6 verify. Reintroducing the #440 bug makes all 6 fail, so the tests now catch it. Tampering with any signed field, adding or removing Subject, or relabelling a SHA256 signature as v1 is rejected. Cert URLs that are non-https, on a lookalike or wrong-region host, not ending in .pem, or that carry a query, port or credentials are rejected without being fetched. A route test runs the real SES complaint end to end through POST (200 and queued) and a tampered copy (401).

Not covered by a real fixture: SubscriptionConfirmation. I couldn't find a published one. It uses the same code path with the documented field list, but please watch the first new SES setup after merge.

Changes in 01cffe7 (+47/−7 outside tests)

  • Every rejected message is now logged at error level, with topic, message id, type, signature version and cert URL. Before this, a plain signature mismatch returned 401 without logging anything, so a regression would have been silent.
  • SNS_SIGNATURE_VERIFICATION=log-only (default enforce) is an escape hatch. In log-only mode, invalid signatures are logged but accepted. The TopicArn check and the SubscribeURL allowlist are always enforced. This also helps self-hosters who run SES/SNS emulators with NODE_ENV=production.
  • Signing cert URLs with a query, hash, port or credentials are rejected. The cert cache is capped at 50 entries; before, forged requests with .pem?x=N URLs could grow it without bound.
  • The cert fetch times out after 5s.
  • Rebased onto main.

Tests: typecheck clean; unit 166/166, api 33/33, trpc 10/10.

Verdict: safe to merge. After deploy, check that Rejected SNS message with invalid signature doesn't appear in the logs, and that email statuses still move past SENT.

@polylane

polylane Bot commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

@KMKoushik I can help with this once you have access to the koushik's Workspace workspace. Already have a Polylane account? Link your GitHub account and I'll pick this up.

Connect GitHub

This branch was successfully deployed

1 active deployment
Preview — 01cffe75 Deployed Oct 25, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

polylane severity:high Polylane autofix severity: high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant