Skip to content

SNS signature validation rejects every authentic SES notification (missing trailing newline in canonical string) #440

Description

@gabrieldasf

Self-hosted useSend accepts and sends the mail, but Email.latestStatus never advances past SENT. Delivery, bounce and complaint events are all silently dropped.

Symptom

SES publishes the events, SNS delivers them to /api/ses_callback, and the app rejects every one:

{"level":40,"service":"next-app","messageId":"ca4fc00f-be59-57ca-99d0-a2fbff85b840","topicArn":"arn:aws:sns:sa-east-1:...:<prefix>-sa-east-1-unsend","msg":"Rejected SNS message with invalid signature"}

The configuration sets and the SNS topic are correctly wired (SesSetting.callbackSuccess, configGeneralSuccess, configFullSuccess all true), the endpoint is publicly reachable, and the message reaches the route — it only fails signature verification.

Cause

apps/web/src/server/aws/sns-message-validator.ts:

return fields
  .filter((field) => values[field] !== undefined)
  .flatMap((field) => [field, values[field] as string])
  .join("\n");

AWS builds the string to sign as name\nvalue\n for each field — every value is terminated by a newline, including the last one (this is what the official sns-validator package does: chunks.push(key + "\n"); chunks.push(message[key] + "\n")). .join("\n") omits that final terminator, so the digest never matches and no authentic notification can ever verify.

Why the test does not catch it

sns-message-validator.unit.test.ts signs the output of buildSnsStringToSign itself:

Signature: sign("RSA-SHA256", Buffer.from(buildSnsStringToSign(message)), privateKey)

That is self-confirming — it passes for any canonical form, correct or not. The uses the SNS canonical field order test asserts the same .join("\n") shape, so it locks the defect in rather than detecting it.

Fix

return fields
  .filter((field) => values[field] !== undefined)
  .map((field) => `${field}\n${values[field] as string}\n`)
  .join("");

and assert an independently written literal in the test instead of re-using the function under test.

Verified in a fork against real SES/SNS traffic in sa-east-1. Related: #439. Happy to open a PR for both.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions