Skip to content

Commit 910822f

Browse files
DawidMyslakclaude
andauthored
feat(Figma Trigger Node): Add webhook request verification (n8n-io#29262)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b3a8061 commit 910822f

4 files changed

Lines changed: 268 additions & 1 deletion

File tree

packages/nodes-base/nodes/Figma/FigmaTrigger.node.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
} from 'n8n-workflow';
1111
import { NodeConnectionTypes } from 'n8n-workflow';
1212

13+
import { verifySignature } from './FigmaTriggerHelpers';
1314
import { figmaApiRequest } from './GenericFunctions';
1415

1516
export class FigmaTrigger implements INodeType {
@@ -124,12 +125,14 @@ export class FigmaTrigger implements INodeType {
124125
const teamId = this.getNodeParameter('teamId') as string;
125126
const endpoint = '/v2/webhooks';
126127

128+
const passcode = randomBytes(32).toString('hex');
129+
127130
const body: IDataObject = {
128131
event_type: snakeCase(triggerOn).toUpperCase(),
129132
team_id: teamId,
130133
description: `n8n-webhook:${webhookUrl}`,
131134
endpoint: webhookUrl,
132-
passcode: randomBytes(10).toString('hex'),
135+
passcode,
133136
};
134137

135138
const responseData = await figmaApiRequest.call(this, 'POST', endpoint, body);
@@ -140,6 +143,7 @@ export class FigmaTrigger implements INodeType {
140143
}
141144

142145
webhookData.webhookId = responseData.id as string;
146+
webhookData.webhookSecret = passcode;
143147
return true;
144148
},
145149
async delete(this: IHookFunctions): Promise<boolean> {
@@ -154,13 +158,23 @@ export class FigmaTrigger implements INodeType {
154158
// Remove from the static workflow data so that it is clear
155159
// that no webhooks are registered anymore
156160
delete webhookData.webhookId;
161+
delete webhookData.webhookSecret;
157162
}
158163
return true;
159164
},
160165
},
161166
};
162167

168+
// eslint-disable-next-line @typescript-eslint/require-await
163169
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
170+
if (!verifySignature.call(this)) {
171+
const res = this.getResponseObject();
172+
res.status(401).send('Unauthorized').end();
173+
return {
174+
noWebhookResponse: true,
175+
};
176+
}
177+
164178
const bodyData = this.getBodyData();
165179

166180
if (bodyData.event_type === 'PING') {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { IWebhookFunctions } from 'n8n-workflow';
2+
3+
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
4+
5+
/**
6+
* Verifies the Figma webhook request by comparing the `passcode` field in the
7+
* payload body against the passcode stored in the workflow static data when
8+
* the webhook was created.
9+
*
10+
* Figma includes the passcode supplied at webhook creation time in every
11+
* event payload (including PING). See:
12+
* https://developers.figma.com/docs/rest-api/webhooks-security/
13+
*
14+
* @returns true if the passcode matches, false otherwise
15+
* @returns true if no passcode is stored (backward compatibility with
16+
* webhooks created before this verification was added)
17+
*/
18+
export function verifySignature(this: IWebhookFunctions): boolean {
19+
const webhookData = this.getWorkflowStaticData('node');
20+
const expectedPasscode = webhookData.webhookSecret;
21+
const bodyData = this.getBodyData();
22+
const actualPasscode = bodyData.passcode;
23+
24+
return verifySignatureGeneric({
25+
getExpectedSignature: () =>
26+
typeof expectedPasscode === 'string' && expectedPasscode.length > 0 ? expectedPasscode : null,
27+
skipIfNoExpectedSignature: true,
28+
getActualSignature: () =>
29+
typeof actualPasscode === 'string' && actualPasscode.length > 0 ? actualPasscode : null,
30+
});
31+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
2+
3+
import { FigmaTrigger } from '../FigmaTrigger.node';
4+
import { verifySignature } from '../FigmaTriggerHelpers';
5+
6+
jest.mock('../FigmaTriggerHelpers', () => ({
7+
verifySignature: jest.fn(),
8+
}));
9+
10+
describe('FigmaTrigger', () => {
11+
let trigger: FigmaTrigger;
12+
let mockWebhookFunctions: Partial<IWebhookFunctions>;
13+
let mockResponse: { status: jest.Mock; send: jest.Mock; end: jest.Mock };
14+
15+
beforeEach(() => {
16+
trigger = new FigmaTrigger();
17+
mockResponse = {
18+
status: jest.fn().mockReturnThis(),
19+
send: jest.fn().mockReturnThis(),
20+
end: jest.fn().mockReturnThis(),
21+
};
22+
23+
mockWebhookFunctions = {
24+
getBodyData: jest.fn(),
25+
getResponseObject: jest.fn().mockReturnValue(mockResponse),
26+
helpers: {
27+
returnJsonArray: jest.fn((data) => data),
28+
} as unknown as IWebhookFunctions['helpers'],
29+
};
30+
31+
(verifySignature as jest.Mock).mockReturnValue(true);
32+
});
33+
34+
describe('webhook', () => {
35+
it('should return 401 when verification fails', async () => {
36+
(verifySignature as jest.Mock).mockReturnValue(false);
37+
38+
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
39+
40+
expect(mockResponse.status).toHaveBeenCalledWith(401);
41+
expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized');
42+
expect(result).toEqual({ noWebhookResponse: true });
43+
});
44+
45+
it('should respond 200 to PING events without triggering workflow', async () => {
46+
const bodyData: IDataObject = {
47+
event_type: 'PING',
48+
passcode: 'test-passcode',
49+
timestamp: '2020-02-23T20:27:16Z',
50+
webhook_id: '22',
51+
};
52+
53+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData);
54+
55+
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
56+
57+
expect(mockResponse.status).toHaveBeenCalledWith(200);
58+
expect(result).toEqual({ noWebhookResponse: true });
59+
});
60+
61+
it('should trigger workflow when verification passes', async () => {
62+
const bodyData: IDataObject = {
63+
event_type: 'FILE_UPDATE',
64+
file_key: 'fake-file-key-1',
65+
file_name: 'Test file',
66+
passcode: 'test-passcode',
67+
timestamp: '2020-02-23T20:27:16Z',
68+
webhook_id: '22',
69+
};
70+
71+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData);
72+
73+
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
74+
75+
expect(result.workflowData).toBeDefined();
76+
expect(mockWebhookFunctions.helpers!.returnJsonArray).toHaveBeenCalledWith(bodyData);
77+
});
78+
79+
it('should trigger workflow when no secret is configured (backward compatibility)', async () => {
80+
(verifySignature as jest.Mock).mockReturnValue(true);
81+
82+
const bodyData: IDataObject = {
83+
event_type: 'FILE_COMMENT',
84+
file_key: 'fake-file-key-2',
85+
file_name: 'Test file',
86+
webhook_id: '22',
87+
};
88+
89+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue(bodyData);
90+
91+
const result = await trigger.webhook.call(mockWebhookFunctions as IWebhookFunctions);
92+
93+
expect(result.workflowData).toBeDefined();
94+
});
95+
});
96+
97+
describe('description', () => {
98+
it('should have correct node metadata', () => {
99+
expect(trigger.description.displayName).toBe('Figma Trigger (Beta)');
100+
expect(trigger.description.name).toBe('figmaTrigger');
101+
expect(trigger.description.group).toContain('trigger');
102+
});
103+
});
104+
});
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type { IDataObject, IWebhookFunctions } from 'n8n-workflow';
2+
3+
import { verifySignature } from '../FigmaTriggerHelpers';
4+
5+
describe('FigmaTriggerHelpers', () => {
6+
describe('verifySignature', () => {
7+
let mockWebhookFunctions: Partial<IWebhookFunctions>;
8+
9+
beforeEach(() => {
10+
mockWebhookFunctions = {
11+
getBodyData: jest.fn(),
12+
getWorkflowStaticData: jest.fn(),
13+
};
14+
});
15+
16+
it('should return true when no passcode is stored (backward compatibility)', () => {
17+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});
18+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
19+
event_type: 'FILE_UPDATE',
20+
passcode: 'whatever',
21+
});
22+
23+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
24+
expect(result).toBe(true);
25+
});
26+
27+
it('should return true when stored passcode is empty (backward compatibility)', () => {
28+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
29+
webhookSecret: '',
30+
} as IDataObject);
31+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
32+
event_type: 'FILE_UPDATE',
33+
passcode: 'whatever',
34+
});
35+
36+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
37+
expect(result).toBe(true);
38+
});
39+
40+
it('should return true when passcode in body matches stored passcode', () => {
41+
const passcode = 'a1b2c3d4e5f6';
42+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
43+
webhookSecret: passcode,
44+
} as IDataObject);
45+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
46+
event_type: 'FILE_UPDATE',
47+
passcode,
48+
});
49+
50+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
51+
expect(result).toBe(true);
52+
});
53+
54+
it('should return false when passcode in body does not match (same length)', () => {
55+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
56+
webhookSecret: 'correct-passcode',
57+
} as IDataObject);
58+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
59+
event_type: 'FILE_UPDATE',
60+
passcode: 'wrongone-passcode',
61+
});
62+
63+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
64+
expect(result).toBe(false);
65+
});
66+
67+
it('should return false when passcode in body does not match (different length)', () => {
68+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
69+
webhookSecret: 'correct-passcode',
70+
} as IDataObject);
71+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
72+
event_type: 'FILE_UPDATE',
73+
passcode: 'wrong',
74+
});
75+
76+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
77+
expect(result).toBe(false);
78+
});
79+
80+
it('should return false when passcode is missing from body', () => {
81+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
82+
webhookSecret: 'expected-passcode',
83+
} as IDataObject);
84+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
85+
event_type: 'FILE_UPDATE',
86+
});
87+
88+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
89+
expect(result).toBe(false);
90+
});
91+
92+
it('should return false when passcode in body is not a string', () => {
93+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
94+
webhookSecret: 'expected-passcode',
95+
} as IDataObject);
96+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
97+
event_type: 'FILE_UPDATE',
98+
passcode: 12345,
99+
});
100+
101+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
102+
expect(result).toBe(false);
103+
});
104+
105+
it('should return false when passcode in body is empty string', () => {
106+
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({
107+
webhookSecret: 'expected-passcode',
108+
} as IDataObject);
109+
(mockWebhookFunctions.getBodyData as jest.Mock).mockReturnValue({
110+
event_type: 'FILE_UPDATE',
111+
passcode: '',
112+
});
113+
114+
const result = verifySignature.call(mockWebhookFunctions as IWebhookFunctions);
115+
expect(result).toBe(false);
116+
});
117+
});
118+
});

0 commit comments

Comments
 (0)