mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
feat(AWS SNS Trigger Node): Add webhook request verification (#30456)
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user