From b79d4166671cc04cc1458d2c24af262af0e16c9e Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 20 Apr 2026 18:40:19 +0200 Subject: [PATCH] feat: implement AWS SNS signature verification in SecurityService --- apps/api/src/controllers/Webhooks.ts | 7 +++ apps/api/src/services/SecurityService.ts | 69 ++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/apps/api/src/controllers/Webhooks.ts b/apps/api/src/controllers/Webhooks.ts index b13fb08..886fbcc 100644 --- a/apps/api/src/controllers/Webhooks.ts +++ b/apps/api/src/controllers/Webhooks.ts @@ -36,6 +36,13 @@ export class Webhooks { @CatchAsync public async receiveSNSWebhook(req: Request, res: Response) { try { + // Verify SNS message signature before processing anything + const signatureValid = await SecurityService.verifySnsSignature(req.body as Record); + if (!signatureValid) { + signale.warn('[WEBHOOK] SNS signature verification failed — request rejected'); + return res.status(403).json({success: false, message: 'Invalid SNS signature'}); + } + // Handle SNS subscription confirmation FIRST (before parsing Message field) if (req.body.Type === 'SubscriptionConfirmation') { signale.info('SNS Subscription Confirmation received'); diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index d75cc26..5281da2 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -1,3 +1,5 @@ +import crypto from 'crypto'; + import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email'; import React from 'react'; import signale from 'signale'; @@ -84,9 +86,76 @@ interface SecurityStatus { warnings: string[]; } +const SNS_CERT_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|cn)$/; +const snsSigningCertCache = new Map(); + +async function fetchSigningCert(certUrl: string): Promise { + const cached = snsSigningCertCache.get(certUrl); + if (cached) return cached; + + const response = await fetch(certUrl); + if (!response.ok) throw new Error(`Failed to fetch SNS signing cert: ${response.statusText}`); + + const pem = await response.text(); + snsSigningCertCache.set(certUrl, pem); + return pem; +} + +function buildSnsStringToSign(message: Record): string { + const fields = + message['Type'] === 'Notification' + ? ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type'] + : ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type']; + + return fields + .filter(key => message[key] !== undefined) + .map(key => `${key}\n${message[key]}\n`) + .join(''); +} + export class SecurityService { private static readonly CACHE_TTL = 300; // 5 minutes + /** + * Verify an AWS SNS message signature. Returns false if the cert URL is + * untrusted, or the signature doesn't match. + */ + public static async verifySnsSignature(body: Record): Promise { + try { + const certUrl = body['SigningCertURL']; + const signature = body['Signature']; + + if (!certUrl || !signature) { + signale.warn('[SNS] Missing SigningCertURL or Signature'); + return false; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(certUrl); + } catch { + signale.warn('[SNS] Unparseable SigningCertURL'); + return false; + } + + if (parsedUrl.protocol !== 'https:' || !SNS_CERT_HOST_RE.test(parsedUrl.hostname)) { + signale.warn(`[SNS] Untrusted SigningCertURL host: ${parsedUrl.hostname}`); + return false; + } + + const pem = await fetchSigningCert(certUrl); + const stringToSign = buildSnsStringToSign(body); + const algorithm = body['SignatureVersion'] === '2' ? 'RSA-SHA256' : 'RSA-SHA1'; + + const verifier = crypto.createVerify(algorithm); + verifier.update(stringToSign, 'utf8'); + return verifier.verify(pem, signature, 'base64'); + } catch (err) { + signale.error('[SNS] Signature verification error:', err); + return false; + } + } + /** * Get security status for a project (with caching) */