mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
fix(Stripe Trigger Node): Add Stripe signature verification (#22764)
This commit is contained in:
@@ -20,6 +20,27 @@ export class StripeApi implements ICredentialType {
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
},
|
||||
{
|
||||
displayName: 'Signature Secret',
|
||||
name: 'signatureSecret',
|
||||
type: 'string',
|
||||
typeOptions: { password: true },
|
||||
default: '',
|
||||
description:
|
||||
'The signature secret is used to verify the authenticity of requests sent by Stripe.',
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
'We strongly recommend setting up a <a href="https://stripe.com/docs/webhooks" target="_blank">signing secret</a> to ensure the authenticity of requests.',
|
||||
name: 'notice',
|
||||
type: 'notice',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
signatureSecret: [''],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
authenticate: IAuthenticateGeneric = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { NodeApiError, NodeConnectionTypes } from 'n8n-workflow';
|
||||
|
||||
import { stripeApiRequest } from './helpers';
|
||||
import { verifySignature } from './StripeTriggerHelpers';
|
||||
|
||||
export class StripeTrigger implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
@@ -949,8 +950,15 @@ export class StripeTrigger implements INodeType {
|
||||
const bodyData = this.getBodyData();
|
||||
const req = this.getRequestObject();
|
||||
|
||||
const events = this.getNodeParameter('events', []) as string[];
|
||||
if (!(await verifySignature.call(this))) {
|
||||
const res = this.getResponseObject();
|
||||
res.status(401).send('Unauthorized').end();
|
||||
return {
|
||||
noWebhookResponse: true,
|
||||
};
|
||||
}
|
||||
|
||||
const events = this.getNodeParameter('events', []) as string[];
|
||||
const eventType = bodyData.type as string | undefined;
|
||||
|
||||
if (eventType === undefined || (!events.includes('*') && !events.includes(eventType))) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
export async function verifySignature(this: IWebhookFunctions): Promise<boolean> {
|
||||
const credential = await this.getCredentials('stripeApi');
|
||||
if (!credential?.signatureSecret) {
|
||||
return true; // No signature secret provided, skip verification
|
||||
}
|
||||
|
||||
const req = this.getRequestObject();
|
||||
|
||||
const signature = req.header('stripe-signature');
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the Stripe signature header
|
||||
const elements = signature.split(',');
|
||||
let timestamp: string | undefined;
|
||||
let signatureValue: string | undefined;
|
||||
|
||||
for (const element of elements) {
|
||||
if (element.startsWith('t=')) {
|
||||
timestamp = element.substring(2);
|
||||
} else if (element.startsWith('v1=')) {
|
||||
signatureValue = element.substring(3);
|
||||
}
|
||||
}
|
||||
|
||||
if (!timestamp || !signatureValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify timestamp
|
||||
const currentTimestamp = Math.floor(Date.now() / 1000);
|
||||
const webhookTimestamp = parseInt(timestamp, 10);
|
||||
const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes
|
||||
|
||||
if (Math.abs(currentTimestamp - webhookTimestamp) > TIMESTAMP_TOLERANCE_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof credential.signatureSecret !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!req.rawBody) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let rawBodyString: string;
|
||||
if (Buffer.isBuffer(req.rawBody)) {
|
||||
rawBodyString = req.rawBody.toString();
|
||||
} else {
|
||||
rawBodyString = typeof req.rawBody === 'string' ? req.rawBody : JSON.stringify(req.rawBody);
|
||||
}
|
||||
|
||||
const signedPayload = `${timestamp}.${rawBodyString}`;
|
||||
const hmac = createHmac('sha256', credential.signatureSecret);
|
||||
hmac.update(signedPayload);
|
||||
const computedSignature = hmac.digest('hex');
|
||||
|
||||
const computedBuffer = Buffer.from(computedSignature);
|
||||
const providedBuffer = Buffer.from(signatureValue);
|
||||
|
||||
return (
|
||||
computedBuffer.length === providedBuffer.length &&
|
||||
timingSafeEqual(computedBuffer, providedBuffer)
|
||||
);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { IHookFunctions } from 'n8n-workflow';
|
||||
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { stripeApiRequest } from '../helpers';
|
||||
import { StripeTrigger } from '../StripeTrigger.node';
|
||||
import { verifySignature } from '../StripeTriggerHelpers';
|
||||
|
||||
jest.mock('../helpers', () => ({
|
||||
stripeApiRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../StripeTriggerHelpers', () => ({
|
||||
verifySignature: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const mockedStripeApiRequest = jest.mocked(stripeApiRequest);
|
||||
const mockedVerifySignature = jest.mocked(verifySignature);
|
||||
|
||||
describe('Stripe Trigger Node', () => {
|
||||
let node: StripeTrigger;
|
||||
@@ -96,4 +102,76 @@ describe('Stripe Trigger Node', () => {
|
||||
const requestBody = callArgs[2];
|
||||
expect(requestBody).toHaveProperty('api_version', '2025-05-28.basil');
|
||||
});
|
||||
|
||||
describe('webhook signature verification', () => {
|
||||
let mockWebhookFunctions: IWebhookFunctions;
|
||||
const testBody = { type: 'charge.succeeded', id: 'ch_123' };
|
||||
const rawBody = JSON.stringify(testBody);
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebhookFunctions = {
|
||||
getBodyData: jest.fn().mockReturnValue(testBody),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
rawBody: Buffer.from(rawBody),
|
||||
body: testBody,
|
||||
}),
|
||||
getResponseObject: jest.fn().mockReturnValue({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
end: jest.fn(),
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockReturnValue(['*']),
|
||||
helpers: {
|
||||
returnJsonArray: jest.fn().mockImplementation((data) => [data]),
|
||||
},
|
||||
} as unknown as IWebhookFunctions;
|
||||
|
||||
// Reset the verifySignature mock to return true by default
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('should process webhook with valid signature', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
workflowData: [[testBody]],
|
||||
});
|
||||
expect(mockedVerifySignature).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should reject webhook with invalid signature', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(false);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
noWebhookResponse: true,
|
||||
});
|
||||
expect(mockedVerifySignature).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should handle events filtering correctly', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
(mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue([
|
||||
'payment_intent.succeeded',
|
||||
]);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should process webhook when event type matches filter', async () => {
|
||||
mockedVerifySignature.mockResolvedValue(true);
|
||||
(mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue(['charge.succeeded']);
|
||||
|
||||
const result = await node.webhook.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toEqual({
|
||||
workflowData: [[testBody]],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import type { IWebhookFunctions } from 'n8n-workflow';
|
||||
|
||||
import { verifySignature } from '../StripeTriggerHelpers';
|
||||
|
||||
describe('StripeTriggerHelpers', () => {
|
||||
describe('verifySignature', () => {
|
||||
let mockWebhookFunctions: IWebhookFunctions;
|
||||
const webhookSecret = 'whsec_test123456789';
|
||||
const getCurrentTimestamp = () => Math.floor(Date.now() / 1000).toString();
|
||||
const testBody = { type: 'charge.succeeded', id: 'ch_123' };
|
||||
const rawBody = JSON.stringify(testBody);
|
||||
|
||||
function generateValidSignature(timestamp: string, body: string, secret: string): string {
|
||||
const signedPayload = `${timestamp}.${body}`;
|
||||
const signature = createHmac('sha256', secret).update(signedPayload).digest('hex');
|
||||
return `t=${timestamp},v1=${signature}`;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockWebhookFunctions = {
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
secretKey: 'sk_test_123',
|
||||
signatureSecret: webhookSecret,
|
||||
}),
|
||||
getRequestObject: jest.fn().mockReturnValue({
|
||||
header: jest.fn(),
|
||||
rawBody: Buffer.from(rawBody),
|
||||
}),
|
||||
} as unknown as IWebhookFunctions;
|
||||
});
|
||||
|
||||
it('should return true when no signature secret is provided', async () => {
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
|
||||
secretKey: 'sk_test_123',
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when stripe-signature header is missing', async () => {
|
||||
const mockHeader = jest.fn().mockReturnValue(undefined);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockHeader).toHaveBeenCalledWith('stripe-signature');
|
||||
});
|
||||
|
||||
it('should return false when signature format is invalid', async () => {
|
||||
const mockHeader = jest.fn().mockReturnValue('invalid-format');
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is missing', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const signature = createHmac('sha256', webhookSecret)
|
||||
.update(`${timestamp}.${rawBody}`)
|
||||
.digest('hex');
|
||||
const mockHeader = jest.fn().mockReturnValue(`v1=${signature}`);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when v1 signature is missing', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const mockHeader = jest.fn().mockReturnValue(`t=${timestamp}`);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when signature is valid', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when signature is invalid', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const wrongSecret = 'wrong_secret';
|
||||
const invalidSignature = generateValidSignature(timestamp, rawBody, wrongSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(invalidSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle complex signature header with multiple elements', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const signature = createHmac('sha256', webhookSecret)
|
||||
.update(`${timestamp}.${rawBody}`)
|
||||
.digest('hex');
|
||||
const complexHeader = `t=${timestamp},v1=${signature},v0=old_signature`;
|
||||
const mockHeader = jest.fn().mockReturnValue(complexHeader);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle string rawBody', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody, // String instead of Buffer
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when rawBody is missing', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: null,
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when signatureSecret is not a string', async () => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
(mockWebhookFunctions.getCredentials as jest.Mock).mockResolvedValue({
|
||||
secretKey: 'sk_test_123',
|
||||
signatureSecret: 123, // Not a string
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is older than 5 minutes', async () => {
|
||||
// Create timestamp that's 6 minutes (360 seconds) old
|
||||
const oldTimestamp = (Math.floor(Date.now() / 1000) - 360).toString();
|
||||
const validSignature = generateValidSignature(oldTimestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when timestamp is from the future beyond tolerance', async () => {
|
||||
// Create timestamp that's 6 minutes (360 seconds) in the future
|
||||
const futureTimestamp = (Math.floor(Date.now() / 1000) + 360).toString();
|
||||
const validSignature = generateValidSignature(futureTimestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when timestamp is within tolerance', async () => {
|
||||
// Create timestamp that's 4 minutes (240 seconds) old - within 5 minute tolerance
|
||||
const recentTimestamp = (Math.floor(Date.now() / 1000) - 240).toString();
|
||||
const validSignature = generateValidSignature(recentTimestamp, rawBody, webhookSecret);
|
||||
const mockHeader = jest.fn().mockReturnValue(validSignature);
|
||||
(mockWebhookFunctions.getRequestObject as jest.Mock).mockReturnValue({
|
||||
header: mockHeader,
|
||||
rawBody: Buffer.from(rawBody),
|
||||
});
|
||||
|
||||
const result = await verifySignature.call(mockWebhookFunctions);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user