feat(AWS SNS Trigger Node): Add webhook request verification (#30456)

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Dawid Myslak
2026-05-18 14:50:41 +00:00
committed by GitHub
co-authored by Cursor
parent 6ba2d50eeb
commit 3ed8910f0b
4 changed files with 681 additions and 4 deletions
@@ -11,6 +11,7 @@ import type {
} from 'n8n-workflow';
import { jsonParse, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import { type AwsSnsMessage, verifySignature } from './AwsSnsTriggerHelpers';
import { awsApiRequestSOAP } from './GenericFunctions';
import { awsNodeAuthOptions, awsNodeCredentials } from './utils';
@@ -245,9 +246,13 @@ export class AwsSnsTrigger implements INodeType {
extractValue: true,
}) as string;
const body = jsonParse<{ Type: string; TopicArn: string; Token: string }>(
req.rawBody.toString(),
);
const body = jsonParse<AwsSnsMessage>(req.rawBody.toString());
if (!(await verifySignature.call(this, body, topic))) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return { noWebhookResponse: true };
}
if (body.Type === 'SubscriptionConfirmation' && body.TopicArn === topic) {
const { Token } = body;
@@ -268,7 +273,6 @@ export class AwsSnsTrigger implements INodeType {
return {};
}
//TODO verify message signature
return {
workflowData: [this.helpers.returnJsonArray(body)],
};
@@ -0,0 +1,212 @@
import { createVerify, X509Certificate } from 'crypto';
import type { IDataObject, IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow';
export interface AwsSnsMessage extends IDataObject {
Type: string;
Message?: string;
MessageId?: string;
Subject?: string;
SubscribeURL?: string;
Timestamp?: string;
Token?: string;
TopicArn?: string;
SignatureVersion?: string;
Signature?: string;
SigningCertURL?: string;
}
type AwsSnsMessageField =
| 'Message'
| 'MessageId'
| 'Subject'
| 'SubscribeURL'
| 'Timestamp'
| 'Token'
| 'TopicArn'
| 'Type';
const certificateCache = new Map<string, X509Certificate>();
export function clearCertificateCache() {
certificateCache.clear();
}
const SIGNABLE_KEYS: readonly AwsSnsMessageField[] = [
'Message',
'MessageId',
'Subject',
'SubscribeURL',
'Timestamp',
'TopicArn',
'Type',
];
const SIGNABLE_KEYS_SUBSCRIPTION: readonly AwsSnsMessageField[] = [
'Message',
'MessageId',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
];
export async function verifySignature(
this: IWebhookFunctions,
message: AwsSnsMessage,
expectedTopicArn: string,
): Promise<boolean> {
try {
if (message.TopicArn !== expectedTopicArn || !hasSignatureFields(message)) {
return false;
}
if (!isValidSigningCertUrl(message.SigningCertURL, message.TopicArn)) {
return false;
}
const algorithm = getSignatureAlgorithm(message.SignatureVersion);
if (!algorithm) {
return false;
}
const certificate = await getCertificate.call(this, message.SigningCertURL);
if (!certificate) {
return false;
}
const stringToSign = buildStringToSign(message);
const verifier = createVerify(algorithm);
verifier.update(stringToSign, 'utf8');
verifier.end();
return verifier.verify(certificate.publicKey, Buffer.from(message.Signature, 'base64'));
} catch {
return false;
}
}
function hasSignatureFields(message: AwsSnsMessage): message is AwsSnsMessage & {
TopicArn: string;
Signature: string;
SignatureVersion: string;
SigningCertURL: string;
} {
return (
typeof message.TopicArn === 'string' &&
typeof message.Signature === 'string' &&
typeof message.SignatureVersion === 'string' &&
typeof message.SigningCertURL === 'string'
);
}
function getSignatureAlgorithm(signatureVersion: string) {
if (signatureVersion === '1') {
return 'RSA-SHA1';
}
if (signatureVersion === '2') {
return 'RSA-SHA256';
}
return null;
}
function buildStringToSign(message: AwsSnsMessage): string {
const keys =
message.Type === 'SubscriptionConfirmation' || message.Type === 'UnsubscribeConfirmation'
? SIGNABLE_KEYS_SUBSCRIPTION
: SIGNABLE_KEYS;
let result = '';
for (const key of keys) {
if (key in message) {
result += key + '\n' + String(message[key]) + '\n';
}
}
return result;
}
function isValidSigningCertUrl(signingCertUrl: string, topicArn: string): boolean {
try {
const url = new URL(signingCertUrl);
const region = getRegionFromTopicArn(topicArn);
if (!region) {
return false;
}
const hostname = url.hostname.toLowerCase();
const validHosts = [`sns.${region}.amazonaws.com`, `sns.${region}.amazonaws.com.cn`];
return (
url.protocol === 'https:' &&
url.username === '' &&
url.password === '' &&
url.port === '' &&
validHosts.includes(hostname) &&
url.pathname.startsWith('/SimpleNotificationService-') &&
url.pathname.endsWith('.pem')
);
} catch {
return false;
}
}
function getRegionFromTopicArn(topicArn: string): string | null {
const arnParts = topicArn.split(':');
if (arnParts.length < 6 || arnParts[2] !== 'sns' || !arnParts[3]) {
return null;
}
return arnParts[3];
}
async function getCertificate(
this: IWebhookFunctions,
signingCertUrl: string,
): Promise<X509Certificate | null> {
const cached = certificateCache.get(signingCertUrl);
if (cached && isCertificateCurrentlyValid(cached)) {
return cached;
}
certificateCache.delete(signingCertUrl);
const certificate = await downloadCertificate.call(this, signingCertUrl);
if (!certificate || !isCertificateCurrentlyValid(certificate)) {
return null;
}
certificateCache.set(signingCertUrl, certificate);
return certificate;
}
async function downloadCertificate(
this: IWebhookFunctions,
signingCertUrl: string,
): Promise<X509Certificate | null> {
const requestOptions: IHttpRequestOptions = {
method: 'GET',
url: signingCertUrl,
disableFollowRedirect: true,
encoding: 'text',
json: false,
};
const certificate: unknown = await this.helpers.httpRequest(requestOptions);
if (typeof certificate !== 'string') {
return null;
}
return new X509Certificate(certificate);
}
function isCertificateCurrentlyValid(certificate: X509Certificate): boolean {
const now = Date.now();
const validFrom = Date.parse(certificate.validFrom);
const validTo = Date.parse(certificate.validTo);
return !Number.isNaN(validFrom) && !Number.isNaN(validTo) && validFrom <= now && now <= validTo;
}
@@ -0,0 +1,122 @@
import type { IWebhookFunctions } from 'n8n-workflow';
import { AwsSnsTrigger } from '../AwsSnsTrigger.node';
import { verifySignature } from '../AwsSnsTriggerHelpers';
import { awsApiRequestSOAP } from '../GenericFunctions';
jest.mock('../AwsSnsTriggerHelpers', () => ({
verifySignature: jest.fn(),
}));
jest.mock('../GenericFunctions', () => ({
awsApiRequestSOAP: jest.fn(),
}));
const topicArn = 'arn:aws:sns:us-east-1:123456789012:MyTopic';
describe('AwsSnsTrigger', () => {
const notification = {
Type: 'Notification',
Message: 'Hello world!',
MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324',
TopicArn: topicArn,
Timestamp: '2012-05-02T00:54:06.655Z',
SignatureVersion: '2',
Signature: 'c2lnbmF0dXJl',
SigningCertURL:
'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-1234567890abcdef1234567890abcdef.pem',
};
const verifySignatureMock = verifySignature as jest.Mock;
const awsApiRequestSOAPMock = awsApiRequestSOAP as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
verifySignatureMock.mockResolvedValue(true);
awsApiRequestSOAPMock.mockResolvedValue({});
});
it('triggers the workflow for a valid notification', async () => {
const node = new AwsSnsTrigger();
const { webhookFunctions, returnJsonArray } = createWebhookFunctions(notification);
const result = await node.webhook.call(webhookFunctions);
expect(verifySignatureMock).toHaveBeenCalledWith(notification, topicArn);
expect(returnJsonArray).toHaveBeenCalledWith(notification);
expect(result).toEqual({ workflowData: [[{ json: notification }]] });
});
it('rejects requests with an invalid signature', async () => {
verifySignatureMock.mockResolvedValue(false);
const node = new AwsSnsTrigger();
const { webhookFunctions, response, returnJsonArray } = createWebhookFunctions(notification);
const result = await node.webhook.call(webhookFunctions);
expect(response.status).toHaveBeenCalledWith(401);
expect(response.send).toHaveBeenCalledWith('Unauthorized');
expect(response.end).toHaveBeenCalled();
expect(returnJsonArray).not.toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('confirms the subscription after validating the request signature', async () => {
const subscriptionConfirmation = {
...notification,
Type: 'SubscriptionConfirmation',
Token: 'token',
};
const node = new AwsSnsTrigger();
const { webhookFunctions } = createWebhookFunctions(subscriptionConfirmation);
const result = await node.webhook.call(webhookFunctions);
expect(verifySignatureMock).toHaveBeenCalledWith(subscriptionConfirmation, topicArn);
expect(awsApiRequestSOAPMock.mock.contexts[0]).toBe(webhookFunctions);
expect(awsApiRequestSOAPMock).toHaveBeenCalledWith(
'sns',
'GET',
'/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:123456789012:MyTopic&Token=token&Version=2010-03-31',
);
expect(result).toEqual({ noWebhookResponse: true });
});
it('does not process unsubscribe confirmations until the signature is valid', async () => {
const unsubscribeConfirmation = {
...notification,
Type: 'UnsubscribeConfirmation',
Token: 'token',
};
const node = new AwsSnsTrigger();
const { webhookFunctions, response } = createWebhookFunctions(unsubscribeConfirmation);
verifySignatureMock.mockResolvedValue(false);
const result = await node.webhook.call(webhookFunctions);
expect(response.status).toHaveBeenCalledWith(401);
expect(result).toEqual({ noWebhookResponse: true });
});
});
function createWebhookFunctions(body: object) {
const response = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
};
const returnJsonArray = jest.fn((data) => [{ json: data }]);
const webhookFunctions = {
getRequestObject: jest.fn().mockReturnValue({
rawBody: Buffer.from(JSON.stringify(body)),
}),
getNodeParameter: jest.fn().mockReturnValue(topicArn),
getResponseObject: jest.fn().mockReturnValue(response),
helpers: {
returnJsonArray,
},
} as unknown as IWebhookFunctions;
return { webhookFunctions, response, returnJsonArray };
}
@@ -0,0 +1,339 @@
import { createSign, createVerify, generateKeyPairSync, X509Certificate } from 'crypto';
import { execFileSync } from 'child_process';
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import type { IWebhookFunctions } from 'n8n-workflow';
import {
type AwsSnsMessage,
clearCertificateCache,
verifySignature,
} from '../AwsSnsTriggerHelpers';
jest.mock('crypto', () => ({
...jest.requireActual('crypto'),
createVerify: jest.fn(),
X509Certificate: jest.fn().mockImplementation(() => ({
publicKey: 'public-key',
validFrom: 'Jan 1 2020 GMT',
validTo: 'Jan 1 2100 GMT',
})),
}));
describe('AwsSnsTriggerHelpers', () => {
const topicArn = 'arn:aws:sns:us-east-1:123456789012:MyTopic';
const signingCertUrl =
'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-1234567890abcdef1234567890abcdef.pem';
const certificate = '-----BEGIN CERTIFICATE-----\ncertificate\n-----END CERTIFICATE-----';
const signature = 'c2lnbmF0dXJl';
const notification: AwsSnsMessage = {
Type: 'Notification',
Message: 'Hello world!',
MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324',
TopicArn: topicArn,
Subject: 'My First Message',
Timestamp: '2012-05-02T00:54:06.655Z',
SignatureVersion: '2',
Signature: signature,
SigningCertURL: signingCertUrl,
};
let mockWebhookFunctions: IWebhookFunctions;
let update: jest.Mock;
let end: jest.Mock;
let verify: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
clearCertificateCache();
update = jest.fn().mockReturnThis();
end = jest.fn();
verify = jest.fn().mockReturnValue(true);
(createVerify as jest.Mock).mockReturnValue({ update, end, verify });
mockWebhookFunctions = {
helpers: {
httpRequest: jest.fn().mockResolvedValue(certificate),
},
} as unknown as IWebhookFunctions;
});
it('returns true when the signature is valid', async () => {
const result = await verifySignature.call(mockWebhookFunctions, notification, topicArn);
expect(result).toBe(true);
expect(mockWebhookFunctions.helpers.httpRequest).toHaveBeenCalledWith({
method: 'GET',
url: signingCertUrl,
disableFollowRedirect: true,
encoding: 'text',
json: false,
});
expect(X509Certificate).toHaveBeenCalledWith(certificate);
expect(createVerify).toHaveBeenCalledWith('RSA-SHA256');
const expectedStringToSign =
`Message\n${notification.Message}\n` +
`MessageId\n${notification.MessageId}\n` +
`Subject\n${notification.Subject}\n` +
`Timestamp\n${notification.Timestamp}\n` +
`TopicArn\n${notification.TopicArn}\n` +
`Type\n${notification.Type}\n`;
expect(update).toHaveBeenCalledWith(expectedStringToSign, 'utf8');
expect(end).toHaveBeenCalled();
expect(verify).toHaveBeenCalledWith('public-key', Buffer.from(signature, 'base64'));
});
it('returns false when the signature is invalid', async () => {
verify.mockReturnValue(false);
const result = await verifySignature.call(mockWebhookFunctions, notification, topicArn);
expect(result).toBe(false);
});
it('returns false when signature fields are missing', async () => {
const result = await verifySignature.call(
mockWebhookFunctions,
{ ...notification, Signature: undefined },
topicArn,
);
expect(result).toBe(false);
expect(mockWebhookFunctions.helpers.httpRequest).not.toHaveBeenCalled();
});
it('returns false when the signing certificate URL is not from SNS', async () => {
const result = await verifySignature.call(
mockWebhookFunctions,
{ ...notification, SigningCertURL: 'https://example.com/cert.pem' },
topicArn,
);
expect(result).toBe(false);
expect(mockWebhookFunctions.helpers.httpRequest).not.toHaveBeenCalled();
});
it('returns false when the message topic does not match the node topic', async () => {
const result = await verifySignature.call(
mockWebhookFunctions,
{
...notification,
TopicArn: 'arn:aws:sns:us-east-1:123456789012:OtherTopic',
},
topicArn,
);
expect(result).toBe(false);
expect(mockWebhookFunctions.helpers.httpRequest).not.toHaveBeenCalled();
});
it('returns false for unsupported SignatureVersion without downloading the certificate', async () => {
const result = await verifySignature.call(
mockWebhookFunctions,
{ ...notification, SignatureVersion: '99' },
topicArn,
);
expect(result).toBe(false);
expect(mockWebhookFunctions.helpers.httpRequest).not.toHaveBeenCalled();
});
it('uses SHA1 for signature version 1', async () => {
const result = await verifySignature.call(
mockWebhookFunctions,
{ ...notification, SignatureVersion: '1' },
topicArn,
);
expect(result).toBe(true);
expect(createVerify).toHaveBeenCalledWith('RSA-SHA1');
});
it('omits Subject from the string to sign when it is not present', async () => {
const { Subject: _, ...message } = notification;
await verifySignature.call(mockWebhookFunctions, message as AwsSnsMessage, topicArn);
const expectedStringToSign =
`Message\n${message.Message}\n` +
`MessageId\n${message.MessageId}\n` +
`Timestamp\n${message.Timestamp}\n` +
`TopicArn\n${message.TopicArn}\n` +
`Type\n${message.Type}\n`;
expect(update).toHaveBeenCalledWith(expectedStringToSign, 'utf8');
});
it('caches the certificate and does not download it again on the second call', async () => {
await verifySignature.call(mockWebhookFunctions, notification, topicArn);
await verifySignature.call(mockWebhookFunctions, notification, topicArn);
expect(mockWebhookFunctions.helpers.httpRequest).toHaveBeenCalledTimes(1);
});
it('includes Token in the string to sign for UnsubscribeConfirmation', async () => {
const message: AwsSnsMessage = {
Type: 'UnsubscribeConfirmation',
Message: 'You have chosen to deactivate subscription.',
MessageId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
SubscribeURL:
'https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:123456789012:MyTopic&Token=abc123',
Timestamp: '2012-05-02T00:54:06.655Z',
Token: 'abc123',
TopicArn: topicArn,
SignatureVersion: '2',
Signature: signature,
SigningCertURL: signingCertUrl,
};
await verifySignature.call(mockWebhookFunctions, message, topicArn);
const expectedStringToSign =
`Message\n${message.Message}\n` +
`MessageId\n${message.MessageId}\n` +
`SubscribeURL\n${message.SubscribeURL}\n` +
`Timestamp\n${message.Timestamp}\n` +
`Token\n${message.Token}\n` +
`TopicArn\n${message.TopicArn}\n` +
`Type\n${message.Type}\n`;
expect(update).toHaveBeenCalledWith(expectedStringToSign, 'utf8');
});
});
describe('AwsSnsTriggerHelpers - integration with real crypto', () => {
const topicArn = 'arn:aws:sns:us-east-1:123456789012:MyTopic';
const signingCertUrl =
'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-integration.pem';
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
let certPem: string;
let tmpDir: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'sns-test-'));
const keyPath = join(tmpDir, 'key.pem');
const certPath = join(tmpDir, 'cert.pem');
const keyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
writeFileSync(keyPath, keyPem);
execFileSync('openssl', [
'req',
'-new',
'-x509',
'-key',
keyPath,
'-out',
certPath,
'-days',
'1',
'-subj',
'/CN=sns-test',
]);
certPem = readFileSync(certPath, 'utf8');
});
afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
function signMessage(message: AwsSnsMessage, algorithm: string): string {
const keys: string[] =
message.Type === 'SubscriptionConfirmation' || message.Type === 'UnsubscribeConfirmation'
? ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type']
: ['Message', 'MessageId', 'Subject', 'SubscribeURL', 'Timestamp', 'TopicArn', 'Type'];
let stringToSign = '';
for (const key of keys) {
if (key in message) {
stringToSign += key + '\n' + String(message[key as keyof AwsSnsMessage]) + '\n';
}
}
const signer = createSign(algorithm);
signer.update(stringToSign, 'utf8');
signer.end();
return signer.sign(privateKey, 'base64');
}
function buildMockWebhookFunctions(certPem: string): IWebhookFunctions {
return {
helpers: {
httpRequest: jest.fn().mockResolvedValue(certPem),
},
} as unknown as IWebhookFunctions;
}
beforeEach(() => {
jest.restoreAllMocks();
clearCertificateCache();
const realCrypto = jest.requireActual('crypto');
(createVerify as jest.Mock).mockImplementation(realCrypto.createVerify);
(X509Certificate as unknown as jest.Mock).mockImplementation(
(pem: string) => new realCrypto.X509Certificate(pem),
);
});
it('verifies a correctly signed Notification using real RSA', async () => {
const message: AwsSnsMessage = {
Type: 'Notification',
Message: 'Integration test message',
MessageId: 'int-test-001',
Subject: 'Test Subject',
TopicArn: topicArn,
Timestamp: '2024-01-01T00:00:00.000Z',
SignatureVersion: '2',
Signature: '',
SigningCertURL: signingCertUrl,
};
message.Signature = signMessage(message, 'RSA-SHA256');
const mock = buildMockWebhookFunctions(certPem);
const result = await verifySignature.call(mock, message, topicArn);
expect(result).toBe(true);
});
it('rejects a tampered message using real RSA', async () => {
const message: AwsSnsMessage = {
Type: 'Notification',
Message: 'Original message',
MessageId: 'int-test-002',
TopicArn: topicArn,
Timestamp: '2024-01-01T00:00:00.000Z',
SignatureVersion: '2',
Signature: '',
SigningCertURL: signingCertUrl,
};
message.Signature = signMessage(message, 'RSA-SHA256');
message.Message = 'Tampered message';
const mock = buildMockWebhookFunctions(certPem);
const result = await verifySignature.call(mock, message, topicArn);
expect(result).toBe(false);
});
it('verifies a SubscriptionConfirmation with real RSA-SHA1', async () => {
const message: AwsSnsMessage = {
Type: 'SubscriptionConfirmation',
Message: 'You have chosen to subscribe.',
MessageId: 'int-test-003',
SubscribeURL: 'https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription',
Timestamp: '2024-01-01T00:00:00.000Z',
Token: 'test-token-123',
TopicArn: topicArn,
SignatureVersion: '1',
Signature: '',
SigningCertURL: signingCertUrl,
};
message.Signature = signMessage(message, 'RSA-SHA1');
const mock = buildMockWebhookFunctions(certPem);
const result = await verifySignature.call(mock, message, topicArn);
expect(result).toBe(true);
});
});