mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(Slack Trigger Node): Add support for signature verification (#17838)
This commit is contained in:
@@ -443,7 +443,7 @@ describe('AI Assistant Credential Help', () => {
|
|||||||
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
|
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
|
||||||
|
|
||||||
credentialsModal.getters.credentialAuthTypeRadioButtons().eq(1).click();
|
credentialsModal.getters.credentialAuthTypeRadioButtons().eq(1).click();
|
||||||
credentialsModal.getters.credentialInputs().should('have.length', 1);
|
credentialsModal.getters.credentialInputs().should('have.length', 3);
|
||||||
aiAssistant.getters.credentialEditAssistantButton().should('exist');
|
aiAssistant.getters.credentialEditAssistantButton().should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,27 @@ export class SlackApi implements ICredentialType {
|
|||||||
default: '',
|
default: '',
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Signature Secret',
|
||||||
|
name: 'signatureSecret',
|
||||||
|
type: 'string',
|
||||||
|
typeOptions: { password: true },
|
||||||
|
default: '',
|
||||||
|
description:
|
||||||
|
'The signature secret is used to verify the authenticity of requests sent by Slack.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName:
|
||||||
|
'We strongly recommend setting up a <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#verify-the-webhook" target="_blank">signing secret</a> to ensure the authenticity of requests.',
|
||||||
|
name: 'notice',
|
||||||
|
type: 'notice',
|
||||||
|
default: '',
|
||||||
|
displayOptions: {
|
||||||
|
show: {
|
||||||
|
signatureSecret: [''],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
authenticate: IAuthenticateGeneric = {
|
authenticate: IAuthenticateGeneric = {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
NodeConnectionTypes,
|
NodeConnectionTypes,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { downloadFile, getChannelInfo, getUserInfo } from './SlackTriggerHelpers';
|
import { downloadFile, getChannelInfo, getUserInfo, verifySignature } from './SlackTriggerHelpers';
|
||||||
import { slackApiRequestAllItems } from './V2/GenericFunctions';
|
import { slackApiRequestAllItems } from './V2/GenericFunctions';
|
||||||
|
|
||||||
export class SlackTrigger implements INodeType {
|
export class SlackTrigger implements INodeType {
|
||||||
@@ -53,7 +53,7 @@ export class SlackTrigger implements INodeType {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
displayName:
|
displayName:
|
||||||
'Set up a webhook in your Slack app to enable this node. <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#configure-a-webhook-in-slack" target="_blank">More info</a>',
|
'Set up a webhook in your Slack app to enable this node. <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#configure-a-webhook-in-slack" target="_blank">More info</a>. We also recommend setting up a <a href="https://docs.n8n.io/integrations/builtin/trigger-nodes/n8n-nodes-base.slacktrigger/#verify-the-webhook" target="_blank">signing secret</a> to ensure the authenticity of requests.',
|
||||||
name: 'notice',
|
name: 'notice',
|
||||||
type: 'notice',
|
type: 'notice',
|
||||||
default: '',
|
default: '',
|
||||||
@@ -321,8 +321,17 @@ export class SlackTrigger implements INodeType {
|
|||||||
const binaryData: IBinaryKeyData = {};
|
const binaryData: IBinaryKeyData = {};
|
||||||
const watchWorkspace = this.getNodeParameter('watchWorkspace', false) as boolean;
|
const watchWorkspace = this.getNodeParameter('watchWorkspace', false) as boolean;
|
||||||
let eventChannel: string = '';
|
let eventChannel: string = '';
|
||||||
// Check if the request is a challenge request
|
|
||||||
|
if (!(await verifySignature.call(this))) {
|
||||||
|
const res = this.getResponseObject();
|
||||||
|
res.status(401).send('Unauthorized').end();
|
||||||
|
return {
|
||||||
|
noWebhookResponse: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (req.body.type === 'url_verification') {
|
if (req.body.type === 'url_verification') {
|
||||||
|
// Check if the request is a challenge request
|
||||||
const res = this.getResponseObject();
|
const res = this.getResponseObject();
|
||||||
res.status(200).json({ challenge: req.body.challenge }).end();
|
res.status(200).json({ challenge: req.body.challenge }).end();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow';
|
import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow';
|
||||||
import { NodeOperationError } from 'n8n-workflow';
|
import { NodeOperationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
|
||||||
import { slackApiRequest } from './V2/GenericFunctions';
|
import { slackApiRequest } from './V2/GenericFunctions';
|
||||||
|
|
||||||
export async function getUserInfo(this: IWebhookFunctions, userId: string): Promise<any> {
|
export async function getUserInfo(this: IWebhookFunctions, userId: string): Promise<any> {
|
||||||
@@ -78,3 +80,57 @@ export async function downloadFile(this: IWebhookFunctions, url: string): Promis
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
|
||||||
|
const credential = await this.getCredentials('slackApi');
|
||||||
|
if (!credential?.signatureSecret) {
|
||||||
|
return true; // No signature secret provided, skip verification
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = this.getRequestObject();
|
||||||
|
|
||||||
|
const signature = req.header('x-slack-signature');
|
||||||
|
const timestamp = req.header('x-slack-request-timestamp');
|
||||||
|
if (!signature || !timestamp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const timestampNum = parseInt(timestamp, 10);
|
||||||
|
if (isNaN(timestampNum) || Math.abs(currentTime - timestampNum) > 60 * 5) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof credential.signatureSecret !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.rawBody) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hmac = createHmac('sha256', credential.signatureSecret);
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(req.rawBody)) {
|
||||||
|
hmac.update(`v0:${timestamp}:`);
|
||||||
|
hmac.update(req.rawBody);
|
||||||
|
} else {
|
||||||
|
const rawBodyString =
|
||||||
|
typeof req.rawBody === 'string' ? req.rawBody : JSON.stringify(req.rawBody);
|
||||||
|
hmac.update(`v0:${timestamp}:${rawBodyString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedSignature = `v0=${hmac.digest('hex')}`;
|
||||||
|
|
||||||
|
const computedBuffer = Buffer.from(computedSignature);
|
||||||
|
const providedBuffer = Buffer.from(signature);
|
||||||
|
|
||||||
|
return (
|
||||||
|
computedBuffer.length === providedBuffer.length &&
|
||||||
|
timingSafeEqual(computedBuffer, providedBuffer)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
162
packages/nodes-base/nodes/Slack/test/SlackTriggerHelper.test.ts
Normal file
162
packages/nodes-base/nodes/Slack/test/SlackTriggerHelper.test.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto';
|
||||||
|
import { verifySignature } from '../SlackTriggerHelpers';
|
||||||
|
|
||||||
|
// Mock crypto module
|
||||||
|
jest.mock('crypto', () => ({
|
||||||
|
createHmac: jest.fn().mockReturnValue({
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
digest: jest
|
||||||
|
.fn()
|
||||||
|
.mockReturnValue('a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503'),
|
||||||
|
}),
|
||||||
|
timingSafeEqual: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('SlackTriggerHelpers', () => {
|
||||||
|
let mockWebhookFunctions: any;
|
||||||
|
const testSignatureSecret = 'xyzz0WbapA4vBCDEFasx0q6G';
|
||||||
|
const testTimestamp = '1531420618';
|
||||||
|
const testBody =
|
||||||
|
'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c';
|
||||||
|
const testSignature = 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock Date.now() to return a fixed timestamp
|
||||||
|
const fixedDate = new Date(parseInt(testTimestamp, 10) * 1000);
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => fixedDate.getTime());
|
||||||
|
|
||||||
|
mockWebhookFunctions = {
|
||||||
|
getCredentials: jest.fn(),
|
||||||
|
getRequestObject: jest.fn(),
|
||||||
|
getNode: jest.fn().mockReturnValue({ name: 'Slack Trigger' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Default mock return values
|
||||||
|
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||||
|
header: jest.fn().mockImplementation((header) => {
|
||||||
|
if (header === 'x-slack-signature') return testSignature;
|
||||||
|
if (header === 'x-slack-request-timestamp') return testTimestamp;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
rawBody: testBody,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('verifySignature', () => {
|
||||||
|
it('should return true when no credentials are provided', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('slackApi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when no signature secret is provided', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
apiToken: 'test-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('slackApi');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when signature header is missing', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||||
|
header: jest.fn().mockImplementation((header) => {
|
||||||
|
if (header === 'x-slack-request-timestamp') return testTimestamp;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
rawBody: testBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when timestamp header is missing', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockWebhookFunctions.getRequestObject.mockReturnValue({
|
||||||
|
header: jest.fn().mockImplementation((header) => {
|
||||||
|
if (header === 'x-slack-signature') return testSignature;
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
rawBody: testBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when timestamp is too old', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock Date.now() to return a timestamp that's more than 5 minutes after the request timestamp
|
||||||
|
const futureDate = new Date((parseInt(testTimestamp, 10) + 301) * 1000);
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => futureDate.getTime());
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when signature is valid', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the timingSafeEqual to return true for valid signature
|
||||||
|
(timingSafeEqual as jest.Mock).mockReturnValue(true);
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(createHmac).toHaveBeenCalledWith('sha256', testSignatureSecret);
|
||||||
|
expect(timingSafeEqual).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when signature is invalid', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock the timingSafeEqual to return false for invalid signature
|
||||||
|
(timingSafeEqual as jest.Mock).mockReturnValue(false);
|
||||||
|
|
||||||
|
const result = await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(createHmac).toHaveBeenCalledWith('sha256', testSignatureSecret);
|
||||||
|
expect(timingSafeEqual).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly build the signature string with the provided timestamp', async () => {
|
||||||
|
mockWebhookFunctions.getCredentials.mockResolvedValue({
|
||||||
|
signatureSecret: testSignatureSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await verifySignature.call(mockWebhookFunctions);
|
||||||
|
|
||||||
|
expect(createHmac).toHaveBeenCalledWith('sha256', testSignatureSecret);
|
||||||
|
const mockHmac = createHmac('sha256', testSignatureSecret);
|
||||||
|
|
||||||
|
// Verify that update was called with the expected string (using the timestamp from the request)
|
||||||
|
expect(mockHmac.update).toHaveBeenCalledWith(`v0:${testTimestamp}:${testBody}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user