feat: implement AWS SNS signature verification in SecurityService
This commit is contained in:
@@ -36,6 +36,13 @@ export class Webhooks {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||||
try {
|
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)
|
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||||
signale.info('SNS Subscription Confirmation received');
|
signale.info('SNS Subscription Confirmation received');
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -84,9 +86,76 @@ interface SecurityStatus {
|
|||||||
warnings: string[];
|
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 {
|
export class SecurityService {
|
||||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
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)
|
* Get security status for a project (with caching)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user