Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable n8n-nodes-base/node-param-description-excess-final-period */
import { createHmac } from 'crypto';
import type {
IDataObject,
IHookFunctions,
Expand Down Expand Up @@ -948,9 +949,46 @@ export class StripeTrigger implements INodeType {
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
const bodyData = this.getBodyData();
const req = this.getRequestObject();
const headerData = this.getHeaderData();
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Rule violated: Tests

Add workflow or unit tests that cover both valid and invalid Stripe webhook signatures for the newly added verification logic to comply with the Community PR Guidelines testing requirement.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts, line 952:

<comment>Add workflow or unit tests that cover both valid and invalid Stripe webhook signatures for the newly added verification logic to comply with the Community PR Guidelines testing requirement.</comment>

<file context>
@@ -948,9 +949,46 @@ export class StripeTrigger implements INodeType {
 	async webhook(this: IWebhookFunctions): Promise&lt;IWebhookResponseData&gt; {
 		const bodyData = this.getBodyData();
 		const req = this.getRequestObject();
+		const headerData = this.getHeaderData();
+		const webhookData = this.getWorkflowStaticData(&#39;node&#39;);
 
</file context>
Fix with Cubic

const webhookData = this.getWorkflowStaticData('node');

const events = this.getNodeParameter('events', []) as string[];
const stripeSignature = headerData['stripe-signature'] as string | undefined;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Rule violated: Prefer Typeguards over Type casting

Avoid narrowing the Stripe signature header with as; validate the value with a type guard before using it to satisfy the “Prefer Typeguards over Type casting” rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts, line 955:

<comment>Avoid narrowing the Stripe signature header with `as`; validate the value with a type guard before using it to satisfy the “Prefer Typeguards over Type casting” rule.</comment>

<file context>
@@ -948,9 +949,46 @@ export class StripeTrigger implements INodeType {
+		const webhookData = this.getWorkflowStaticData(&#39;node&#39;);
 
-		const events = this.getNodeParameter(&#39;events&#39;, []) as string[];
+		const stripeSignature = headerData[&#39;stripe-signature&#39;] as string | undefined;
+		if (!stripeSignature) {
+			return {};
</file context>
Fix with Cubic

if (!stripeSignature) {
return {};
}

const webhookSecret = webhookData.webhookSecret as string | undefined;
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Rule violated: Prefer Typeguards over Type casting

Replace this as cast on the stored webhook secret with an explicit type guard so the secret is only treated as a string when it actually is one, per the “Prefer Typeguards over Type casting” rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts, line 960:

<comment>Replace this `as` cast on the stored webhook secret with an explicit type guard so the secret is only treated as a string when it actually is one, per the “Prefer Typeguards over Type casting” rule.</comment>

<file context>
@@ -948,9 +949,46 @@ export class StripeTrigger implements INodeType {
+			return {};
+		}
+
+		const webhookSecret = webhookData.webhookSecret as string | undefined;
+		if (!webhookSecret) {
+			return {};
</file context>
Fix with Cubic

if (!webhookSecret) {
return {};
}

const elements = stripeSignature.split(',');
let timestamp: string | undefined;
let signature: string | undefined;

for (const element of elements) {
if (element.startsWith('t=')) {
timestamp = element.substring(2);
} else if (element.startsWith('v1=')) {
signature = element.substring(3);
}
}

if (!timestamp || !signature) {
return {};
}

const signedPayload = `${timestamp}.${req.rawBody.toString()}`;

const expectedSignature = createHmac('sha256', webhookSecret)
.update(signedPayload)
.digest('hex');

if (signature !== expectedSignature) {
return {};
}

const events = this.getNodeParameter('events', []) as string[];
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Rule violated: Prefer Typeguards over Type casting

Do not cast the events parameter to string[] with as; narrow it using a runtime guard (e.g., check that the parameter is an array of strings) to comply with the “Prefer Typeguards over Type casting” rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/nodes-base/nodes/Stripe/StripeTrigger.node.ts, line 991:

<comment>Do not cast the `events` parameter to `string[]` with `as`; narrow it using a runtime guard (e.g., check that the parameter is an array of strings) to comply with the “Prefer Typeguards over Type casting” rule.</comment>

<file context>
@@ -948,9 +949,46 @@ export class StripeTrigger implements INodeType {
+			return {};
+		}
+
+		const events = this.getNodeParameter(&#39;events&#39;, []) as string[];
 		const eventType = bodyData.type as string | undefined;
 
</file context>
Fix with Cubic

const eventType = bodyData.type as string | undefined;

if (eventType === undefined || (!events.includes('*') && !events.includes(eventType))) {
Expand Down
169 changes: 168 additions & 1 deletion packages/nodes-base/nodes/Stripe/__tests__/StripeTrigger.node.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { IHookFunctions } from 'n8n-workflow';
import type { IHookFunctions, IWebhookFunctions } from 'n8n-workflow';
import { createHmac } from 'crypto';

import { stripeApiRequest } from '../helpers';
import { StripeTrigger } from '../StripeTrigger.node';
Expand Down Expand Up @@ -96,4 +97,170 @@ 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 webhookSecret = 'whsec_test123456789';
const timestamp = '1234567890';
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,
}),
getHeaderData: jest.fn(),
getWorkflowStaticData: jest.fn().mockReturnValue({
webhookSecret,
}),
getNodeParameter: jest.fn().mockReturnValue(['*']),
helpers: {
returnJsonArray: jest.fn().mockImplementation((data) => [data]),
},
} as unknown as IWebhookFunctions;
});

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}`;
}

it('should process webhook with valid signature', async () => {
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': validSignature,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({
workflowData: [[testBody]],
});
});

it('should reject webhook with missing signature', async () => {
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with missing webhook secret', async () => {
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': validSignature,
});
(mockWebhookFunctions.getWorkflowStaticData as jest.Mock).mockReturnValue({});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with invalid signature format', async () => {
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': 'invalid-format',
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with missing timestamp in signature', async () => {
const signature = createHmac('sha256', webhookSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': `v1=${signature}`,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with missing v1 signature', async () => {
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': `t=${timestamp}`,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with incorrect signature', async () => {
const wrongSecret = 'wrong_secret';
const invalidSignature = generateValidSignature(timestamp, rawBody, wrongSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': invalidSignature,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should reject webhook with signature for different body', async () => {
const differentBody = JSON.stringify({ type: 'payment_intent.succeeded' });
const invalidSignature = generateValidSignature(timestamp, differentBody, webhookSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': invalidSignature,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({});
});

it('should handle events filtering correctly', async () => {
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': validSignature,
});
(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 () => {
const validSignature = generateValidSignature(timestamp, rawBody, webhookSecret);
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': validSignature,
});
(mockWebhookFunctions.getNodeParameter as jest.Mock).mockReturnValue(['charge.succeeded']);

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({
workflowData: [[testBody]],
});
});

it('should handle complex signature header with multiple elements', async () => {
const signature = createHmac('sha256', webhookSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const complexHeader = `t=${timestamp},v1=${signature},v0=old_signature`;
(mockWebhookFunctions.getHeaderData as jest.Mock).mockReturnValue({
'stripe-signature': complexHeader,
});

const result = await node.webhook.call(mockWebhookFunctions);

expect(result).toEqual({
workflowData: [[testBody]],
});
});
});
});
Loading