mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(Calendly Trigger Node): Add webhook request verification (#29482)
This commit is contained in:
parent
a772016e36
commit
e929f9fbe7
|
|
@ -1,3 +1,5 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
|
||||
import type {
|
||||
IHookFunctions,
|
||||
IWebhookFunctions,
|
||||
|
|
@ -8,6 +10,7 @@ import type {
|
|||
} from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from './CalendlyTriggerHelpers';
|
||||
import { calendlyApiRequest, getAuthenticationType } from './GenericFunctions';
|
||||
|
||||
export class CalendlyTrigger implements INodeType {
|
||||
|
|
@ -216,11 +219,14 @@ export class CalendlyTrigger implements INodeType {
|
|||
const scope = this.getNodeParameter('scope', 0) as string;
|
||||
const { resource } = await calendlyApiRequest.call(this, 'GET', '/users/me');
|
||||
|
||||
const webhookSecret = randomBytes(32).toString('hex');
|
||||
|
||||
const body: IDataObject = {
|
||||
url: webhookUrl,
|
||||
events,
|
||||
organization: resource.current_organization,
|
||||
scope,
|
||||
signing_key: webhookSecret,
|
||||
};
|
||||
|
||||
if (scope === 'user') {
|
||||
|
|
@ -235,6 +241,7 @@ export class CalendlyTrigger implements INodeType {
|
|||
}
|
||||
|
||||
webhookData.webhookURI = responseData.resource.uri;
|
||||
webhookData.webhookSecret = webhookSecret;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -276,6 +283,7 @@ export class CalendlyTrigger implements INodeType {
|
|||
}
|
||||
|
||||
delete webhookData.webhookURI;
|
||||
delete webhookData.webhookSecret;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -285,6 +293,14 @@ export class CalendlyTrigger implements INodeType {
|
|||
};
|
||||
|
||||
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
|
||||
if (!verifySignature.call(this)) {
|
||||
const res = this.getResponseObject();
|
||||
res.status(401).send('Unauthorized').end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
const bodyData = this.getBodyData();
|
||||
return {
|
||||
workflowData: [this.helpers.returnJsonArray(bodyData)],
|
||||
|
|
|
|||
69
packages/nodes-base/nodes/Calendly/CalendlyTriggerHelpers.ts
Normal file
69
packages/nodes-base/nodes/Calendly/CalendlyTriggerHelpers.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { createHmac } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
|
||||
|
||||
/**
|
||||
* Calendly sends the signature in the `Calendly-Webhook-Signature` header
|
||||
* with the format `t=<timestamp>,v1=<hex-signature>`. The signature is the
|
||||
* HMAC SHA-256 of `<timestamp>.<raw-body>` using the `signing_key` provided
|
||||
* when the webhook subscription was created.
|
||||
*/
|
||||
function parseSignatureHeader(header: string | null): {
|
||||
timestamp: string | null;
|
||||
signature: string | null;
|
||||
} {
|
||||
if (!header) {
|
||||
return { timestamp: null, signature: null };
|
||||
}
|
||||
const parts = header.split(',');
|
||||
let timestamp: string | null = null;
|
||||
let signature: string | null = null;
|
||||
for (const part of parts) {
|
||||
const [rawKey, ...rest] = part.split('=');
|
||||
if (!rawKey || rest.length === 0) continue;
|
||||
const key = rawKey.trim();
|
||||
const value = rest.join('=').trim();
|
||||
if (key === 't') {
|
||||
timestamp = value;
|
||||
} else if (key === 'v1') {
|
||||
signature = value;
|
||||
}
|
||||
}
|
||||
return { timestamp, signature };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the Calendly webhook signature.
|
||||
*
|
||||
* Returns `true` if the signature is valid or if no `webhookSecret` is
|
||||
* stored (backward compatibility with webhook subscriptions created before
|
||||
* signing was supported).
|
||||
*/
|
||||
export function verifySignature(this: IWebhookFunctions): boolean {
|
||||
const req = this.getRequestObject();
|
||||
const webhookData = this.getWorkflowStaticData('node');
|
||||
const secret = webhookData.webhookSecret;
|
||||
const hasSecret = typeof secret === 'string' && secret.length > 0;
|
||||
|
||||
const headerValue = req.header('calendly-webhook-signature');
|
||||
const { timestamp, signature } = parseSignatureHeader(
|
||||
typeof headerValue === 'string' ? headerValue : null,
|
||||
);
|
||||
|
||||
return verifySignatureGeneric({
|
||||
getExpectedSignature: () => {
|
||||
if (!hasSecret || typeof secret !== 'string' || !req.rawBody || !timestamp) {
|
||||
return null;
|
||||
}
|
||||
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
|
||||
const hmac = createHmac('sha256', secret);
|
||||
hmac.update(`${timestamp}.`);
|
||||
hmac.update(payload);
|
||||
return hmac.digest('hex');
|
||||
},
|
||||
skipIfNoExpectedSignature: !hasSecret,
|
||||
getActualSignature: () => signature,
|
||||
getTimestamp: hasSecret ? () => timestamp : undefined,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { CalendlyTrigger } from '../CalendlyTrigger.node';
|
||||
import { verifySignature } from '../CalendlyTriggerHelpers';
|
||||
import { calendlyApiRequest, getAuthenticationType } from '../GenericFunctions';
|
||||
|
||||
jest.mock('../GenericFunctions');
|
||||
jest.mock('../CalendlyTriggerHelpers');
|
||||
jest.mock('crypto', () => ({
|
||||
...jest.requireActual('crypto'),
|
||||
randomBytes: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('CalendlyTrigger', () => {
|
||||
let trigger: CalendlyTrigger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
trigger = new CalendlyTrigger();
|
||||
});
|
||||
|
||||
describe('webhookMethods.default.create', () => {
|
||||
it('should generate a signing key and send it to Calendly via v2 API', async () => {
|
||||
const webhookSecret = 'a'.repeat(64);
|
||||
const userResource = {
|
||||
uri: 'https://api.calendly.com/users/USER',
|
||||
current_organization: 'https://api.calendly.com/organizations/ORG',
|
||||
};
|
||||
|
||||
(getAuthenticationType as jest.Mock).mockResolvedValue('accessToken');
|
||||
(randomBytes as jest.Mock).mockReturnValue({
|
||||
toString: jest.fn().mockReturnValue(webhookSecret),
|
||||
});
|
||||
(calendlyApiRequest as jest.Mock)
|
||||
.mockResolvedValueOnce({ resource: userResource })
|
||||
.mockResolvedValueOnce({
|
||||
resource: { uri: 'https://api.calendly.com/webhook_subscriptions/SUB' },
|
||||
});
|
||||
|
||||
const webhookData: Record<string, unknown> = {};
|
||||
const mockHook = {
|
||||
getNodeWebhookUrl: jest.fn().mockReturnValue('https://example.com/webhook'),
|
||||
getNodeParameter: jest.fn().mockImplementation((name: string) => {
|
||||
if (name === 'events') return ['invitee.created'];
|
||||
if (name === 'scope') return 'user';
|
||||
}),
|
||||
getWorkflowStaticData: jest.fn().mockReturnValue(webhookData),
|
||||
} as unknown as IHookFunctions;
|
||||
|
||||
await trigger.webhookMethods!.default.create.call(mockHook);
|
||||
|
||||
expect(calendlyApiRequest).toHaveBeenLastCalledWith(
|
||||
'POST',
|
||||
'/webhook_subscriptions',
|
||||
expect.objectContaining({ signing_key: webhookSecret }),
|
||||
);
|
||||
expect(webhookData.webhookSecret).toBe(webhookSecret);
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook', () => {
|
||||
it('should return 401 when signature verification fails', async () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
};
|
||||
(verifySignature as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const mockFn = {
|
||||
getResponseObject: jest.fn().mockReturnValue(mockRes),
|
||||
} as unknown as IWebhookFunctions;
|
||||
|
||||
const result = await trigger.webhook.call(mockFn);
|
||||
|
||||
expect(result).toEqual({ noWebhookResponse: true });
|
||||
expect(mockRes.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('should process the webhook when signature is valid', async () => {
|
||||
const bodyData = { event: 'invitee.created', payload: { foo: 'bar' } };
|
||||
(verifySignature as jest.Mock).mockReturnValue(true);
|
||||
|
||||
const mockFn = {
|
||||
getBodyData: jest.fn().mockReturnValue(bodyData),
|
||||
helpers: { returnJsonArray: jest.fn((data) => data) },
|
||||
} as unknown as IWebhookFunctions;
|
||||
|
||||
const result = await trigger.webhook.call(mockFn);
|
||||
|
||||
expect(result).toEqual({ workflowData: [bodyData] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import { createHmac } from 'crypto';
|
||||
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from '../CalendlyTriggerHelpers';
|
||||
|
||||
describe('CalendlyTriggerHelpers', () => {
|
||||
const testSecret = 'test-secret-key-12345';
|
||||
const testPayload = Buffer.from('{"event":"invitee.created"}');
|
||||
|
||||
function buildSignatureHeader(secret: string, timestamp: number, payload: Buffer) {
|
||||
const hmac = createHmac('sha256', secret);
|
||||
hmac.update(`${timestamp}.`);
|
||||
hmac.update(payload);
|
||||
return `t=${timestamp},v1=${hmac.digest('hex')}`;
|
||||
}
|
||||
|
||||
function createMockContext(opts: {
|
||||
webhookSecret?: string;
|
||||
headerValue: string | null;
|
||||
rawBody: Buffer | string | undefined;
|
||||
}) {
|
||||
return {
|
||||
getWorkflowStaticData: jest
|
||||
.fn()
|
||||
.mockReturnValue(
|
||||
opts.webhookSecret !== undefined ? { webhookSecret: opts.webhookSecret } : {},
|
||||
),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
header: jest
|
||||
.fn()
|
||||
.mockImplementation((name: string) =>
|
||||
name === 'calendly-webhook-signature' ? opts.headerValue : null,
|
||||
),
|
||||
rawBody: opts.rawBody,
|
||||
}),
|
||||
} as unknown as IWebhookFunctions;
|
||||
}
|
||||
|
||||
describe('verifySignature', () => {
|
||||
it('should skip verification when no secret is stored (backward compatibility)', () => {
|
||||
const ctx = createMockContext({ headerValue: null, rawBody: testPayload });
|
||||
expect(verifySignature.call(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept a valid signature and timestamp', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const header = buildSignatureHeader(testSecret, timestamp, testPayload);
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: header,
|
||||
rawBody: testPayload,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an invalid signature', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: `t=${timestamp},v1=invalidsignature`,
|
||||
rawBody: testPayload,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when signature header is missing', () => {
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: null,
|
||||
rawBody: testPayload,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when timestamp is too old', () => {
|
||||
const oldTimestamp = Math.floor(Date.now() / 1000) - 600;
|
||||
const header = buildSignatureHeader(testSecret, oldTimestamp, testPayload);
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: header,
|
||||
rawBody: testPayload,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject a malformed header', () => {
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: 'not-a-valid-header',
|
||||
rawBody: testPayload,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject when raw body is missing', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const header = buildSignatureHeader(testSecret, timestamp, testPayload);
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: header,
|
||||
rawBody: undefined,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle raw body provided as a string', () => {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const body = '{"event":"invitee.created"}';
|
||||
const header = buildSignatureHeader(testSecret, timestamp, Buffer.from(body));
|
||||
const ctx = createMockContext({
|
||||
webhookSecret: testSecret,
|
||||
headerValue: header,
|
||||
rawBody: body,
|
||||
});
|
||||
expect(verifySignature.call(ctx)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user