diff --git a/apps/web/src/app/api/ses_callback/route.ts b/apps/web/src/app/api/ses_callback/route.ts index 70913c1e..8d36b89b 100644 --- a/apps/web/src/app/api/ses_callback/route.ts +++ b/apps/web/src/app/api/ses_callback/route.ts @@ -1,9 +1,10 @@ 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 { verifySnsMessageSignature } from "~/server/aws/sns-message-validator"; export const dynamic = "force-dynamic"; @@ -34,7 +35,7 @@ export async function POST(req: Request) { message = JSON.parse(data.Message || "{}"); const status = await SesHookParser.queue({ event: message, - messageId: data.MessageId, + messageId: data.MessageId }); if (!status) { return Response.json({ data: "Error in parsing hook" }); @@ -52,14 +53,14 @@ export async function POST(req: Request) { */ async function handleSubscription(message: any) { await fetch(message.SubscribeURL, { - method: "GET", + method: "GET" }); const topicArn = message.TopicArn as string; const setting = await db.sesSetting.findFirst({ where: { - topicArn, - }, + topicArn + } }); if (!setting) { @@ -68,11 +69,11 @@ async function handleSubscription(message: any) { await db.sesSetting.update({ where: { - id: setting?.id, + id: setting?.id }, data: { - callbackSuccess: true, - }, + callbackSuccess: true + } }); SesSettingsService.invalidateCache(); @@ -81,7 +82,7 @@ async function handleSubscription(message: any) { } /** - * A simple check to ensure that the event is from the correct topic + * A simple check to ensure that the event is from the correct topic and has a valid signature */ async function checkEventValidity(message: SnsNotificationMessage) { if (env.NODE_ENV === "development") { @@ -95,5 +96,15 @@ async function checkEventValidity(message: SnsNotificationMessage) { return false; } + // Verify the SNS message signature + const isSignatureValid = await verifySnsMessageSignature(message); + if (!isSignatureValid) { + logger.error({ + topicArn: TopicArn, + msg: "Rejected SNS message with invalid signature" + }); + return false; + } + return true; } 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 00000000..62f76296 --- /dev/null +++ b/apps/web/src/server/aws/sns-message-validator.ts @@ -0,0 +1,183 @@ +import { createVerify } from "crypto"; +import https from "https"; +import { SnsNotificationMessage } from "~/types/aws-types"; + +/** + * Extracts the region from an SNS TopicArn. + * TopicArn format: arn:aws:sns:REGION:ACCOUNT-ID:TOPIC-NAME + */ +function extractRegionFromTopicArn(topicArn: string): string | null { + const parts = topicArn.split(":"); + if (parts.length >= 4 && parts[0] === "arn" && parts[2] === "sns") { + return parts[3]; + } + return null; +} + +/** + * Validates that the signing certificate URL is from an AWS SNS host in the correct region. + * Prevents SSRF attacks by: + * 1. Deriving the expected region from TopicArn + * 2. Validating the certificate URL hostname matches that region + * 3. Enforcing HTTPS protocol + * 4. Verifying the path contains expected SNS certificate path pattern + */ +function isValidSnsSigningCertificateUrl( + url: string, + topicArn: string +): boolean { + try { + const urlObj = new URL(url); + + // Extract region from TopicArn + const region = extractRegionFromTopicArn(topicArn); + if (!region) { + return false; + } + + // Build expected hostname patterns for this region + const expectedHostnames = [ + `sns.${region}.amazonaws.com`, + // Some regions might use the regional endpoint + `sns.amazonaws.com` + ]; + + // Check if the certificate URL hostname matches expected patterns + const isExpectedHost = expectedHostnames.some( + (host) => urlObj.hostname === host + ); + + // Ensure it's HTTPS and has the expected SNS certificate path format + return ( + urlObj.protocol === "https:" && + (urlObj.pathname.includes("/sns/") || urlObj.pathname.endsWith(".pem")) && + isExpectedHost + ); + } catch { + return false; + } +} + +/** + * Builds the canonical string to sign for SNS message signature verification. + * AWS SNS signs a string where every field is terminated by a newline, including the last one. + * Field order depends on message type: + * - Notification: Message, MessageId, Subject, Timestamp, TopicArn, Type + * - SubscriptionConfirmation: Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, Type + * Format: field1\nvalue1\nfield2\nvalue2\n...\n + */ +export function buildSnsStringToSign(message: SnsNotificationMessage): string { + let fields: string[]; + const values: Record = {}; + + if (message.Type === "SubscriptionConfirmation") { + // SubscriptionConfirmation message field order + fields = [ + "Message", + "MessageId", + "SubscribeURL", + "Timestamp", + "Token", + "TopicArn", + "Type" + ]; + values.Message = message.Message; + values.MessageId = message.MessageId; + values.SubscribeURL = message.SubscribeURL; + values.Timestamp = message.Timestamp; + values.Token = message.Token; + values.TopicArn = message.TopicArn; + values.Type = message.Type; + } else { + // Notification message field order (default) + fields = [ + "Message", + "MessageId", + "Subject", + "Timestamp", + "TopicArn", + "Type" + ]; + values.Message = message.Message; + values.MessageId = message.MessageId; + values.Subject = message.Subject; + values.Timestamp = message.Timestamp; + values.TopicArn = message.TopicArn; + values.Type = message.Type; + } + + return fields + .filter((field) => values[field] !== undefined) + .map((field) => `${field}\n${values[field] as string}\n`) + .join(""); +} + +/** + * Fetches the certificate from the SigningCertURL after validation. + * Validates that the URL is from an AWS SNS host in the same region as TopicArn before fetching. + */ +export async function getCertificate( + signingCertUrl: string, + topicArn: string +): Promise { + // Validate the URL before making any network request + if (!isValidSnsSigningCertificateUrl(signingCertUrl, topicArn)) { + const region = extractRegionFromTopicArn(topicArn); + throw new Error( + `Invalid SNS signing certificate URL: must be from aws SNS host in region ${region}` + ); + } + + return new Promise((resolve, reject) => { + https + .get(signingCertUrl, (res) => { + let data = ""; + res.on("data", (chunk) => (data += chunk)); + res.on("end", () => resolve(data)); + }) + .on("error", reject); + }); +} + +/** + * Determines the hash algorithm based on SignatureVersion. + * Version 1 uses RSA-SHA1, Version 2 uses RSA-SHA256. + */ +function getSignatureAlgorithm(signatureVersion: string): string { + switch (signatureVersion) { + case "1": + return "RSA-SHA1"; + case "2": + return "RSA-SHA256"; + default: + throw new Error( + `Unsupported SNS SignatureVersion: ${signatureVersion}. Supported versions are 1 (RSA-SHA1) and 2 (RSA-SHA256)` + ); + } +} + +/** + * Verifies the SNS message signature using the appropriate algorithm based on SignatureVersion. + */ +export async function verifySnsMessageSignature( + message: SnsNotificationMessage +): Promise { + try { + // Validate signature version before proceeding + const algorithm = getSignatureAlgorithm(message.SignatureVersion); + + const stringToSign = buildSnsStringToSign(message); + const certificate = await getCertificate( + message.SigningCertURL, + message.TopicArn + ); + + const verifier = createVerify(algorithm); + verifier.update(stringToSign); + + return verifier.verify(certificate, message.Signature, "base64"); + } catch (error) { + console.error("Error verifying SNS message signature:", error); + 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 00000000..4eab9741 --- /dev/null +++ b/apps/web/src/server/aws/sns-message-validator.unit.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi } from "vitest"; +import { + buildSnsStringToSign, + getCertificate +} from "~/server/aws/sns-message-validator"; +import { SnsNotificationMessage } from "~/types/aws-types"; +// Mock https.get to prevent actual network requests +vi.mock("https", () => ({ + default: { + get: vi.fn(() => ({ + on: vi.fn((event: string, handler: any) => { + if (event === "error") { + handler(new Error("Certificate fetch failed (mocked)")); + } + return { + on: vi.fn() + }; + }) + })) + } +})); +describe("buildSnsStringToSign", () => { + it("uses the SNS canonical field order", () => { + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "12345", + TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", + Subject: "Test Subject", + Message: "Test Message", + Timestamp: "2024-01-01T00:00:00Z", + SignatureVersion: "1", + Signature: "fake-signature", + SigningCertURL: "https://example.com/cert.pem", + UnsubscribeURL: "https://example.com/unsubscribe" + }; + + const result = buildSnsStringToSign(message); + + // The canonical form should include each field with its value, each terminated by a newline + // including the final value + const expected = + "Message\nTest Message\n" + + "MessageId\n12345\n" + + "Subject\nTest Subject\n" + + "Timestamp\n2024-01-01T00:00:00Z\n" + + "TopicArn\narn:aws:sns:us-east-1:123456789012:test-topic\n" + + "Type\nNotification\n"; + + expect(result).toBe(expected); + }); + + it("omits fields with undefined values", () => { + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "12345", + TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", + Message: "Test Message", + Timestamp: "2024-01-01T00:00:00Z", + SignatureVersion: "1", + Signature: "fake-signature", + SigningCertURL: "https://example.com/cert.pem", + UnsubscribeURL: "https://example.com/unsubscribe" + // Subject is intentionally omitted (undefined) + }; + + const result = buildSnsStringToSign(message); + + // Subject field should not be in the result + expect(result).not.toContain("Subject"); + + const expected = + "Message\nTest Message\n" + + "MessageId\n12345\n" + + "Timestamp\n2024-01-01T00:00:00Z\n" + + "TopicArn\narn:aws:sns:us-east-1:123456789012:test-topic\n" + + "Type\nNotification\n"; + + expect(result).toBe(expected); + }); + + it("terminates the final value with a newline", () => { + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "12345", + TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", + Message: "Test Message", + Timestamp: "2024-01-01T00:00:00Z", + SignatureVersion: "1", + Signature: "fake-signature", + SigningCertURL: "https://example.com/cert.pem", + UnsubscribeURL: "https://example.com/unsubscribe" + }; + + const result = buildSnsStringToSign(message); + + // The string must end with a newline (this was the original bug) + expect(result).toMatch(/\n$/); + }); + + it("correctly signs and verifies against independently known string", () => { + // This test uses a known string and independently verifies it can be signed/verified + // The canonical form has each field name and value separated by newlines, + // with a newline after each value + const canonicalString = + "Message\nTest Message\n" + + "MessageId\n12345\n" + + "Timestamp\n2024-01-01T00:00:00Z\n" + + "TopicArn\narn:aws:sns:us-east-1:123456789012:test-topic\n" + + "Type\nNotification\n"; + + const message: SnsNotificationMessage = { + Type: "Notification", + MessageId: "12345", + TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", + Message: "Test Message", + Timestamp: "2024-01-01T00:00:00Z", + SignatureVersion: "1", + Signature: "", + SigningCertURL: "https://example.com/cert.pem", + UnsubscribeURL: "https://example.com/unsubscribe" + }; + + const result = buildSnsStringToSign(message); + expect(result).toBe(canonicalString); + }); + + it("uses different field order for SubscriptionConfirmation messages", () => { + const message: SnsNotificationMessage = { + Type: "SubscriptionConfirmation", + MessageId: "sub-123", + TopicArn: "arn:aws:sns:us-east-1:123456789012:test-topic", + Message: "You have chosen to subscribe to the topic...", + Timestamp: "2024-01-01T00:00:00Z", + Token: "token-value", + SubscribeURL: "https://sns.amazonaws.com/?Action=ConfirmSubscription&...", + SignatureVersion: "1", + Signature: "fake-signature", + SigningCertURL: "https://example.com/cert.pem" + }; + + const result = buildSnsStringToSign(message); + + // SubscriptionConfirmation uses different field order: + // Message, MessageId, SubscribeURL, Timestamp, Token, TopicArn, Type + const expected = + "Message\nYou have chosen to subscribe to the topic...\n" + + "MessageId\nsub-123\n" + + "SubscribeURL\nhttps://sns.amazonaws.com/?Action=ConfirmSubscription&...\n" + + "Timestamp\n2024-01-01T00:00:00Z\n" + + "Token\ntoken-value\n" + + "TopicArn\narn:aws:sns:us-east-1:123456789012:test-topic\n" + + "Type\nSubscriptionConfirmation\n"; + + expect(result).toBe(expected); + }); +}); + +describe("getCertificate URL validation", () => { + it("accepts certificates from SNS hosts in the same region as TopicArn", async () => { + const topicArn = "arn:aws:sns:us-east-1:123456789012:test-topic"; + const signingCertUrl = + "https://sns.us-east-1.amazonaws.com/SNSCertificate.pem"; + + // Should not throw with valid certificate URL + try { + await getCertificate(signingCertUrl, topicArn); + } catch (error: any) { + // Certificate fetch will fail, but URL validation should pass + expect(error.message).not.toContain( + "Invalid SNS signing certificate URL" + ); + } + }); + + it("accepts certificates from eu-central-2 (new region)", async () => { + const topicArn = "arn:aws:sns:eu-central-2:123456789012:test-topic"; + const signingCertUrl = + "https://sns.eu-central-2.amazonaws.com/SNSCertificate.pem"; + + // Should not throw with valid certificate URL + try { + await getCertificate(signingCertUrl, topicArn); + } catch (error: any) { + // Certificate fetch will fail, but URL validation should pass + expect(error.message).not.toContain( + "Invalid SNS signing certificate URL" + ); + } + }); + + it("accepts certificates from ap-southeast-4 (new region)", async () => { + const topicArn = "arn:aws:sns:ap-southeast-4:123456789012:test-topic"; + const signingCertUrl = + "https://sns.ap-southeast-4.amazonaws.com/SNSCertificate.pem"; + + // Should not throw with valid certificate URL + try { + await getCertificate(signingCertUrl, topicArn); + } catch (error: any) { + // Certificate fetch will fail, but URL validation should pass + expect(error.message).not.toContain( + "Invalid SNS signing certificate URL" + ); + } + }); + + it("rejects certificates from mismatched regions", async () => { + const topicArn = "arn:aws:sns:us-east-1:123456789012:test-topic"; + const signingCertUrl = + "https://sns.eu-west-1.amazonaws.com/SNSCertificate.pem"; // Wrong region + + await expect(getCertificate(signingCertUrl, topicArn)).rejects.toThrow( + "Invalid SNS signing certificate URL" + ); + }); + + it("rejects non-HTTPS certificate URLs", async () => { + const topicArn = "arn:aws:sns:us-east-1:123456789012:test-topic"; + const signingCertUrl = "http://sns.us-east-1.amazonaws.com/cert.pem"; // HTTP not HTTPS + + await expect(getCertificate(signingCertUrl, topicArn)).rejects.toThrow( + "Invalid SNS signing certificate URL" + ); + }); + + it("rejects invalid TopicArn format", async () => { + const topicArn = "invalid-arn"; + const signingCertUrl = + "https://sns.us-east-1.amazonaws.com/SNSCertificate.pem"; + + await expect(getCertificate(signingCertUrl, topicArn)).rejects.toThrow( + "Invalid SNS signing certificate URL" + ); + }); +}); diff --git a/apps/web/src/types/aws-types.ts b/apps/web/src/types/aws-types.ts index ddcbb118..f20bee7c 100644 --- a/apps/web/src/types/aws-types.ts +++ b/apps/web/src/types/aws-types.ts @@ -8,7 +8,9 @@ export interface SnsNotificationMessage { SignatureVersion: string; Signature: string; SigningCertURL: string; - UnsubscribeURL: string; + UnsubscribeURL?: string; + SubscribeURL?: string; // For SubscriptionConfirmation messages + Token?: string; // For SubscriptionConfirmation messages } export interface SesMail {