fix(Typeform Node): Refactor signature validation (#24987)

This commit is contained in:
yehorkardash
2026-02-03 11:44:52 +01:00
committed by GitHub
parent 7a349742d2
commit 91ec8dcda0
11 changed files with 971 additions and 124 deletions
@@ -1,13 +1,9 @@
import { randomBytes, timingSafeEqual } from 'crypto';
import { randomBytes } from 'crypto';
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
const CURRENTS_API_BASE = 'https://api.currents.dev/v1';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
/**
* Maximum allowed age for a webhook request timestamp (5 minutes).
* Requests older than this are considered potential replay attacks.
*/
const MAX_TIMESTAMP_AGE_SECONDS = 300;
const CURRENTS_API_BASE = 'https://api.currents.dev/v1';
/**
* Header name used for webhook secret validation.
@@ -143,49 +139,20 @@ export function verifyWebhook(this: IWebhookFunctions): boolean {
const req = this.getRequestObject();
const headerData = this.getHeaderData();
// Check timestamp to prevent replay attacks (Currents sends milliseconds)
const timestampHeader = req.headers['x-timestamp'];
if (typeof timestampHeader === 'string') {
const requestTimeMs = parseInt(timestampHeader, 10);
if (isNaN(requestTimeMs)) {
return false;
}
const requestTimeSec = Math.floor(requestTimeMs / 1000);
const currentTimeSec = Math.floor(Date.now() / 1000);
const age = Math.abs(currentTimeSec - requestTimeSec);
if (age > MAX_TIMESTAMP_AGE_SECONDS) {
return false;
}
}
const webhookData = this.getWorkflowStaticData('node');
const expectedSecret = webhookData.webhookSecret;
if (typeof expectedSecret === 'string') {
const actualSecret = headerData[WEBHOOK_SECRET_HEADER];
if (typeof actualSecret !== 'string') {
return false;
}
// Use constant-time comparison to prevent timing attacks
if (
expectedSecret.length !== actualSecret.length ||
!timingSafeEqual(Buffer.from(expectedSecret), Buffer.from(actualSecret))
) {
return false;
}
}
return true;
}
/**
* Validates that a millisecond timestamp is within the acceptable window.
* Exported separately for unit testing.
*/
export function isTimestampValid(timestampMs: number, currentTimeSec?: number): boolean {
const requestTimeSec = Math.floor(timestampMs / 1000);
const now = currentTimeSec ?? Math.floor(Date.now() / 1000);
const age = Math.abs(now - requestTimeSec);
return age <= MAX_TIMESTAMP_AGE_SECONDS;
return verifySignatureGeneric({
getExpectedSignature: () => (typeof expectedSecret === 'string' ? expectedSecret : null),
skipIfNoExpectedSignature: true,
getActualSignature: () => {
const actualSecret = headerData[WEBHOOK_SECRET_HEADER];
return typeof actualSecret === 'string' ? actualSecret : null;
},
getTimestamp: () => {
const timestampHeader = req.headers['x-timestamp'];
return typeof timestampHeader === 'string' ? timestampHeader : null;
},
skipIfNoTimestamp: true,
});
}
@@ -5,45 +5,12 @@ import {
deleteWebhook,
findWebhookByUrl,
generateWebhookSecret,
isTimestampValid,
listWebhooks,
updateWebhook,
verifyWebhook,
} from '../CurrentsTriggerHelpers';
describe('CurrentsTriggerHelpers', () => {
describe('isTimestampValid', () => {
it('should return true for current timestamp in milliseconds', () => {
const nowSec = Math.floor(Date.now() / 1000);
const nowMs = nowSec * 1000;
expect(isTimestampValid(nowMs, nowSec)).toBe(true);
});
it('should return true for timestamp within 5 minutes', () => {
const nowSec = Math.floor(Date.now() / 1000);
const fourMinutesAgoMs = (nowSec - 240) * 1000;
expect(isTimestampValid(fourMinutesAgoMs, nowSec)).toBe(true);
});
it('should return false for timestamp older than 5 minutes', () => {
const nowSec = Math.floor(Date.now() / 1000);
const sixMinutesAgoMs = (nowSec - 360) * 1000;
expect(isTimestampValid(sixMinutesAgoMs, nowSec)).toBe(false);
});
it('should return false for timestamp from the future beyond tolerance', () => {
const nowSec = Math.floor(Date.now() / 1000);
const sixMinutesInFutureMs = (nowSec + 360) * 1000;
expect(isTimestampValid(sixMinutesInFutureMs, nowSec)).toBe(false);
});
it('should return true for timestamp at exactly 5 minutes', () => {
const nowSec = Math.floor(Date.now() / 1000);
const fiveMinutesAgoMs = (nowSec - 300) * 1000;
expect(isTimestampValid(fiveMinutesAgoMs, nowSec)).toBe(true);
});
});
describe('generateWebhookSecret', () => {
it('should generate a 64-character hex string', () => {
const secret = generateWebhookSecret();
@@ -322,7 +322,8 @@ export class SlackTrigger implements INodeType {
const watchWorkspace = this.getNodeParameter('watchWorkspace', false) as boolean;
let eventChannel: string = '';
if (!(await verifySignature.call(this))) {
const isSignatureValid = await verifySignature.call(this);
if (!isSignatureValid) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return {
@@ -1,9 +1,9 @@
import { createHmac } from 'crypto';
import type { IHttpRequestOptions, IWebhookFunctions } from 'n8n-workflow';
import { NodeOperationError } from 'n8n-workflow';
import { createHmac, timingSafeEqual } from 'crypto';
import { slackApiRequest } from './V2/GenericFunctions';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
export async function getUserInfo(this: IWebhookFunctions, userId: string): Promise<any> {
const user = await slackApiRequest.call(
@@ -83,53 +83,43 @@ export async function downloadFile(this: IWebhookFunctions, url: string): Promis
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) {
if (!timestamp) {
return false;
}
const signatureSecret = credential.signatureSecret;
try {
if (typeof credential.signatureSecret !== 'string') {
return false;
}
const isValid = verifySignatureGeneric({
getExpectedSignature: () => {
if (!signatureSecret || typeof signatureSecret !== 'string' || !req.rawBody) {
return null;
}
if (!req.rawBody) {
return false;
}
const hmac = createHmac('sha256', signatureSecret);
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}`);
}
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)
);
const computedSignature = `v0=${hmac.digest('hex')}`;
return computedSignature;
},
skipIfNoExpectedSignature: !signatureSecret || typeof signatureSecret !== 'string',
getActualSignature: () => {
const actualSignature = req.header('x-slack-signature');
return typeof actualSignature === 'string' ? actualSignature : null;
},
getTimestamp: () => timestamp,
});
return isValid;
} catch (error) {
return false;
}
@@ -46,7 +46,7 @@ describe('SlackTriggerHelpers', () => {
describe('verifySignature', () => {
it('should return true when no credentials are provided', async () => {
mockWebhookFunctions.getCredentials.mockResolvedValue(null);
mockWebhookFunctions.getCredentials.mockResolvedValue({});
const result = await verifySignature.call(mockWebhookFunctions);
@@ -158,5 +158,39 @@ describe('SlackTriggerHelpers', () => {
// Verify that update was called with the expected string (using the timestamp from the request)
expect(mockHmac.update).toHaveBeenCalledWith(`v0:${testTimestamp}:${testBody}`);
});
it('should verify timestamp even if signature secret is not set', async () => {
// No signature secret in credentials
mockWebhookFunctions.getCredentials.mockResolvedValue({
apiToken: 'test-token',
});
// 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);
// Should return false because timestamp is too old, even though signature secret is not set
expect(result).toBe(false);
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('slackApi');
});
it('should return true when timestamp is valid even if signature secret is not set', async () => {
// No signature secret in credentials
mockWebhookFunctions.getCredentials.mockResolvedValue({
apiToken: 'test-token',
});
// Keep Date.now() at the same time as the request timestamp (within 5 minute window)
const fixedDate = new Date(parseInt(testTimestamp, 10) * 1000);
jest.spyOn(Date, 'now').mockImplementation(() => fixedDate.getTime());
const result = await verifySignature.call(mockWebhookFunctions);
// Should return true because timestamp is valid and signature secret is not required
expect(result).toBe(true);
expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('slackApi');
});
});
});
@@ -1,3 +1,5 @@
import { randomBytes } from 'crypto';
import type {
IHookFunctions,
IWebhookFunctions,
@@ -18,6 +20,7 @@ import type {
ITypeformDefinition,
} from './GenericFunctions';
import { apiRequest, getForms } from './GenericFunctions';
import { verifySignature } from './TypeformTriggerHelpers';
export class TypeformTrigger implements INodeType {
description: INodeTypeDescription = {
@@ -181,17 +184,21 @@ export class TypeformTrigger implements INodeType {
const endpoint = `forms/${formId}/webhooks/${webhookId}`;
// TODO: Add HMAC-validation once either the JSON data can be used for it or there is a way to access the binary-payload-data
// Generate a secret for webhook signature verification
const webhookSecret = randomBytes(32).toString('hex');
const body = {
url: webhookUrl,
enabled: true,
verify_ssl: true,
secret: webhookSecret,
};
await apiRequest.call(this, 'PUT', endpoint, body);
const webhookData = this.getWorkflowStaticData('node');
webhookData.webhookId = webhookId;
webhookData.webhookSecret = webhookSecret;
return true;
},
@@ -212,6 +219,7 @@ export class TypeformTrigger implements INodeType {
// Remove from the static workflow data so that it is clear
// that no webhooks are registered anymore
delete webhookData.webhookId;
delete webhookData.webhookSecret;
}
return true;
@@ -220,6 +228,15 @@ export class TypeformTrigger implements INodeType {
};
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
// Verify webhook signature if secret is configured
if (!verifySignature.call(this)) {
const res = this.getResponseObject();
res.status(401).send('Unauthorized').end();
return {
noWebhookResponse: true,
};
}
const version = this.getNode().typeVersion;
const bodyData = this.getBodyData();
@@ -0,0 +1,41 @@
import { createHmac } from 'crypto';
import type { IWebhookFunctions } from 'n8n-workflow';
import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification';
/**
* Verifies the Typeform webhook signature.
*
* Typeform signs webhooks using HMAC SHA-256:
* 1. Create HMAC SHA-256 hash of the entire payload (as binary) using the secret as key
* 2. Encode the hash in base64 format
* 3. Add prefix `sha256=` to the hash
* 4. Compare with the signature in the `Typeform-Signature` header
*
* @returns true if the signature is valid, false otherwise
* @returns true if no secret is configured (backward compatibility with old triggers)
*/
export function verifySignature(this: IWebhookFunctions): boolean {
const req = this.getRequestObject();
const webhookData = this.getWorkflowStaticData('node');
const secret = webhookData.webhookSecret;
return verifySignatureGeneric({
getExpectedSignature: () => {
if (!secret || typeof secret !== 'string' || !req.rawBody) {
return null;
}
const hmac = createHmac('sha256', secret);
const payload = Buffer.isBuffer(req.rawBody) ? req.rawBody : Buffer.from(req.rawBody);
hmac.update(payload);
const hash = hmac.digest('base64');
const computedSignature = `sha256=${hash}`;
return computedSignature;
},
skipIfNoExpectedSignature: !secret || typeof secret !== 'string',
getActualSignature: () => {
const receivedSignature = req.header('typeform-signature');
return typeof receivedSignature === 'string' ? receivedSignature : null;
},
});
}
@@ -0,0 +1,362 @@
import { randomBytes } from 'crypto';
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
import { NodeApiError } from 'n8n-workflow';
import { apiRequest } from '../GenericFunctions';
import { TypeformTrigger } from '../TypeformTrigger.node';
import { verifySignature } from '../TypeformTriggerHelpers';
jest.mock('../GenericFunctions');
jest.mock('../TypeformTriggerHelpers');
jest.mock('crypto', () => ({
...jest.requireActual('crypto'),
randomBytes: jest.fn(),
}));
describe('TypeformTrigger', () => {
let trigger: TypeformTrigger;
let mockHookFunctions: Pick<
jest.Mocked<IHookFunctions>,
'getNodeWebhookUrl' | 'getNodeParameter' | 'getWorkflowStaticData' | 'helpers'
>;
let mockWebhookFunctions: Pick<
jest.Mocked<IWebhookFunctions>,
| 'getNode'
| 'getNodeParameter'
| 'getBodyData'
| 'getRequestObject'
| 'getResponseObject'
| 'getWorkflowStaticData'
| 'helpers'
>;
beforeEach(() => {
jest.clearAllMocks();
trigger = new TypeformTrigger();
mockHookFunctions = {
getNodeWebhookUrl: jest.fn(),
getNodeParameter: jest.fn(),
getWorkflowStaticData: jest.fn(),
helpers: {
requestWithAuthentication: jest.fn(),
requestOAuth2: jest.fn(),
} as any,
};
mockWebhookFunctions = {
getNode: jest.fn().mockReturnValue({ typeVersion: 1.1 }),
getNodeParameter: jest.fn(),
getBodyData: jest.fn(),
getRequestObject: jest.fn(),
getResponseObject: jest.fn(),
getWorkflowStaticData: jest.fn(),
helpers: {
returnJsonArray: jest.fn((data) => data),
} as any,
};
});
describe('webhookMethods.default.checkExists', () => {
it('should return true when webhook exists', async () => {
const webhookUrl = 'https://example.com/webhook';
const formId = 'form-123';
const webhookId = 'webhook-123';
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
(apiRequest as jest.Mock).mockResolvedValue({
items: [
{
form_id: formId,
url: webhookUrl,
tag: webhookId,
},
],
});
const result = await trigger.webhookMethods!.default.checkExists.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(true);
expect(apiRequest).toHaveBeenCalledWith('GET', `forms/${formId}/webhooks`, {});
expect(mockHookFunctions.getWorkflowStaticData).toHaveBeenCalledWith('node');
});
it('should return false when webhook does not exist', async () => {
const webhookUrl = 'https://example.com/webhook';
const formId = 'form-123';
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
(apiRequest as jest.Mock).mockResolvedValue({
items: [
{
form_id: formId,
url: 'https://different-url.com/webhook',
tag: 'webhook-123',
},
],
});
const result = await trigger.webhookMethods!.default.checkExists.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(false);
});
it('should return false when no webhooks exist', async () => {
const webhookUrl = 'https://example.com/webhook';
const formId = 'form-123';
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
(apiRequest as jest.Mock).mockResolvedValue({
items: [],
});
const result = await trigger.webhookMethods!.default.checkExists.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(false);
});
});
describe('webhookMethods.default.create', () => {
it('should create webhook with secret', async () => {
const webhookUrl = 'https://example.com/webhook';
const formId = 'form-123';
const webhookSecret = 'a'.repeat(64); // 32 bytes = 64 hex chars
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
(randomBytes as jest.Mock).mockReturnValue({
toString: jest.fn().mockReturnValue(webhookSecret),
});
(apiRequest as jest.Mock).mockResolvedValue({});
const result = await trigger.webhookMethods!.default.create.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(true);
expect(randomBytes).toHaveBeenCalledWith(32);
expect(apiRequest).toHaveBeenCalledWith(
'PUT',
expect.stringContaining(`forms/${formId}/webhooks/n8n-`),
{
url: webhookUrl,
enabled: true,
verify_ssl: true,
secret: webhookSecret,
},
);
const webhookData = mockHookFunctions.getWorkflowStaticData!('node');
expect(webhookData.webhookId).toBeDefined();
expect(webhookData.webhookSecret).toBe(webhookSecret);
});
it('should save webhook secret in static data', async () => {
const webhookUrl = 'https://example.com/webhook';
const formId = 'form-123';
const webhookSecret = 'test-secret-123';
mockHookFunctions.getNodeWebhookUrl.mockReturnValue(webhookUrl);
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
const webhookData: any = {};
mockHookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
(randomBytes as jest.Mock).mockReturnValue({
toString: jest.fn().mockReturnValue(webhookSecret),
});
(apiRequest as jest.Mock).mockResolvedValue({});
await trigger.webhookMethods!.default.create.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(webhookData.webhookSecret).toBe(webhookSecret);
expect(webhookData.webhookId).toBeDefined();
});
});
describe('webhookMethods.default.delete', () => {
it('should delete webhook and clean up secret', async () => {
const formId = 'form-123';
const webhookId = 'webhook-123';
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
const webhookData: any = {
webhookId,
webhookSecret: 'test-secret',
};
mockHookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
(apiRequest as jest.Mock).mockResolvedValue({});
const result = await trigger.webhookMethods!.default.delete.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(true);
expect(apiRequest).toHaveBeenCalledWith(
'DELETE',
`forms/${formId}/webhooks/${webhookId}`,
{},
);
expect(webhookData.webhookId).toBeUndefined();
expect(webhookData.webhookSecret).toBeUndefined();
});
it('should return true when webhookId is not set', async () => {
mockHookFunctions.getNodeParameter.mockReturnValue('form-123');
mockHookFunctions.getWorkflowStaticData.mockReturnValue({});
const result = await trigger.webhookMethods!.default.delete.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(true);
expect(apiRequest).not.toHaveBeenCalled();
});
it('should return false when deletion fails', async () => {
const formId = 'form-123';
const webhookId = 'webhook-123';
mockHookFunctions.getNodeParameter.mockReturnValue(formId);
const webhookData: any = {
webhookId,
};
mockHookFunctions.getWorkflowStaticData.mockReturnValue(webhookData);
(apiRequest as jest.Mock).mockRejectedValue(new Error('Delete failed'));
const result = await trigger.webhookMethods!.default.delete.call(
mockHookFunctions as unknown as IHookFunctions,
);
expect(result).toBe(false);
});
});
describe('webhook', () => {
it('should return 401 when signature verification fails', async () => {
const mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
end: jest.fn(),
};
(verifySignature as jest.Mock).mockReturnValue(false);
mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse as any);
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(verifySignature).toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.send).toHaveBeenCalledWith('Unauthorized');
expect(result).toEqual({
noWebhookResponse: true,
});
});
it('should process webhook when signature verification passes', async () => {
const bodyData = {
form_response: {
definition: {
fields: [
{
id: 'field1',
title: 'Question 1',
},
],
},
answers: [
{
field: { id: 'field1' },
type: 'text',
text: 'Answer 1',
},
],
},
};
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getNodeParameter.mockImplementation((name) => {
if (name === 'simplifyAnswers') return true;
if (name === 'onlyAnswers') return true;
return null;
});
mockWebhookFunctions.getBodyData!.mockReturnValue(bodyData as any);
const result = await trigger.webhook.call(
mockWebhookFunctions as unknown as IWebhookFunctions,
);
expect(verifySignature).toHaveBeenCalled();
expect(result).toBeDefined();
expect(result.workflowData).toBeDefined();
});
it('should throw error when form_response is missing', async () => {
const bodyData = {};
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue(bodyData as any);
await expect(
trigger.webhook.call(mockWebhookFunctions as unknown as IWebhookFunctions),
).rejects.toThrow(NodeApiError);
});
it('should throw error when definition is missing', async () => {
const bodyData = {
form_response: {
answers: [],
},
};
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue(bodyData as any);
await expect(
trigger.webhook.call(mockWebhookFunctions as unknown as IWebhookFunctions),
).rejects.toThrow(NodeApiError);
});
it('should throw error when answers is missing', async () => {
const bodyData = {
form_response: {
definition: {
fields: [],
},
},
};
(verifySignature as jest.Mock).mockReturnValue(true);
mockWebhookFunctions.getBodyData.mockReturnValue(bodyData as any);
await expect(
trigger.webhook.call(mockWebhookFunctions as unknown as IWebhookFunctions),
).rejects.toThrow(NodeApiError);
});
});
});
@@ -0,0 +1,75 @@
import { createHmac } from 'crypto';
import { verifySignature } from '../TypeformTriggerHelpers';
describe('TypeformTriggerHelpers', () => {
let mockWebhookFunctions: any;
const testSecret = 'test-secret-key-12345';
const testPayload = Buffer.from('{"event":"form_response"}');
beforeEach(() => {
jest.clearAllMocks();
mockWebhookFunctions = {
getRequestObject: jest.fn(),
getWorkflowStaticData: jest.fn(),
};
});
describe('verifySignature', () => {
it('should return true if no signature exists', () => {
// No secret configured
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockReturnValue(null),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return true if signatures match', () => {
// Compute the expected signature
const hmac = createHmac('sha256', testSecret);
hmac.update(testPayload);
const expectedSignature = `sha256=${hmac.digest('base64')}`;
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'typeform-signature') return expectedSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(true);
});
it('should return false if signatures do not match', () => {
// Use a different signature that won't match
const wrongSignature = 'sha256=wrongsignature1234567890';
mockWebhookFunctions.getWorkflowStaticData.mockReturnValue({
webhookSecret: testSecret,
});
mockWebhookFunctions.getRequestObject.mockReturnValue({
header: jest.fn().mockImplementation((header) => {
if (header === 'typeform-signature') return wrongSignature;
return null;
}),
rawBody: testPayload,
});
const result = verifySignature.call(mockWebhookFunctions);
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,268 @@
import { timingSafeEqual } from 'crypto';
import { verifySignature } from '../webhook-signature-verification';
jest.mock('crypto', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
...jest.requireActual('crypto'),
timingSafeEqual: jest.fn(),
}));
describe('webhook-signature-verification', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('verifySignature', () => {
it('should return true when signatures match', () => {
const expectedSignature = 'sha256=abc123';
const actualSignature = 'sha256=abc123';
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => expectedSignature,
getActualSignature: () => actualSignature,
});
expect(result).toBe(true);
expect(timingSafeEqual).toHaveBeenCalled();
});
it('should return false when signatures do not match', () => {
const expectedSignature = 'sha256=abc123';
const actualSignature = 'sha256=xyz789';
(timingSafeEqual as jest.Mock).mockReturnValue(false);
const result = verifySignature({
getExpectedSignature: () => expectedSignature,
getActualSignature: () => actualSignature,
});
expect(result).toBe(false);
expect(timingSafeEqual).toHaveBeenCalled();
});
it('should verify signature even when timestamp is skipped', () => {
const expectedSignature = 'sha256=abc123';
const actualSignature = 'sha256=xyz789';
(timingSafeEqual as jest.Mock).mockReturnValue(false);
const result = verifySignature({
getExpectedSignature: () => expectedSignature,
getActualSignature: () => actualSignature,
getTimestamp: () => null,
skipIfNoTimestamp: true,
});
expect(result).toBe(false);
expect(timingSafeEqual).toHaveBeenCalled();
});
it('should return false when signatures have different lengths', () => {
const expectedSignature = 'sha256=abc123';
const actualSignature = 'sha256=abc1234';
const result = verifySignature({
getExpectedSignature: () => expectedSignature,
getActualSignature: () => actualSignature,
});
expect(result).toBe(false);
expect(timingSafeEqual).not.toHaveBeenCalled();
});
it('should return false when expected signature is missing', () => {
const result = verifySignature({
getExpectedSignature: () => null,
getActualSignature: () => 'sha256=abc123',
});
expect(result).toBe(false);
});
it('should return false when expected signature is undefined', () => {
const result = verifySignature({
getExpectedSignature: () => undefined as unknown as string,
getActualSignature: () => 'sha256=abc123',
});
expect(result).toBe(false);
});
it('should return false when actual signature is missing', () => {
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => null,
});
expect(result).toBe(false);
});
it('should return false when actual signature is undefined', () => {
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => undefined as unknown as string,
});
expect(result).toBe(false);
});
it('should return true when skipIfNoExpectedSignature is true and no expected signature', () => {
const result = verifySignature({
getExpectedSignature: () => null,
getActualSignature: () => 'sha256=abc123',
skipIfNoExpectedSignature: true,
});
expect(result).toBe(true);
});
it('should validate timestamp when provided and within window', () => {
const currentTimeSec = Math.floor(Date.now() / 1000);
const recentTimestamp = currentTimeSec - 60; // 1 minute ago
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => recentTimestamp,
});
expect(result).toBe(true);
});
it('should return false when timestamp is too old', () => {
const currentTimeSec = Math.floor(Date.now() / 1000);
const oldTimestamp = currentTimeSec - 400; // More than 5 minutes ago
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => oldTimestamp,
});
expect(result).toBe(false);
});
it('should return false when timestamp is too far in future', () => {
const currentTimeSec = Math.floor(Date.now() / 1000);
const futureTimestamp = currentTimeSec + 400; // More than 5 minutes in future
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => futureTimestamp,
});
expect(result).toBe(false);
});
it('should handle timestamp as string', () => {
const currentTimeSec = Math.floor(Date.now() / 1000);
const recentTimestamp = String(currentTimeSec - 60);
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => recentTimestamp,
});
expect(result).toBe(true);
});
it('should convert milliseconds timestamp to seconds', () => {
const currentTimeMs = Date.now();
const recentTimestampMs = currentTimeMs - 60 * 1000; // 1 minute ago in ms
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => recentTimestampMs,
});
expect(result).toBe(true);
});
it('should use custom maxTimestampAgeSeconds', () => {
const currentTimeSec = Math.floor(Date.now() / 1000);
const timestamp = currentTimeSec - 120; // 2 minutes ago
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => timestamp,
maxTimestampAgeSeconds: 60, // 1 minute window
});
expect(result).toBe(false);
});
it('should return true when skipIfNoTimestamp is true and timestamp is null', () => {
(timingSafeEqual as jest.Mock).mockReturnValue(true);
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => null,
skipIfNoTimestamp: true,
});
expect(result).toBe(true);
});
it('should return false when timestamp is null and skipIfNoTimestamp is false', () => {
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => null,
skipIfNoTimestamp: false,
});
expect(result).toBe(false);
});
it('should return false when timestamp is invalid (NaN)', () => {
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
getTimestamp: () => 'invalid-timestamp',
});
expect(result).toBe(false);
});
it('should handle errors gracefully and return false', () => {
const result = verifySignature({
getExpectedSignature: () => {
throw new Error('Test error');
},
getActualSignature: () => 'sha256=abc123',
});
expect(result).toBe(false);
});
it('should handle timingSafeEqual errors gracefully', () => {
(timingSafeEqual as jest.Mock).mockImplementation(() => {
throw new Error('Buffer length mismatch');
});
const result = verifySignature({
getExpectedSignature: () => 'sha256=abc123',
getActualSignature: () => 'sha256=abc123',
});
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,125 @@
import { timingSafeEqual } from 'crypto';
/**
* Maximum allowed age for a webhook request timestamp (5 minutes).
* Requests older than this are considered potential replay attacks.
*/
const MAX_TIMESTAMP_AGE_SECONDS = 300;
export interface VerifySignatureOptions {
/**
* Returns the expected signature/secret. For HMAC, compute using the same algorithm.
* Return `null` if signature cannot be computed (missing secret/body).
*/
getExpectedSignature: () => string | null;
/**
* If true, skip validation when `getExpectedSignature()` returns `null`.
* Use for backward compatibility with unsigned webhooks.
* @default false
*/
skipIfNoExpectedSignature?: boolean;
/**
* Returns the actual signature from request headers, or `null` if not present.
*/
getActualSignature: () => string | null;
/**
* Optional. Returns timestamp from request (seconds or milliseconds, auto-converted).
* Enables replay attack prevention (default: 5 minute window).
*/
getTimestamp?: () => number | string | null;
/**
* If true, skip timestamp validation when `getTimestamp()` returns `null`.
* @default false
*/
skipIfNoTimestamp?: boolean;
/**
* Maximum allowed timestamp age in seconds.
* @default 300 (5 minutes)
*/
maxTimestampAgeSeconds?: number;
}
/**
* Verifies webhook signatures and prevents replay attacks.
*
* Features:
* - Signature verification using constant-time comparison (prevents timing attacks)
* - Optional timestamp validation (prevents replay attacks)
* - Supports HMAC-based and simple secret comparison patterns
*
* @param options - Configuration options
* @returns `true` if valid, `false` otherwise. Never throws.
*
* @example
* verifySignature({
* getExpectedSignature: () => {
* const hmac = createHmac('sha256', secret);
* hmac.update(rawBody);
* return `sha256=${hmac.digest('base64')}`;
* },
* getActualSignature: () => req.header('x-signature'),
* getTimestamp: () => req.header('x-timestamp'),
* });
*/
export function verifySignature(options: VerifySignatureOptions): boolean {
const { getExpectedSignature, getActualSignature, getTimestamp, maxTimestampAgeSeconds } =
options;
try {
// Validate timestamp if provided (replay attack prevention)
if (getTimestamp) {
const timestamp = getTimestamp();
const shouldSkip = options.skipIfNoTimestamp && timestamp === null;
if (!shouldSkip && !isTimestampValid(timestamp, maxTimestampAgeSeconds)) {
return false;
}
}
// Get expected signature
const expectedSignature = getExpectedSignature();
if (!expectedSignature || typeof expectedSignature !== 'string') {
if (options.skipIfNoExpectedSignature) {
return true;
}
return false;
}
// Get actual signature
const actualSignature = getActualSignature();
if (!actualSignature || typeof actualSignature !== 'string') {
return false;
}
const expectedBuffer = Buffer.from(expectedSignature);
const actualBuffer = Buffer.from(actualSignature);
// Perform constant-time comparison to prevent timing attacks
return (
expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer)
);
} catch (error) {
return false;
}
}
/**
* Validates timestamp is within acceptable window (auto-detects seconds/milliseconds).
*/
function isTimestampValid(
timestamp: number | string | null,
maxTimestampAgeSeconds?: number,
): boolean {
if (timestamp === null) {
return false;
}
const timestampNum =
typeof timestamp === 'string' ? parseInt(timestamp, 10) : Math.floor(timestamp);
if (isNaN(timestampNum)) {
return false;
}
// Convert to seconds if timestamp is in milliseconds
const timestampSec = timestampNum > 1e10 ? Math.floor(timestampNum / 1000) : timestampNum;
const currentTimeSec = Math.floor(Date.now() / 1000);
const maxAge = maxTimestampAgeSeconds ?? MAX_TIMESTAMP_AGE_SECONDS;
const age = Math.abs(currentTimeSec - timestampSec);
return age <= maxAge;
}