diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 70913c1e..c071977c 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,9 +1,13 @@ -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 { SesHookParser } from "~/server/service/ses-hook-parser"; import { SesSettingsService } from "~/server/service/ses-settings-service"; -import { SnsNotificationMessage } from "~/types/aws-types"; +import { + isSnsNotificationMessage, + isTrustedSnsSubscriptionUrl, + verifySnsMessageSignature, +} from "~/server/security/sns-message-validator"; +import type { SnsNotificationMessage } from "~/types/aws-types"; export const dynamic = "force-dynamic"; @@ -12,16 +16,21 @@ export async function GET() { } export async function POST(req: Request) { - const data = await req.json(); + let data: unknown; + try { + data = await req.json(); + } catch { + return Response.json({ data: "Invalid JSON" }, { status: 400 }); + } - console.log(data, data.Message); + if (!isSnsNotificationMessage(data)) { + return Response.json({ data: "Event is not valid" }, { status: 401 }); + } 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,10 +59,44 @@ 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, { - method: "GET", - }); +async function handleSubscription(message: SnsNotificationMessage) { + if ( + !message.SubscribeURL || + !isTrustedSnsSubscriptionUrl(message.SubscribeURL, message.TopicArn) + ) { + return Response.json( + { data: "Subscription URL is not valid" }, + { status: 400 }, + ); + } + + try { + const response = await fetch(message.SubscribeURL, { + method: "GET", + redirect: "error", + signal: AbortSignal.timeout(5_000), + }); + + if (!response.ok) { + logger.warn( + { status: response.status, topicArn: message.TopicArn }, + "SNS subscription confirmation failed", + ); + return Response.json( + { data: "Subscription confirmation failed" }, + { status: 502 }, + ); + } + } catch (error) { + logger.warn( + { err: error, topicArn: message.TopicArn }, + "SNS subscription confirmation request failed", + ); + return Response.json( + { data: "Subscription confirmation failed" }, + { status: 502 }, + ); + } const topicArn = message.TopicArn as string; const setting = await db.sesSetting.findFirst({ @@ -81,19 +124,14 @@ async function handleSubscription(message: any) { } /** - * A simple check to ensure that the event is from the correct topic + * Ensure the event is signed by SNS and belongs to a configured topic. */ async function checkEventValidity(message: SnsNotificationMessage) { - if (env.NODE_ENV === "development") { - return true; - } - - const { TopicArn } = message; const configuredTopicArn = await SesSettingsService.getTopicArns(); - if (!configuredTopicArn.includes(TopicArn)) { + if (!configuredTopicArn.includes(message.TopicArn)) { return false; } - return true; + return verifySnsMessageSignature(message); } diff --git a/apps/web/src/app/api/ses_callback/route.unit.test.ts b/apps/web/src/app/api/ses_callback/route.unit.test.ts new file mode 100644 index 00000000..8f829b7f --- /dev/null +++ b/apps/web/src/app/api/ses_callback/route.unit.test.ts @@ -0,0 +1,228 @@ +import { createSign, generateKeyPairSync } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SnsNotificationMessage } from "~/types/aws-types"; + +const mocks = vi.hoisted(() => ({ + getTopicArns: vi.fn(), + findSetting: vi.fn(), + updateSetting: vi.fn(), + invalidateCache: vi.fn(), + queue: vi.fn(), +})); + +vi.mock("~/server/db", () => ({ + db: { + sesSetting: { + findFirst: mocks.findSetting, + update: mocks.updateSetting, + }, + }, +})); + +vi.mock("~/server/service/ses-settings-service", () => ({ + SesSettingsService: { + getTopicArns: mocks.getTopicArns, + invalidateCache: mocks.invalidateCache, + }, +})); + +vi.mock("~/server/service/ses-hook-parser", () => ({ + SesHookParser: { + queue: mocks.queue, + }, +})); + +import { POST } from "./route"; + +const topicArn = "arn:aws:sns:eu-west-1:123456789012:usesend-events"; +const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, +}); +const publicKeyPem = publicKey + .export({ type: "spki", format: "pem" }) + .toString(); + +function signedNotification(): SnsNotificationMessage { + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "11111111-2222-3333-4444-555555555555", + TopicArn: topicArn, + Subject: "Amazon SES Email Event Notification", + Message: '{"eventType":"Delivery"}', + Timestamp: "2026-08-22T12:34:56.000Z", + SignatureVersion: "2", + Signature: "", + SigningCertURL: + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem", + }; + const payload = [ + "Message", + "MessageId", + "Subject", + "Timestamp", + "TopicArn", + "Type", + ] + .map( + (field) => + `${field}\n${message[field as keyof SnsNotificationMessage]}\n`, + ) + .join(""); + const signer = createSign("RSA-SHA256"); + signer.update(payload, "utf8"); + signer.end(); + message.Signature = signer.sign(privateKey, "base64"); + return message; +} + +function signedSubscriptionConfirmation(): SnsNotificationMessage { + const message: SnsNotificationMessage = { + Type: "SubscriptionConfirmation", + MessageId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + TopicArn: topicArn, + Message: "You have chosen to subscribe to the topic.", + Timestamp: "2026-08-22T12:34:56.000Z", + SignatureVersion: "2", + Signature: "", + SigningCertURL: + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem", + SubscribeURL: + "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&Token=test", + Token: "test-token", + }; + const payload = [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ] + .map( + (field) => + `${field}\n${message[field as keyof SnsNotificationMessage]}\n`, + ) + .join(""); + const signer = createSign("RSA-SHA256"); + signer.update(payload, "utf8"); + signer.end(); + message.Signature = signer.sign(privateKey, "base64"); + return message; +} + +function requestFor(message: SnsNotificationMessage) { + return new Request("https://send.growthpath.systems/api/ses_callback", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(message), + }); +} + +describe("SES callback", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getTopicArns.mockResolvedValue([topicArn]); + mocks.findSetting.mockResolvedValue({ id: "setting_1" }); + mocks.updateSetting.mockResolvedValue({ id: "setting_1" }); + mocks.queue.mockResolvedValue(true); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("queues a legitimately signed SNS notification", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(publicKeyPem, { status: 200 })), + ); + const message = signedNotification(); + + const response = await POST(requestFor(message)); + + expect(response.status).toBe(200); + expect(mocks.queue).toHaveBeenCalledWith({ + event: { eventType: "Delivery" }, + messageId: message.MessageId, + }); + }); + + it("rejects a tampered notification before it reaches the event queue", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(publicKeyPem, { status: 200 })), + ); + const message = signedNotification(); + message.Message = '{"eventType":"Complaint"}'; + + const response = await POST(requestFor(message)); + + expect(response.status).toBe(401); + expect(mocks.queue).not.toHaveBeenCalled(); + }); + + it("does not follow a subscription URL from an unsigned request", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response(publicKeyPem, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const message = signedNotification(); + message.Type = "SubscriptionConfirmation"; + message.SubscribeURL = "https://internal.example/metadata"; + message.Token = "attacker-token"; + + const response = await POST(requestFor(message)); + + expect(response.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + new URL(message.SigningCertURL), + expect.objectContaining({ redirect: "error" }), + ); + }); + + it("confirms an authentic SNS subscription before recording success", async () => { + const message = signedSubscriptionConfirmation(); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(publicKeyPem, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + const response = await POST(requestFor(message)); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + message.SubscribeURL, + expect.objectContaining({ + redirect: "error", + signal: expect.any(AbortSignal), + }), + ); + expect(mocks.updateSetting).toHaveBeenCalledWith({ + where: { id: "setting_1" }, + data: { callbackSuccess: true }, + }); + expect(mocks.invalidateCache).toHaveBeenCalledOnce(); + }); + + it("does not record a failed SNS subscription confirmation", async () => { + const message = signedSubscriptionConfirmation(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(new Response(publicKeyPem, { status: 200 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })), + ); + + const response = await POST(requestFor(message)); + + expect(response.status).toBe(502); + expect(mocks.updateSetting).not.toHaveBeenCalled(); + expect(mocks.invalidateCache).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/server/security/sns-message-validator.ts b/apps/web/src/server/security/sns-message-validator.ts new file mode 100644 index 00000000..428dc4ea --- /dev/null +++ b/apps/web/src/server/security/sns-message-validator.ts @@ -0,0 +1,202 @@ +import { createVerify } from "node:crypto"; + +import type { SnsNotificationMessage } from "~/types/aws-types"; + +const SNS_MESSAGE_FIELDS = { + Notification: [ + "Message", + "MessageId", + "Subject", + "Timestamp", + "TopicArn", + "Type", + ], + SubscriptionConfirmation: [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ], + UnsubscribeConfirmation: [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ], +} as const; + +type SnsMessageType = keyof typeof SNS_MESSAGE_FIELDS; + +export function isSnsNotificationMessage( + value: unknown, +): value is SnsNotificationMessage { + if (!value || typeof value !== "object") { + return false; + } + + const message = value as Record; + return [ + "Type", + "MessageId", + "TopicArn", + "Message", + "Timestamp", + "SignatureVersion", + "Signature", + "SigningCertURL", + ].every((field) => typeof message[field] === "string"); +} + +function getSnsHostname(topicArn: string) { + const [arn, partition, service, region] = topicArn.split(":"); + + if (arn !== "arn" || service !== "sns" || !region) { + return null; + } + + if (partition === "aws-cn") { + return `sns.${region}.amazonaws.com.cn`; + } + + if (partition === "aws" || partition === "aws-us-gov") { + return `sns.${region}.amazonaws.com`; + } + + return null; +} + +function parseTrustedSnsUrl(value: string, topicArn: string) { + const expectedHostname = getSnsHostname(topicArn); + if (!expectedHostname) { + return null; + } + + try { + const url = new URL(value); + if ( + url.protocol !== "https:" || + url.hostname !== expectedHostname || + url.port || + url.username || + url.password + ) { + return null; + } + + return url; + } catch { + return null; + } +} + +function parseSigningCertificateUrl(value: string, topicArn: string) { + const url = parseTrustedSnsUrl(value, topicArn); + if ( + !url || + url.search || + url.hash || + !/^\/SimpleNotificationService-[A-Za-z0-9_-]+\.pem$/.test(url.pathname) + ) { + return null; + } + + return url; +} + +function isSnsMessageType(value: string): value is SnsMessageType { + return value in SNS_MESSAGE_FIELDS; +} + +function buildSignaturePayload(message: SnsNotificationMessage) { + if (!isSnsMessageType(message.Type)) { + return null; + } + + const fields = SNS_MESSAGE_FIELDS[message.Type]; + let payload = ""; + + for (const field of fields) { + const value = message[field]; + + if (field === "Subject" && value === undefined) { + continue; + } + + if (typeof value !== "string") { + return null; + } + + payload += `${field}\n${value}\n`; + } + + return payload; +} + +async function fetchSigningCertificate(url: URL) { + const response = await fetch(url, { + method: "GET", + redirect: "error", + signal: AbortSignal.timeout(5_000), + }); + + if (!response.ok) { + throw new Error( + `SNS signing certificate request failed: ${response.status}`, + ); + } + + const certificate = await response.text(); + if (certificate.length > 100_000) { + throw new Error("SNS signing certificate is too large"); + } + + return certificate; +} + +type CertificateFetcher = typeof fetchSigningCertificate; + +export function isTrustedSnsSubscriptionUrl(value: string, topicArn: string) { + return Boolean(parseTrustedSnsUrl(value, topicArn)); +} + +export async function verifySnsMessageSignature( + value: unknown, + fetchCertificate: CertificateFetcher = fetchSigningCertificate, +) { + if (!isSnsNotificationMessage(value)) { + return false; + } + + const message = value; + if (message.SignatureVersion !== "1" && message.SignatureVersion !== "2") { + return false; + } + + const certificateUrl = parseSigningCertificateUrl( + message.SigningCertURL, + message.TopicArn, + ); + const payload = buildSignaturePayload(message); + + if (!certificateUrl || !payload || typeof message.Signature !== "string") { + return false; + } + + try { + const certificate = await fetchCertificate(certificateUrl); + const verifier = createVerify( + message.SignatureVersion === "1" ? "RSA-SHA1" : "RSA-SHA256", + ); + verifier.update(payload, "utf8"); + verifier.end(); + return verifier.verify(certificate, message.Signature, "base64"); + } catch { + return false; + } +} diff --git a/apps/web/src/server/security/sns-message-validator.unit.test.ts b/apps/web/src/server/security/sns-message-validator.unit.test.ts new file mode 100644 index 00000000..1ce56918 --- /dev/null +++ b/apps/web/src/server/security/sns-message-validator.unit.test.ts @@ -0,0 +1,180 @@ +import { createSign, generateKeyPairSync } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; + +import { + isSnsNotificationMessage, + isTrustedSnsSubscriptionUrl, + verifySnsMessageSignature, +} from "./sns-message-validator"; +import type { SnsNotificationMessage } from "~/types/aws-types"; + +const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, +}); +const publicKeyPem = publicKey + .export({ type: "spki", format: "pem" }) + .toString(); + +function signaturePayload(message: SnsNotificationMessage) { + const fields = + message.Type === "Notification" + ? ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] + : [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type", + ]; + + return fields + .filter((field) => field !== "Subject" || message.Subject !== undefined) + .map( + (field) => + `${field}\n${message[field as keyof SnsNotificationMessage]}\n`, + ) + .join(""); +} + +function signedMessage( + overrides: Partial = {}, +): SnsNotificationMessage { + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "11111111-2222-3333-4444-555555555555", + TopicArn: "arn:aws:sns:eu-west-1:123456789012:usesend-events", + Subject: "Amazon SES Email Event Notification", + Message: '{"eventType":"Delivery"}', + Timestamp: "2026-08-22T12:34:56.000Z", + SignatureVersion: "2", + Signature: "", + SigningCertURL: + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem", + ...overrides, + }; + + const signer = createSign( + message.SignatureVersion === "1" ? "RSA-SHA1" : "RSA-SHA256", + ); + signer.update(signaturePayload(message), "utf8"); + signer.end(); + message.Signature = signer.sign(privateKey, "base64"); + return message; +} + +describe("verifySnsMessageSignature", () => { + it("rejects malformed payloads", async () => { + expect(isSnsNotificationMessage(null)).toBe(false); + await expect(verifySnsMessageSignature({})).resolves.toBe(false); + }); + + it("accepts an authentic notification", async () => { + const fetchCertificate = vi.fn().mockResolvedValue(publicKeyPem); + + await expect( + verifySnsMessageSignature(signedMessage(), fetchCertificate), + ).resolves.toBe(true); + expect(fetchCertificate).toHaveBeenCalledWith( + new URL( + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem", + ), + ); + }); + + it("accepts an authentic version 1 subscription confirmation", async () => { + const message = signedMessage({ + Type: "SubscriptionConfirmation", + SignatureVersion: "1", + SubscribeURL: + "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&Token=test", + Token: "test-token", + }); + + await expect( + verifySnsMessageSignature(message, async () => publicKeyPem), + ).resolves.toBe(true); + }); + + it("rejects a message changed after signing", async () => { + const message = signedMessage(); + message.Message = '{"eventType":"Complaint"}'; + + await expect( + verifySnsMessageSignature(message, async () => publicKeyPem), + ).resolves.toBe(false); + }); + + it("rejects an attacker-controlled certificate host without fetching it", async () => { + const fetchCertificate = vi.fn().mockResolvedValue(publicKeyPem); + const message = signedMessage({ + SigningCertURL: + "https://sns.eu-west-1.amazonaws.com.evil.example/SimpleNotificationService-test.pem", + }); + + await expect( + verifySnsMessageSignature(message, fetchCertificate), + ).resolves.toBe(false); + expect(fetchCertificate).not.toHaveBeenCalled(); + }); + + it("rejects a certificate URL from a different SNS region", async () => { + const fetchCertificate = vi.fn().mockResolvedValue(publicKeyPem); + const message = signedMessage({ + SigningCertURL: + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-test.pem", + }); + + await expect( + verifySnsMessageSignature(message, fetchCertificate), + ).resolves.toBe(false); + expect(fetchCertificate).not.toHaveBeenCalled(); + }); + + it.each([ + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem?x=1", + "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem#fragment", + "https://sns.eu-west-1.amazonaws.com:8443/SimpleNotificationService-test.pem", + "https://user:pass@sns.eu-west-1.amazonaws.com/SimpleNotificationService-test.pem", + "https://sns.eu-west-1.amazonaws.com/not-an-sns-certificate.pem", + ])("rejects an unsafe certificate URL: %s", async (SigningCertURL) => { + const fetchCertificate = vi.fn().mockResolvedValue(publicKeyPem); + + await expect( + verifySnsMessageSignature( + signedMessage({ SigningCertURL }), + fetchCertificate, + ), + ).resolves.toBe(false); + expect(fetchCertificate).not.toHaveBeenCalled(); + }); +}); + +describe("isTrustedSnsSubscriptionUrl", () => { + const topicArn = "arn:aws:sns:eu-west-1:123456789012:usesend-events"; + + it("accepts the matching regional SNS endpoint", () => { + expect( + isTrustedSnsSubscriptionUrl( + "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription", + topicArn, + ), + ).toBe(true); + }); + + it("rejects lookalike and non-HTTPS endpoints", () => { + expect( + isTrustedSnsSubscriptionUrl( + "https://sns.eu-west-1.amazonaws.com.evil.example/confirm", + topicArn, + ), + ).toBe(false); + expect( + isTrustedSnsSubscriptionUrl( + "http://sns.eu-west-1.amazonaws.com/confirm", + topicArn, + ), + ).toBe(false); + }); +}); diff --git a/apps/web/src/types/aws-types.ts b/apps/web/src/types/aws-types.ts index ddcbb118..4ee204b1 100644 --- a/apps/web/src/types/aws-types.ts +++ b/apps/web/src/types/aws-types.ts @@ -8,7 +8,8 @@ export interface SnsNotificationMessage { SignatureVersion: string; Signature: string; SigningCertURL: string; - UnsubscribeURL: string; + SubscribeURL?: string; + Token?: string; } export interface SesMail {