feat: implement AWS SNS signature verification in SecurityService

This commit is contained in:
Dries Augustyns
2026-04-20 18:40:19 +02:00
parent bf12392b88
commit b79d416667
2 changed files with 76 additions and 0 deletions
+7
View File
@@ -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<string, string>);
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');
+69
View File
@@ -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<string, string>();
async function fetchSigningCert(certUrl: string): Promise<string> {
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, string>): 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<string, string>): Promise<boolean> {
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)
*/