From 4b9621af75fd38b66e2a86bb286356b243fe3069 Mon Sep 17 00:00:00 2001 From: KM Koushik <24666922+KMKoushik@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:40:28 +0000 Subject: [PATCH 1/2] fix(ses): verify SNS message signatures on the callback route Co-authored-by: polylane[bot] <277585245+polylane[bot]@users.noreply.github.com> --- .../app/api/ses_callback/route.api.test.ts | 156 ++++++++++++++ apps/web/src/app/api/ses_callback/route.ts | 42 ++-- .../src/server/aws/sns-message-validator.ts | 177 ++++++++++++++++ .../aws/sns-message-validator.unit.test.ts | 194 ++++++++++++++++++ apps/web/src/types/aws-types.ts | 2 + 5 files changed, 559 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/api/ses_callback/route.api.test.ts create mode 100644 apps/web/src/server/aws/sns-message-validator.ts create mode 100644 apps/web/src/server/aws/sns-message-validator.unit.test.ts diff --git a/apps/web/src/app/api/ses_callback/route.api.test.ts b/apps/web/src/app/api/ses_callback/route.api.test.ts new file mode 100644 index 000000000..068781e65 --- /dev/null +++ b/apps/web/src/app/api/ses_callback/route.api.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { state, mockQueue, mockGetTopicArns, mockInvalidateCache, mockDb } = + vi.hoisted(() => ({ + state: { + signatureValid: false, + subscribeUrlTrusted: false, + }, + mockQueue: vi.fn(), + mockGetTopicArns: vi.fn(), + mockInvalidateCache: vi.fn(), + mockDb: { + sesSetting: { + findFirst: vi.fn(), + update: vi.fn(), + }, + }, + })); + +vi.mock("~/env", () => ({ env: { NODE_ENV: "test" } })); + +vi.mock("~/server/db", () => ({ db: mockDb })); + +vi.mock("~/server/service/ses-hook-parser", () => ({ + SesHookParser: { queue: mockQueue }, +})); + +vi.mock("~/server/service/ses-settings-service", () => ({ + SesSettingsService: { + getTopicArns: mockGetTopicArns, + invalidateCache: mockInvalidateCache, + }, +})); + +vi.mock("~/server/aws/sns-message-validator", () => ({ + verifySnsMessageSignature: vi.fn(async () => state.signatureValid), + isTrustedSnsUrl: vi.fn(() => state.subscribeUrlTrusted), +})); + +import { POST } from "~/app/api/ses_callback/route"; + +const TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:my-topic"; + +function postRequest(body: unknown) { + return POST( + new Request("http://localhost/api/ses_callback", { + method: "POST", + body: JSON.stringify(body), + }), + ); +} + +function forgedNotification() { + return { + Type: "Notification", + MessageId: "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324", + TopicArn: TOPIC_ARN, + Message: '{"notificationType":"Bounce"}', + Timestamp: "2012-05-02T00:54:06.655Z", + SignatureVersion: "2", + Signature: "forged", + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-abc.pem", + }; +} + +beforeEach(() => { + mockGetTopicArns.mockResolvedValue([TOPIC_ARN]); + mockQueue.mockResolvedValue(true); + mockDb.sesSetting.findFirst.mockResolvedValue({ id: "setting_1" }); + mockDb.sesSetting.update.mockResolvedValue({ id: "setting_1" }); + state.signatureValid = false; + state.subscribeUrlTrusted = false; +}); + +describe("ses_callback route", () => { + it("rejects a forged notification with an invalid signature", async () => { + const response = await postRequest(forgedNotification()); + + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ data: "Event is not valid" }); + expect(mockQueue).not.toHaveBeenCalled(); + }); + + it("processes a notification with a valid signature", async () => { + state.signatureValid = true; + + const response = await postRequest(forgedNotification()); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ data: "Success" }); + expect(mockQueue).toHaveBeenCalledTimes(1); + }); + + it("rejects a notification from an unconfigured topic", async () => { + state.signatureValid = true; + mockGetTopicArns.mockResolvedValue(["arn:aws:sns:us-east-1:123456789012:other"]); + + const response = await postRequest(forgedNotification()); + + expect(response.status).toBe(401); + expect(mockQueue).not.toHaveBeenCalled(); + }); + + it("does not fetch an untrusted SubscribeURL", async () => { + state.signatureValid = true; + state.subscribeUrlTrusted = false; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const response = await postRequest({ + ...forgedNotification(), + Type: "SubscriptionConfirmation", + SubscribeURL: "https://evil.example.com/confirm", + Token: "abc", + }); + + expect(response.status).toBe(400); + expect(fetchMock).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); + + it("confirms a subscription with a trusted SubscribeURL", async () => { + state.signatureValid = true; + state.subscribeUrlTrusted = true; + const fetchMock = vi.fn(async () => new Response("ok", { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const response = await postRequest({ + ...forgedNotification(), + Type: "SubscriptionConfirmation", + SubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=abc", + Token: "abc", + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ data: "Success" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(mockInvalidateCache).toHaveBeenCalledTimes(1); + + vi.unstubAllGlobals(); + }); + + it("rejects a malformed body with 400", async () => { + const response = await POST( + new Request("http://localhost/api/ses_callback", { + method: "POST", + body: "not-json", + }), + ); + + expect(response.status).toBe(400); + }); +}); diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 70913c1ee..083edb998 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,7 +1,11 @@ import { env } from "~/env"; import { db } from "~/server/db"; import { logger } from "~/server/logger/log"; -import { parseSesHook, SesHookParser } from "~/server/service/ses-hook-parser"; +import { + isTrustedSnsUrl, + verifySnsMessageSignature, +} from "~/server/aws/sns-message-validator"; +import { SesHookParser } from "~/server/service/ses-hook-parser"; import { SesSettingsService } from "~/server/service/ses-settings-service"; import { SnsNotificationMessage } from "~/types/aws-types"; @@ -12,16 +16,18 @@ export async function GET() { } export async function POST(req: Request) { - const data = await req.json(); + let data: SnsNotificationMessage; - console.log(data, data.Message); + try { + data = (await req.json()) as SnsNotificationMessage; + } catch { + return Response.json({ data: "Invalid payload" }, { status: 400 }); + } const isEventValid = await checkEventValidity(data); - console.log("Is event valid: ", isEventValid); - if (!isEventValid) { - return Response.json({ data: "Event is not valid" }); + return Response.json({ data: "Event is not valid" }, { status: 401 }); } if (data.Type === "SubscriptionConfirmation") { @@ -50,12 +56,21 @@ export async function POST(req: Request) { /** * Handles the subscription confirmation event. called only once for a webhook */ -async function handleSubscription(message: any) { - await fetch(message.SubscribeURL, { +async function handleSubscription(message: SnsNotificationMessage) { + const subscribeUrl = message.SubscribeURL; + + if (!subscribeUrl || !isTrustedSnsUrl(subscribeUrl, message.TopicArn)) { + logger.warn({ subscribeUrl }, "Rejected untrusted SNS SubscribeURL"); + return Response.json({ data: "Invalid subscription URL" }, { status: 400 }); + } + + await fetch(subscribeUrl, { method: "GET", + // Do not follow redirects: the confirmation URL must be the AWS SNS host. + redirect: "error", }); - const topicArn = message.TopicArn as string; + const topicArn = message.TopicArn; const setting = await db.sesSetting.findFirst({ where: { topicArn, @@ -81,9 +96,12 @@ async function handleSubscription(message: any) { } /** - * A simple check to ensure that the event is from the correct topic + * Checks that the event is from a configured topic and that its AWS SNS + * signature is valid before the payload is trusted. */ -async function checkEventValidity(message: SnsNotificationMessage) { +async function checkEventValidity( + message: SnsNotificationMessage +): Promise { if (env.NODE_ENV === "development") { return true; } @@ -95,5 +113,5 @@ async function checkEventValidity(message: SnsNotificationMessage) { return false; } - return true; + return verifySnsMessageSignature(message); } diff --git a/apps/web/src/server/aws/sns-message-validator.ts b/apps/web/src/server/aws/sns-message-validator.ts new file mode 100644 index 000000000..05c018d4e --- /dev/null +++ b/apps/web/src/server/aws/sns-message-validator.ts @@ -0,0 +1,177 @@ +import { createVerify } from "node:crypto"; +import { logger } from "~/server/logger/log"; +import type { SnsNotificationMessage } from "~/types/aws-types"; + +const SNS_HOST_PATTERN = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/; +const SIGNING_CERT_PATH_PATTERN = /\.pem$/i; + +const certificateCache = new Map>(); + +/** + * Extracts the region from an SNS topic ARN (arn:aws:sns:::). + */ +export function getSnsTopicRegion(topicArn: string): string | null { + const match = /^arn:[^:]*:sns:([^:]+):/.exec(topicArn); + return match?.[1] ?? null; +} + +/** + * Returns true only for an https URL on an AWS SNS regional host. When the + * topic ARN is known, the host must match the topic's own region so a message + * cannot point the server at another region or at a non-AWS host. + */ +export function isTrustedSnsUrl(rawUrl: string, topicArn?: string): boolean { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return false; + } + + if (url.protocol !== "https:") { + return false; + } + + if (!SNS_HOST_PATTERN.test(url.hostname)) { + return false; + } + + const region = topicArn ? getSnsTopicRegion(topicArn) : null; + if (region) { + const allowedHosts = [ + `sns.${region}.amazonaws.com`, + `sns.${region}.amazonaws.com.cn`, + ]; + if (!allowedHosts.includes(url.hostname)) { + return false; + } + } + + return true; +} + +function isTrustedSigningCertUrl(rawUrl: string, topicArn?: string): boolean { + if (!isTrustedSnsUrl(rawUrl, topicArn)) { + return false; + } + + try { + return SIGNING_CERT_PATH_PATTERN.test(new URL(rawUrl).pathname); + } catch { + return false; + } +} + +async function getSigningCertificate(certUrl: string): Promise { + const cached = certificateCache.get(certUrl); + if (cached) { + return cached; + } + + const pending = (async () => { + const response = await fetch(certUrl, { + method: "GET", + // Do not follow redirects: the certificate URL must be the AWS SNS host. + redirect: "error", + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch SNS signing certificate, status: ${response.status}`, + ); + } + + return await response.text(); + })(); + + certificateCache.set(certUrl, pending); + + try { + return await pending; + } catch (error) { + certificateCache.delete(certUrl); + throw error; + } +} + +/** + * Builds the canonical string that AWS SNS signs. The fields are concatenated + * as "\n\n" in AWS's order, which is alphabetical by field name. + */ +function buildSignableString(message: SnsNotificationMessage): string { + const isSubscription = + message.Type === "SubscriptionConfirmation" || + message.Type === "UnsubscribeConfirmation"; + + const fieldOrder = isSubscription + ? [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ] + : ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"]; + + let signable = ""; + for (const field of fieldOrder) { + const value = (message as unknown as Record)[field]; + if (value === undefined || value === null) { + continue; + } + signable += `${field}\n${value}\n`; + } + + return signable; +} + +function getSignatureAlgorithm(signatureVersion: string): string | null { + if (signatureVersion === "1") { + return "RSA-SHA1"; + } + if (signatureVersion === "2") { + return "RSA-SHA256"; + } + return null; +} + +/** + * Verifies the AWS SNS message signature. Returns true only when the message + * carries a valid signature produced by the certificate served from the AWS + * SNS host for the message's own topic region. + */ +export async function verifySnsMessageSignature( + message: SnsNotificationMessage, +): Promise { + try { + const { Signature, SignatureVersion, SigningCertURL, TopicArn } = message; + + if (!Signature || !SignatureVersion || !SigningCertURL || !TopicArn) { + logger.warn("SNS message is missing signature fields"); + return false; + } + + const algorithm = getSignatureAlgorithm(SignatureVersion); + if (!algorithm) { + logger.warn({ SignatureVersion }, "Unsupported SNS signature version"); + return false; + } + + if (!isTrustedSigningCertUrl(SigningCertURL, TopicArn)) { + logger.warn({ SigningCertURL }, "Untrusted SNS signing certificate URL"); + return false; + } + + const certificate = await getSigningCertificate(SigningCertURL); + + const verifier = createVerify(algorithm); + verifier.update(buildSignableString(message), "utf8"); + + return verifier.verify(certificate, Signature, "base64"); + } catch (error) { + logger.error({ err: error }, "Failed to verify SNS message signature"); + return false; + } +} diff --git a/apps/web/src/server/aws/sns-message-validator.unit.test.ts b/apps/web/src/server/aws/sns-message-validator.unit.test.ts new file mode 100644 index 000000000..a9fd1dabb --- /dev/null +++ b/apps/web/src/server/aws/sns-message-validator.unit.test.ts @@ -0,0 +1,194 @@ +import { createSign, generateKeyPairSync } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getSnsTopicRegion, + isTrustedSnsUrl, + verifySnsMessageSignature, +} from "~/server/aws/sns-message-validator"; +import type { SnsNotificationMessage } from "~/types/aws-types"; + +const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, +}); + +const TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:my-topic"; + +function sign( + message: Partial, + version: "1" | "2" = "2", +): string { + const isSubscription = + message.Type === "SubscriptionConfirmation" || + message.Type === "UnsubscribeConfirmation"; + + const fieldOrder = isSubscription + ? [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ] + : ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"]; + + let canonical = ""; + for (const field of fieldOrder) { + const value = (message as Record)[field]; + if (value === undefined || value === null) { + continue; + } + canonical += `${field}\n${value}\n`; + } + + const signer = createSign(version === "1" ? "RSA-SHA1" : "RSA-SHA256"); + signer.update(canonical, "utf8"); + return signer.sign(privateKey, "base64"); +} + +function buildNotification( + overrides: Partial = {}, +): SnsNotificationMessage { + const base = { + Type: "Notification", + MessageId: "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324", + TopicArn: TOPIC_ARN, + Subject: "My First Message", + Message: '{"notificationType":"Bounce"}', + Timestamp: "2012-05-02T00:54:06.655Z", + SignatureVersion: "2", + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-abc.pem", + } as SnsNotificationMessage; + + const merged = { ...base, ...overrides }; + merged.Signature = overrides.Signature ?? sign(merged, "2"); + return merged; +} + +function stubCertFetch(cert: string) { + const fetchMock = vi.fn( + async () => new Response(cert, { status: 200 }), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("getSnsTopicRegion", () => { + it("extracts the region from a topic ARN", () => { + expect(getSnsTopicRegion(TOPIC_ARN)).toBe("us-east-1"); + }); + + it("returns null for a malformed ARN", () => { + expect(getSnsTopicRegion("not-an-arn")).toBeNull(); + }); +}); + +describe("isTrustedSnsUrl", () => { + it("accepts an https URL on the topic's regional SNS host", () => { + expect( + isTrustedSnsUrl( + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription", + TOPIC_ARN, + ), + ).toBe(true); + }); + + it("rejects non-https URLs", () => { + expect( + isTrustedSnsUrl("http://sns.us-east-1.amazonaws.com/foo", TOPIC_ARN), + ).toBe(false); + }); + + it("rejects a non-AWS host", () => { + expect(isTrustedSnsUrl("https://evil.example.com/foo", TOPIC_ARN)).toBe( + false, + ); + }); + + it("rejects an AWS SNS host in a different region than the topic", () => { + expect( + isTrustedSnsUrl("https://sns.us-west-2.amazonaws.com/foo", TOPIC_ARN), + ).toBe(false); + }); +}); + +describe("verifySnsMessageSignature", () => { + it("accepts a message with a valid signature", async () => { + stubCertFetch(publicKey); + const message = buildNotification(); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + }); + + it("accepts a valid SubscriptionConfirmation message", async () => { + stubCertFetch(publicKey); + const message = buildNotification({ + Type: "SubscriptionConfirmation", + SubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&Token=abc", + Token: "abc", + } as Partial); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + }); + + it("rejects a message whose payload was changed after signing", async () => { + stubCertFetch(publicKey); + const signed = buildNotification(); + const tampered = { ...signed, Message: '{"notificationType":"Complaint"}' }; + + await expect(verifySnsMessageSignature(tampered)).resolves.toBe(false); + }); + + it("rejects a signature that was not produced by the fetched key", async () => { + stubCertFetch(publicKey); + const message = buildNotification({ + Signature: sign( + { + Type: "Notification", + MessageId: "different", + TopicArn: TOPIC_ARN, + Message: "other", + Timestamp: "2012-05-02T00:54:06.655Z", + }, + "2", + ), + }); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + }); + + it("rejects an unsupported signature version without fetching", async () => { + const fetchMock = stubCertFetch(publicKey); + const message = buildNotification({ SignatureVersion: "3" }); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects an untrusted SigningCertURL without fetching", async () => { + const fetchMock = stubCertFetch(publicKey); + const message = buildNotification({ + SigningCertURL: "https://evil.example.com/cert.pem", + }); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects a message missing signature fields", async () => { + const message = buildNotification(); + // @ts-expect-error simulate a forged message with no signature + delete message.Signature; + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + }); +}); diff --git a/apps/web/src/types/aws-types.ts b/apps/web/src/types/aws-types.ts index ddcbb118e..fc574c11c 100644 --- a/apps/web/src/types/aws-types.ts +++ b/apps/web/src/types/aws-types.ts @@ -9,6 +9,8 @@ export interface SnsNotificationMessage { Signature: string; SigningCertURL: string; UnsubscribeURL: string; + SubscribeURL?: string; + Token?: string; } export interface SesMail { From 01cffe755d0aabc59714bce8f8d11b9e607c1133 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Mon, 26 Oct 2026 09:38:03 +1100 Subject: [PATCH 2/2] fix(ses): test SNS verification against real AWS signatures and harden 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 --- .../app/api/ses_callback/route.api.test.ts | 158 +++++++++++++++--- apps/web/src/app/api/ses_callback/route.ts | 27 ++- apps/web/src/env.js | 4 + .../src/server/aws/sns-message-validator.ts | 23 ++- .../aws/sns-message-validator.unit.test.ts | 112 ++++++++++++- .../test/fixtures/aws-sns-signed-messages.ts | 141 ++++++++++++++++ 6 files changed, 433 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/test/fixtures/aws-sns-signed-messages.ts diff --git a/apps/web/src/app/api/ses_callback/route.api.test.ts b/apps/web/src/app/api/ses_callback/route.api.test.ts index 068781e65..12da92818 100644 --- a/apps/web/src/app/api/ses_callback/route.api.test.ts +++ b/apps/web/src/app/api/ses_callback/route.api.test.ts @@ -1,23 +1,42 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { state, mockQueue, mockGetTopicArns, mockInvalidateCache, mockDb } = - vi.hoisted(() => ({ - state: { - signatureValid: false, - subscribeUrlTrusted: false, - }, - mockQueue: vi.fn(), - mockGetTopicArns: vi.fn(), - mockInvalidateCache: vi.fn(), - mockDb: { - sesSetting: { - findFirst: vi.fn(), - update: vi.fn(), - }, +const { + state, + mockEnv, + mockLogger, + mockQueue, + mockGetTopicArns, + mockInvalidateCache, + mockDb, +} = vi.hoisted(() => ({ + state: { + signatureValid: false, + subscribeUrlTrusted: false, + useRealValidator: false, + }, + mockEnv: { + NODE_ENV: "test", + SNS_SIGNATURE_VERIFICATION: "enforce", + }, + mockLogger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + mockQueue: vi.fn(), + mockGetTopicArns: vi.fn(), + mockInvalidateCache: vi.fn(), + mockDb: { + sesSetting: { + findFirst: vi.fn(), + update: vi.fn(), }, - })); + }, +})); + +vi.mock("~/env", () => ({ env: mockEnv })); -vi.mock("~/env", () => ({ env: { NODE_ENV: "test" } })); +vi.mock("~/server/logger/log", () => ({ logger: mockLogger })); vi.mock("~/server/db", () => ({ db: mockDb })); @@ -32,12 +51,25 @@ vi.mock("~/server/service/ses-settings-service", () => ({ }, })); -vi.mock("~/server/aws/sns-message-validator", () => ({ - verifySnsMessageSignature: vi.fn(async () => state.signatureValid), - isTrustedSnsUrl: vi.fn(() => state.subscribeUrlTrusted), -})); +vi.mock("~/server/aws/sns-message-validator", async (importOriginal) => { + const actual = + await importOriginal(); + return { + verifySnsMessageSignature: vi.fn(async (message: SnsNotificationMessage) => + state.useRealValidator + ? actual.verifySnsMessageSignature(message) + : state.signatureValid, + ), + isTrustedSnsUrl: vi.fn(() => state.subscribeUrlTrusted), + }; +}); import { POST } from "~/app/api/ses_callback/route"; +import { + AWS_SIGNED_SNS_MESSAGES, + AWS_SNS_SIGNING_CERT, +} from "~/test/fixtures/aws-sns-signed-messages"; +import type { SnsNotificationMessage } from "~/types/aws-types"; const TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:my-topic"; @@ -71,6 +103,10 @@ beforeEach(() => { mockDb.sesSetting.update.mockResolvedValue({ id: "setting_1" }); state.signatureValid = false; state.subscribeUrlTrusted = false; + state.useRealValidator = false; + mockEnv.SNS_SIGNATURE_VERIFICATION = "enforce"; + mockLogger.error.mockClear(); + mockQueue.mockClear(); }); describe("ses_callback route", () => { @@ -78,7 +114,9 @@ describe("ses_callback route", () => { const response = await postRequest(forgedNotification()); expect(response.status).toBe(401); - await expect(response.json()).resolves.toEqual({ data: "Event is not valid" }); + await expect(response.json()).resolves.toEqual({ + data: "Event is not valid", + }); expect(mockQueue).not.toHaveBeenCalled(); }); @@ -94,7 +132,9 @@ describe("ses_callback route", () => { it("rejects a notification from an unconfigured topic", async () => { state.signatureValid = true; - mockGetTopicArns.mockResolvedValue(["arn:aws:sns:us-east-1:123456789012:other"]); + mockGetTopicArns.mockResolvedValue([ + "arn:aws:sns:us-east-1:123456789012:other", + ]); const response = await postRequest(forgedNotification()); @@ -153,4 +193,78 @@ describe("ses_callback route", () => { expect(response.status).toBe(400); }); + + it("logs an error when it rejects an invalid signature", async () => { + await postRequest(forgedNotification()); + + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ topicArn: TOPIC_ARN, enforced: true }), + "Rejected SNS message with invalid signature", + ); + }); + + it("accepts an invalid signature but still logs it in log-only mode", async () => { + mockEnv.SNS_SIGNATURE_VERIFICATION = "log-only"; + + const response = await postRequest(forgedNotification()); + + expect(response.status).toBe(200); + expect(mockQueue).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ enforced: false }), + expect.stringContaining("log-only"), + ); + }); + + it("still rejects an unconfigured topic in log-only mode", async () => { + mockEnv.SNS_SIGNATURE_VERIFICATION = "log-only"; + mockGetTopicArns.mockResolvedValue([ + "arn:aws:sns:us-east-1:123456789012:other", + ]); + + const response = await postRequest(forgedNotification()); + + expect(response.status).toBe(401); + expect(mockQueue).not.toHaveBeenCalled(); + }); + + it("queues a genuine AWS-signed SES event end to end", async () => { + state.useRealValidator = true; + const message = AWS_SIGNED_SNS_MESSAGES.sesComplaintEventWithSubject; + mockGetTopicArns.mockResolvedValue([message.TopicArn]); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(AWS_SNS_SIGNING_CERT, { status: 200 })), + ); + + const response = await postRequest(message); + + expect(response.status).toBe(200); + expect(mockQueue).toHaveBeenCalledWith({ + event: JSON.parse(message.Message), + messageId: message.MessageId, + }); + + vi.unstubAllGlobals(); + }); + + it("rejects a genuine AWS-signed SES event after tampering", async () => { + state.useRealValidator = true; + const message = AWS_SIGNED_SNS_MESSAGES.sesComplaintEventWithSubject; + mockGetTopicArns.mockResolvedValue([message.TopicArn]); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(AWS_SNS_SIGNING_CERT, { status: 200 })), + ); + + const response = await postRequest({ + ...message, + Message: message.Message.replace("Complaint", "Bounce"), + }); + + expect(response.status).toBe(401); + expect(mockQueue).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); }); diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 083edb998..4a3121aa8 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -100,7 +100,7 @@ async function handleSubscription(message: SnsNotificationMessage) { * signature is valid before the payload is trusted. */ async function checkEventValidity( - message: SnsNotificationMessage + message: SnsNotificationMessage, ): Promise { if (env.NODE_ENV === "development") { return true; @@ -110,8 +110,31 @@ async function checkEventValidity( const configuredTopicArn = await SesSettingsService.getTopicArns(); if (!configuredTopicArn.includes(TopicArn)) { + logger.warn( + { topicArn: TopicArn, messageId: message.MessageId }, + "Rejected SNS message from an unknown topic", + ); return false; } - return verifySnsMessageSignature(message); + if (await verifySnsMessageSignature(message)) { + return true; + } + + const enforced = env.SNS_SIGNATURE_VERIFICATION !== "log-only"; + logger.error( + { + topicArn: TopicArn, + messageId: message.MessageId, + type: message.Type, + signatureVersion: message.SignatureVersion, + signingCertUrl: message.SigningCertURL, + enforced, + }, + enforced + ? "Rejected SNS message with invalid signature" + : "SNS message has an invalid signature, accepting because SNS_SIGNATURE_VERIFICATION=log-only", + ); + + return !enforced; } diff --git a/apps/web/src/env.js b/apps/web/src/env.js index 140852f45..946e81123 100644 --- a/apps/web/src/env.js +++ b/apps/web/src/env.js @@ -39,6 +39,9 @@ export const env = createEnv({ GOOGLE_CLIENT_SECRET: z.string().optional(), AWS_SES_ENDPOINT: z.string().optional(), AWS_SNS_ENDPOINT: z.string().optional(), + SNS_SIGNATURE_VERIFICATION: z + .enum(["enforce", "log-only"]) + .default("enforce"), AWS_DEFAULT_REGION: z .string() .trim() @@ -112,6 +115,7 @@ export const env = createEnv({ AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, AWS_SES_ENDPOINT: process.env.AWS_SES_ENDPOINT, AWS_SNS_ENDPOINT: process.env.AWS_SNS_ENDPOINT, + SNS_SIGNATURE_VERIFICATION: process.env.SNS_SIGNATURE_VERIFICATION, API_RATE_LIMIT: process.env.API_RATE_LIMIT, AUTH_EMAIL_RATE_LIMIT: process.env.AUTH_EMAIL_RATE_LIMIT, NEXT_PUBLIC_IS_CLOUD: process.env.NEXT_PUBLIC_IS_CLOUD, diff --git a/apps/web/src/server/aws/sns-message-validator.ts b/apps/web/src/server/aws/sns-message-validator.ts index 05c018d4e..3a6a9b9f4 100644 --- a/apps/web/src/server/aws/sns-message-validator.ts +++ b/apps/web/src/server/aws/sns-message-validator.ts @@ -4,6 +4,8 @@ import type { SnsNotificationMessage } from "~/types/aws-types"; const SNS_HOST_PATTERN = /^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/; const SIGNING_CERT_PATH_PATTERN = /\.pem$/i; +const CERTIFICATE_FETCH_TIMEOUT_MS = 5000; +const CERTIFICATE_CACHE_LIMIT = 50; const certificateCache = new Map>(); @@ -55,11 +57,17 @@ function isTrustedSigningCertUrl(rawUrl: string, topicArn?: string): boolean { return false; } - try { - return SIGNING_CERT_PATH_PATTERN.test(new URL(rawUrl).pathname); - } catch { - return false; - } + const url = new URL(rawUrl); + // Reject query strings, credentials and ports so forged messages cannot + // make us fetch and cache an unbounded number of distinct URLs. + return ( + SIGNING_CERT_PATH_PATTERN.test(url.pathname) && + !url.search && + !url.hash && + !url.username && + !url.password && + !url.port + ); } async function getSigningCertificate(certUrl: string): Promise { @@ -68,11 +76,16 @@ async function getSigningCertificate(certUrl: string): Promise { return cached; } + if (certificateCache.size >= CERTIFICATE_CACHE_LIMIT) { + certificateCache.clear(); + } + const pending = (async () => { const response = await fetch(certUrl, { method: "GET", // Do not follow redirects: the certificate URL must be the AWS SNS host. redirect: "error", + signal: AbortSignal.timeout(CERTIFICATE_FETCH_TIMEOUT_MS), }); if (!response.ok) { diff --git a/apps/web/src/server/aws/sns-message-validator.unit.test.ts b/apps/web/src/server/aws/sns-message-validator.unit.test.ts index a9fd1dabb..9201410b0 100644 --- a/apps/web/src/server/aws/sns-message-validator.unit.test.ts +++ b/apps/web/src/server/aws/sns-message-validator.unit.test.ts @@ -5,6 +5,10 @@ import { isTrustedSnsUrl, verifySnsMessageSignature, } from "~/server/aws/sns-message-validator"; +import { + AWS_SIGNED_SNS_MESSAGES, + AWS_SNS_SIGNING_CERT, +} from "~/test/fixtures/aws-sns-signed-messages"; import type { SnsNotificationMessage } from "~/types/aws-types"; const { privateKey, publicKey } = generateKeyPairSync("rsa", { @@ -70,9 +74,7 @@ function buildNotification( } function stubCertFetch(cert: string) { - const fetchMock = vi.fn( - async () => new Response(cert, { status: 200 }), - ); + const fetchMock = vi.fn(async () => new Response(cert, { status: 200 })); vi.stubGlobal("fetch", fetchMock); return fetchMock; } @@ -192,3 +194,107 @@ describe("verifySnsMessageSignature", () => { await expect(verifySnsMessageSignature(message)).resolves.toBe(false); }); }); + +describe("verifySnsMessageSignature with genuine AWS-signed messages", () => { + const realMessages = Object.entries(AWS_SIGNED_SNS_MESSAGES).map( + ([name, message]) => [name, message as SnsNotificationMessage] as const, + ); + + it.each(realMessages)("accepts the real %s message", async (_, message) => { + stubCertFetch(AWS_SNS_SIGNING_CERT); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + }); + + it.each([ + ["Message", { Message: "Goodbye world" }], + ["MessageId", { MessageId: "00000000-0000-0000-0000-000000000000" }], + ["Timestamp", { Timestamp: "2022-07-28T21:24:09.324Z" }], + ["TopicArn", { TopicArn: "arn:aws:sns:us-east-2:295079676684:other" }], + ["Subject (added)", { Subject: "Injected" }], + ])("rejects a real message with a tampered %s", async (_, change) => { + stubCertFetch(AWS_SNS_SIGNING_CERT); + const message = { + ...(AWS_SIGNED_SNS_MESSAGES.sha256 as SnsNotificationMessage), + ...change, + }; + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + }); + + it("rejects a real message whose Subject was removed", async () => { + stubCertFetch(AWS_SNS_SIGNING_CERT); + const { Subject: _, ...message } = + AWS_SIGNED_SNS_MESSAGES.sesComplaintEventWithSubject; + + await expect( + verifySnsMessageSignature(message as SnsNotificationMessage), + ).resolves.toBe(false); + }); + + it("rejects a real SHA256 signature relabelled as SignatureVersion 1", async () => { + stubCertFetch(AWS_SNS_SIGNING_CERT); + const message = { + ...(AWS_SIGNED_SNS_MESSAGES.sha256 as SnsNotificationMessage), + SignatureVersion: "1", + }; + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + }); + + it.each([ + "http://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + "https://sns.us-east-2.amazonaws.com.evil.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.txt", + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem?x=1", + "https://sns.us-east-2.amazonaws.com:8443/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + "https://user@sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + ])( + "rejects a real message pointing at an untrusted cert URL %s", + async (SigningCertURL) => { + const fetchMock = stubCertFetch(AWS_SNS_SIGNING_CERT); + const message = { + ...(AWS_SIGNED_SNS_MESSAGES.sha256 as SnsNotificationMessage), + SigningCertURL, + }; + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); +}); + +describe("signing certificate fetch", () => { + it("fetches with a timeout, refuses redirects and caches the certificate", async () => { + const fetchMock = stubCertFetch(publicKey); + const message = buildNotification({ + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-cache.pem", + }); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as unknown as [ + string, + RequestInit, + ]; + expect(init.redirect).toBe("error"); + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("does not cache a failed certificate fetch", async () => { + const fetchMock = vi.fn(async () => new Response("nope", { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + const message = buildNotification({ + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-flaky.pem", + }); + + await expect(verifySnsMessageSignature(message)).resolves.toBe(false); + stubCertFetch(publicKey); + await expect(verifySnsMessageSignature(message)).resolves.toBe(true); + }); +}); diff --git a/apps/web/src/test/fixtures/aws-sns-signed-messages.ts b/apps/web/src/test/fixtures/aws-sns-signed-messages.ts new file mode 100644 index 000000000..bb983c58f --- /dev/null +++ b/apps/web/src/test/fixtures/aws-sns-signed-messages.ts @@ -0,0 +1,141 @@ +/** + * Genuine AWS-signed SNS messages, captured from real SNS deliveries and + * published as test fixtures by AWS and django-ses: + * - https://github.com/aws/aws-php-sns-message-validator/blob/master/tests/FunctionalValidationsTest.php + * - https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-sns/spec/message_verifier_spec.rb + * - https://github.com/django-ses/django-ses/blob/main/tests/test_verifier.py + * All of them are signed by the certificate below (CN=sns.amazonaws.com), which + * AWS no longer serves, so tests stub the certificate fetch with it. + */ + +export const AWS_SNS_SIGNING_CERT = `-----BEGIN CERTIFICATE----- +MIIF1zCCBL+gAwIBAgIQB9pYWG3Mi7xej22g9pobJTANBgkqhkiG9w0BAQsFADBG +MQswCQYDVQQGEwJVUzEPMA0GA1UEChMGQW1hem9uMRUwEwYDVQQLEwxTZXJ2ZXIg +Q0EgMUIxDzANBgNVBAMTBkFtYXpvbjAeFw0yMTA5MDcwMDAwMDBaFw0yMjA4MTcy +MzU5NTlaMBwxGjAYBgNVBAMTEXNucy5hbWF6b25hd3MuY29tMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAutFqueT3XgP13udzxE6UpbdjOtVO5DwoMpSM +iDNMnGzF1TYH5/R2LPUOBeTB0SkKnR4kpNcUZhicpGD4aKciz/GEZ6wu65xncfT9 +H/KBOQwoXYTuClHwp6fYpGzcGFaFoEYMnijL/o4qmTSd+ukglQUgKpsDw4ofw6rU +m2CttJo+GQSNQ9NfGR1h/0J+zsApkeSYrXRx5wNlu87z8os1C/6PBrUHwt3xXeaf +Xzfwut8aRRYsS8BySOA9DAgLfNHlfdQCjKPXKrG/ussgReyWD6n/HH+j7Uha3xos +TzQqJifcxlTq6MxWdPR6fDaJNvqw6DOE7UjUNxHguXHlVfxhlQIDAQABo4IC6TCC +AuUwHwYDVR0jBBgwFoAUWaRmBlKge5WSPKOUByeWdFv5PdAwHQYDVR0OBBYEFAqz +C+vyouneE7mWWLbi9i0UsWUbMBwGA1UdEQQVMBOCEXNucy5hbWF6b25hd3MuY29t +MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIw +OwYDVR0fBDQwMjAwoC6gLIYqaHR0cDovL2NybC5zY2ExYi5hbWF6b250cnVzdC5j +b20vc2NhMWIuY3JsMBMGA1UdIAQMMAowCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkw +ZzAtBggrBgEFBQcwAYYhaHR0cDovL29jc3Auc2NhMWIuYW1hem9udHJ1c3QuY29t +MDYGCCsGAQUFBzAChipodHRwOi8vY3J0LnNjYTFiLmFtYXpvbnRydXN0LmNvbS9z +Y2ExYi5jcnQwDAYDVR0TAQH/BAIwADCCAX0GCisGAQQB1nkCBAIEggFtBIIBaQFn +AHYAKXm+8J45OSHwVnOfY6V35b5XfZxgCvj5TV0mXCVdx4QAAAF7vfDVkQAABAMA +RzBFAiEA2XfHuy36aqRFiaL8c3md2mH451go8707+fRE0pEdSRACIE/g5FXTUXUZ +PFcmOhm9TZ+uMY1i4CIQ/CKVWln6C3t+AHYAUaOw9f0BeZxWbbg3eI8MpHrMGyfL +956IQpoN/tSLBeUAAAF7vfDVjAAABAMARzBFAiBF1MhhFP0+FQt3daDFfMYoWwnr +muTInrjNpwfzlvQBugIhAPYadFzr+LaxSJoiZEbEHBvTts7bT0M3eCQONA2O7w6n +AHUAQcjKsd8iRkoQxqE6CUKHXk4xixsD6+tLx2jwkGKWBvYAAAF7vfDVdAAABAMA +RjBEAiAtPapmFAuA71ih4NoSd5hJelzAltNQpxDMcDfDyHyU8gIgWxmaa6+2KbBu +9xdv379zvnJACFR7jc+4asl08Dn4aagwDQYJKoZIhvcNAQELBQADggEBAA54QX0u +oFWXfMmv02CGZv4NWo5TapyeeixQ2kKpZHRdVZjxZrw+hoF6HD7P3kGjH8ztyJll +tDxB0qgMltbPhQdScwhA6iTgoaBYqEUC/VHKd4PmmPT6yIsM36NBZVmkGlzl5uNo +/dBgBaG0SsVJnhr5zro3c2quC7n6fVGEZhf/UgQwRnnvThnvbNKguglDMq4uEqv8 +njKyleht+glkcmXO0m9qLKt6BOS0amy6U2GlAwRn0Wx02ndJtnRCSC6kPuRWK/SQ +FEjB7gCK4hdKaAOuWdZpI55vF6ifOeM8toC3g7ofO8qLTnJupAG+ZitY5J3cvHWr +HqOUdKigPDHYLRo= +-----END CERTIFICATE----- +`; + +export const AWS_SIGNED_SNS_MESSAGES = { + sha1: { + Type: "Notification", + MessageId: "792cda85-518f-5dd3-9163-81d851212f3a", + TopicArn: + "arn:aws:sns:us-east-2:295079676684:publish-and-verify-892f85fe-4836-424d-8188-ab85bef0f362", + Message: "Hello world", + Timestamp: "2022-07-28T21:23:58.317Z", + SignatureVersion: "1", + Signature: + "ghtf+deOBAzHJJZ6s6CdRLfTQAlcGzq9naoFM1wi0CJiq//uVRuZnamrkWNF0fhouMFvuLVRwcz8PZLUMSfnmd5VpdTKpTyiKmy1qJAZXma0w+yi7G+I33hD1Jyk1Nbym2n0kqp3fVu2aoooiN2ZeLAT2bH0/BtjLSfN1yAOKNoprco4qV9gGUZinXJdj9a1YdNhDR2jKi33ldlsVtEXAtiaDklGEk7DgRKX38GerBPiLg3FdtgY6KC7cdeGpU/dGK+4hjc83Ive1HoFkAwqhpgInM2sMytBosoiXfCmOKmU4xeGD0gHDNZTlJUJQDlzw8Eag0H9f/5zXF9d3uy0YQ==", + SigningCertURL: + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:295079676684:publish-and-verify-892f85fe-4836-424d-8188-ab85bef0f362:2296bc94-7992-4be1-b15f-b97229b5c1d8", + }, + sha256: { + Type: "Notification", + MessageId: "17dea24b-55c2-540b-8362-f916557af765", + TopicArn: + "arn:aws:sns:us-east-2:295079676684:publish-and-verify-62674b1d-4295-426b-88e7-5fb75652a04e", + Message: "Hello world", + Timestamp: "2022-07-28T21:24:08.324Z", + SignatureVersion: "2", + Signature: + "CXVqp9PfZAL+4JHS3Zxo1PFbQsvnOjvmYhtIf17TWpwc+iIVas8kZ8GopuzVzVMdatE7rCl/O4P91Zp05Dwz8lk8dLhfp8gSu3Njlzxlyrmzo9x3va3Jb7zFnedgS2GKnZWHGBdwTXho+TosNUE+3e10OMSlwN5XGDwX7+R3WL+rn+AXmFAqp3alg27sYa55h1dLE9cGszGPjScPdtF3BmZsUDMx9wSdNKsCk+vSvE8yBjnCmUl7laSFj3LzPVrlSwgNYCF3kYnNAkah7NplK4SFhJYLwS0HCVCQJKa8rVbQLf9cBTu60U402mrgy0bN8xWoyimzbYbrOMJjalqkUg==", + SigningCertURL: + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:295079676684:publish-and-verify-62674b1d-4295-426b-88e7-5fb75652a04e:ad7d16e3-0a7c-46aa-b23e-ffaf02250cbe", + }, + sha1WithSubject: { + Type: "Notification", + MessageId: "11405cc3-9ac7-56d5-b45d-079e8f7a8edf", + TopicArn: + "arn:aws:sns:us-east-2:295079676684:publish-and-verify-6e11fed2-fcdf-4c52-9dc4-36ef43f37f84", + Subject: "Hello world", + Message: "Hello world", + Timestamp: "2022-07-28T22:53:49.654Z", + SignatureVersion: "1", + Signature: + "AItkS26d8yvnIKJevdirIPW7eM/yKbZy3/CF2EreCHmXWB3etWaV5Fb7SYpGABMpugpDZzNyGY1wCVWaopDoQ+7Q/kI2TpDu8bw1eExbi8U3kduvc/2m2fIrI4gDEY8/v3nzoLcr8pPodqMzrX6SzQou4klfaqbNK+rFmH0LVf2Q1VyOROODoSXmo4jg2Yu12jfxccBl96Drr/ihq4MJ4OcrWh6UzXXlVYjJHx2Ui4anNwNEb+Z4C2CAF1DjQUbhDtaoajDBPY+4d9C1OwbqwQpXsd6tyVcI9nFyEsVK8lfnAV+/3GZQcdXHbIUYBRGcBa4X5TlWJku5nDH2ERtHHw==", + SigningCertURL: + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:295079676684:publish-and-verify-6e11fed2-fcdf-4c52-9dc4-36ef43f37f84:adb318c3-2234-4c56-905d-c324cf0df874", + }, + sha256WithSubject: { + Type: "Notification", + MessageId: "4504e649-d933-5aa9-8199-bd14ccf05f0b", + TopicArn: + "arn:aws:sns:us-east-2:295079676684:publish-and-verify-530b26da-0687-4fe4-9f71-780bad3181e2", + Subject: "Hello world", + Message: "Hello world", + Timestamp: "2022-07-28T22:53:55.086Z", + SignatureVersion: "2", + Signature: + "cETcSvmmkt+My05qCLKexyl0+RyG83mSryKPqTfS+tYcxDJWVcjPJAr+qdpElzVaBl1aTGYVWMY64i9JqZ/JES8pylNj8LGvdhuNQKO59/WCoIimZAsNhn0xEgOeeDU+W/0BU4sdpCGMNjo0S/FuIiWaRe4E0YWRVrxeQevaQ70euDdfWgd5v1eCKQz8b367b9XBmMztL/CWUFI6YaKK/MV21eyvJe3Y7CtVYiOKEYiAZnAEkynK7gUGO5TsgDjGNYhj6U3xYsWgI03bmioSl7kdFSUj+AZ7ugas5fghqxgoDsdfqsjMYKRm5KKHQWsgzI619yIzpNKUiSMHxdZXpQ==", + SigningCertURL: + "https://sns.us-east-2.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:295079676684:publish-and-verify-530b26da-0687-4fe4-9f71-780bad3181e2:db0ad2ad-03d1-48ca-a5da-51f317800a57", + }, + sesComplaintEventWithSubject: { + Type: "Notification", + MessageId: "97903a0c-8818-5442-94b2-60b0c63d3da0", + TopicArn: "arn:aws:sns:us-east-1:364852123998:test-email", + Subject: "Amazon SES Email Event Notification", + Message: + '{"eventType":"Complaint","complaint":{"feedbackId":"0100017fd2f87864-7f070191-4eb1-49e3-a42e-7e996b718724-000000","complaintSubType":null,"complainedRecipients":[{"emailAddress":"complaint@simulator.amazonses.com"}],"timestamp":"2022-03-28T23:59:33.564Z","userAgent":"Amazon SES Mailbox Simulator","complaintFeedbackType":"abuse","arrivalDate":"2022-03-28T23:59:33.564Z"},"mail":{"timestamp":"2022-03-28T23:59:32.804Z","source":"jesus.islasf@alumno.buap.mx","sourceArn":"arn:aws:ses:us-east-1:364852123998:identity/jesus.islasf@alumno.buap.mx","sendingAccountId":"364852123998","messageId":"0100017fd2f875c4-c2929163-2767-49b1-8513-1a29e428e134-000000","destination":["complaint@simulator.amazonses.com"],"headersTruncated":false,"headers":[{"name":"From","value":"jesus.islasf@alumno.buap.mx"},{"name":"To","value":"complaint@simulator.amazonses.com"},{"name":"Subject","value":"Complaint Notification"},{"name":"MIME-Version","value":"1.0"},{"name":"Content-Type","value":"multipart/alternative; boundary=\\"----=_Part_1160373_1543555432.1648511972808\\""}],"commonHeaders":{"from":["jesus.islasf@alumno.buap.mx"],"to":["complaint@simulator.amazonses.com"],"messageId":"0100017fd2f875c4-c2929163-2767-49b1-8513-1a29e428e134-000000","subject":"Complaint Notification"},"tags":{"ses:operation":["SendEmail"],"ses:configuration-set":["test-set"],"ses:source-ip":["189.203.131.80"],"ses:from-domain":["alumno.buap.mx"],"ses:caller-identity":["root"]}}}\n', + Timestamp: "2022-03-28T23:59:33.713Z", + SignatureVersion: "1", + Signature: + "MAnUSvI5C6S0GERh05oFiMtpZuGTW0J/6I/AaBinsZWK+Na8TwJsiNSUjShgZ8NItzQkjBnY1R1qT0wtPf6FfNXxGu3oQRaYsj1alz5NJyiuZwGlryX9LHuOoSJ7tGhrKA5yfYI1JPZsdUJKnkI3+UgKbIg7ml2FoSMU8s3HP3V/FOhvp6V5P6yt2okxABbw13WQPrzeUCZ9pRLgB3TnY59wsWM2SlynWEG/u/pFHyzuvkmtrGZtjZfITm7bfMnGy8FTOox4PfzCu4bysKlUbhc/yJ0fzI/+XsT2gKasXETzlmx6vd4qKgKWP5U9OJVh+Cx//npFDCBI2Tba8JK+Cg==", + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:364852123998:test-email:0fe0bffc-6470-4502-9414-d5e3d9fdd71e", + }, + sesBounceNotificationWithoutSubject: { + Type: "Notification", + MessageId: "71a6b82e-0f1a-58db-b902-e2ef60f87541", + TopicArn: "arn:aws:sns:us-east-1:364852123998:test-email", + Message: + '{"notificationType":"Bounce","bounce":{"feedbackId":"0100017fe7044080-b95aeb18-3fb8-408a-be9b-15fdbac3945a-000000","bounceType":"Permanent","bounceSubType":"General","bouncedRecipients":[{"emailAddress":"bounce@simulator.amazonses.com","action":"failed","status":"5.1.1","diagnosticCode":"smtp; 550 5.1.1 user unknown"}],"timestamp":"2022-04-01T21:24:49.000Z","remoteMtaIp":"3.231.136.178","reportingMTA":"dns; a48-30.smtp-out.amazonses.com"},"mail":{"timestamp":"2022-04-01T21:24:49.422Z","source":"jesus.islasf@alumno.buap.mx","sourceArn":"arn:aws:ses:us-east-1:364852123998:identity/jesus.islasf@alumno.buap.mx","sourceIp":"189.203.131.80","sendingAccountId":"364852123998","messageId":"0100017fe7043e8e-fc9c357d-a9a4-49bc-a4b4-fa984009d568-000000","destination":["bounce@simulator.amazonses.com"],"headersTruncated":false,"headers":[{"name":"From","value":"jesus.islasf@alumno.buap.mx"},{"name":"To","value":"bounce@simulator.amazonses.com"},{"name":"Subject","value":"Subject Test"},{"name":"MIME-Version","value":"1.0"},{"name":"Content-Type","value":"multipart/alternative; boundary=\\"----=_Part_2196631_1474166380.1648848289426\\""}],"commonHeaders":{"from":["jesus.islasf@alumno.buap.mx"],"to":["bounce@simulator.amazonses.com"],"subject":"Subject Test"}}}', + Timestamp: "2022-04-01T21:24:50.200Z", + SignatureVersion: "1", + Signature: + "K27nwTcT0qTPKEQP3noOWV21gDGB6XnLXSwN2i+4176naErwyDSd72w44UesYE/KaRXU+Kusi7b8uoLYPOcYXHH45UCFMrStf9nzu0uIZdMSd7cFGPm0KAxqoDcP9UQw0+ssK1rjVWkywTYmeDyFF/j3IQZxTA/vINOLYbrmMKhyJUPjcZZwdgLmlKcNfKJ5PKmg5WXlr8nWtjW3K+k725nkoAZemuAFt3PmA2k35JoHphkcOBjV2f1qR9zJTOgrVQ1d6k2v6t8G7Nlg6FP5OiwThgKHkehIPfJfLhTmo05tfPCBzXYMzDbnX+HLidvkyibHlalRl/DuDxXXL7SUiA==", + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-7ff5318490ec183fbaddaa2a969abfda.pem", + UnsubscribeURL: + "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:364852123998:test-email:0fe0bffc-6470-4502-9414-d5e3d9fdd71e", + }, +};