mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
fix(Typeform Node): Refactor signature validation (#24987)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user