From 16728b301cf4c300b3ef61405bd23e16a170c87b Mon Sep 17 00:00:00 2001 From: Sandra Zollner Date: Fri, 29 May 2026 16:49:12 +0200 Subject: [PATCH] fix: Set Content-Type for Meta-family trigger node responses (#31354) Co-authored-by: Cursor --- .../nodes/Facebook/FacebookTrigger.node.ts | 18 ++--- .../__tests__/FacebookTrigger.node.test.ts | 74 +++++++++++++++++++ .../FacebookLeadAdsTrigger.node.ts | 2 +- .../FacebookLeadAdsTrigger.node.test.ts | 48 ++++++++++++ .../Teams/MicrosoftTeamsTrigger.node.ts | 2 +- .../test/trigger/MicrosoftTeamTrigger.test.ts | 2 + .../nodes/WhatsApp/WhatsAppTrigger.node.ts | 2 +- .../tests/WhatsAppTrigger.node.test.ts | 2 + 8 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 packages/nodes-base/nodes/Facebook/__tests__/FacebookTrigger.node.test.ts create mode 100644 packages/nodes-base/nodes/FacebookLeadAds/__tests__/FacebookLeadAdsTrigger.node.test.ts diff --git a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts index fa71edcf98b..713f3552112 100644 --- a/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts +++ b/packages/nodes-base/nodes/Facebook/FacebookTrigger.node.ts @@ -12,7 +12,6 @@ import type { JsonObject, } from 'n8n-workflow'; import { NodeApiError, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; -import { v4 as uuid } from 'uuid'; import { facebookApiRequest, getAllFields, getFields } from './GenericFunctions'; import type { FacebookWebhookSubscription } from './types'; @@ -241,7 +240,6 @@ export class FacebookTrigger implements INodeType { return true; }, async create(this: IHookFunctions): Promise { - const webhookData = this.getWorkflowStaticData('node'); const webhookUrl = this.getNodeWebhookUrl('default') as string; const object = this.getNodeParameter('object') as string; const appId = this.getNodeParameter('appId') as string; @@ -251,7 +249,7 @@ export class FacebookTrigger implements INodeType { const body = { object: snakeCase(object), callback_url: webhookUrl, - verify_token: uuid(), + verify_token: this.getNode().id, fields: fields.includes('*') ? getAllFields(object) : fields, } as IDataObject; @@ -266,8 +264,6 @@ export class FacebookTrigger implements INodeType { body, ); - webhookData.verifyToken = body.verify_token; - if (responseData.success !== true) { // Facebook did not return success, so something went wrong throw new NodeApiError(this.getNode(), responseData as JsonObject, { @@ -305,13 +301,11 @@ export class FacebookTrigger implements INodeType { // Check if we're getting facebook's challenge request (https://developers.facebook.com/docs/graph-api/webhooks/getting-started) if (this.getWebhookName() === 'setup') { if (query['hub.challenge']) { - //TODO - //compare hub.verify_token with the saved token - //const webhookData = this.getWorkflowStaticData('node'); - // if (webhookData.verifyToken !== query['hub.verify_token']) { - // return {}; - // } - res.status(200).send(query['hub.challenge']).end(); + if (this.getNode().id !== query['hub.verify_token']) { + return {}; + } + + res.status(200).type('text/plain').send(query['hub.challenge']).end(); return { noWebhookResponse: true, }; diff --git a/packages/nodes-base/nodes/Facebook/__tests__/FacebookTrigger.node.test.ts b/packages/nodes-base/nodes/Facebook/__tests__/FacebookTrigger.node.test.ts new file mode 100644 index 00000000000..d27393dcf77 --- /dev/null +++ b/packages/nodes-base/nodes/Facebook/__tests__/FacebookTrigger.node.test.ts @@ -0,0 +1,74 @@ +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import type { IDataObject, IWebhookFunctions } from 'n8n-workflow'; + +import { FacebookTrigger } from '../FacebookTrigger.node'; + +describe('FacebookTrigger', () => { + let node: FacebookTrigger; + let mockWebhookFunctions: jest.Mocked; + + beforeEach(() => { + node = new FacebookTrigger(); + mockWebhookFunctions = mock(); + jest.clearAllMocks(); + }); + + describe('webhook', () => { + const createMockResponse = () => + ({ + status: jest.fn().mockReturnThis(), + type: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + end: jest.fn(), + }) as unknown as Response; + + it('should respond to verification challenge as text/plain when the verify token matches', async () => { + const mockResponse = createMockResponse(); + + mockWebhookFunctions.getNode.mockReturnValue({ id: 'test-token' } as any); + mockWebhookFunctions.getWebhookName.mockReturnValue('setup'); + mockWebhookFunctions.getQueryData.mockReturnValue({ + 'hub.challenge': 'test-challenge', + 'hub.verify_token': 'test-token', + } as IDataObject); + mockWebhookFunctions.getBodyData.mockReturnValue({}); + mockWebhookFunctions.getHeaderData.mockReturnValue({}); + mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse); + mockWebhookFunctions.getNodeParameter.mockReturnValue('accessToken'); + mockWebhookFunctions.getCredentials.mockResolvedValue({ appSecret: '' }); + + const result = await node.webhook.call(mockWebhookFunctions); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.type).toHaveBeenCalledWith('text/plain'); + expect(mockResponse.send).toHaveBeenCalledWith('test-challenge'); + expect(mockResponse.end).toHaveBeenCalled(); + expect(result).toEqual({ noWebhookResponse: true }); + }); + + it('should reject the verification challenge when the verify token does not match', async () => { + const mockResponse = createMockResponse(); + + mockWebhookFunctions.getNode.mockReturnValue({ id: 'expected-token' } as any); + mockWebhookFunctions.getWebhookName.mockReturnValue('setup'); + mockWebhookFunctions.getQueryData.mockReturnValue({ + 'hub.challenge': 'test-challenge', + 'hub.verify_token': 'attacker-supplied-token', + } as IDataObject); + mockWebhookFunctions.getBodyData.mockReturnValue({}); + mockWebhookFunctions.getHeaderData.mockReturnValue({}); + mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse); + mockWebhookFunctions.getNodeParameter.mockReturnValue('accessToken'); + mockWebhookFunctions.getCredentials.mockResolvedValue({ appSecret: '' }); + + const result = await node.webhook.call(mockWebhookFunctions); + + expect(mockResponse.send).not.toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); +}); diff --git a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts index 090be5f12f7..91d72cca72a 100644 --- a/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts +++ b/packages/nodes-base/nodes/FacebookLeadAds/FacebookLeadAdsTrigger.node.ts @@ -224,7 +224,7 @@ export class FacebookLeadAdsTrigger implements INodeType { return {}; } - res.status(200).send(query['hub.challenge']).end(); + res.status(200).type('text/plain').send(query['hub.challenge']).end(); return { noWebhookResponse: true }; } diff --git a/packages/nodes-base/nodes/FacebookLeadAds/__tests__/FacebookLeadAdsTrigger.node.test.ts b/packages/nodes-base/nodes/FacebookLeadAds/__tests__/FacebookLeadAdsTrigger.node.test.ts new file mode 100644 index 00000000000..86163745c7b --- /dev/null +++ b/packages/nodes-base/nodes/FacebookLeadAds/__tests__/FacebookLeadAdsTrigger.node.test.ts @@ -0,0 +1,48 @@ +import type { Response } from 'express'; +import { mock } from 'jest-mock-extended'; +import type { IDataObject, INode, IWebhookFunctions } from 'n8n-workflow'; + +import { FacebookLeadAdsTrigger } from '../FacebookLeadAdsTrigger.node'; + +describe('FacebookLeadAdsTrigger', () => { + let node: FacebookLeadAdsTrigger; + let mockWebhookFunctions: jest.Mocked; + + beforeEach(() => { + node = new FacebookLeadAdsTrigger(); + mockWebhookFunctions = mock(); + jest.clearAllMocks(); + }); + + describe('webhook', () => { + it('should respond to verification challenge as text/plain', async () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + type: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + end: jest.fn(), + } as unknown as Response; + + mockWebhookFunctions.getWebhookName.mockReturnValue('setup'); + mockWebhookFunctions.getQueryData.mockReturnValue({ + 'hub.challenge': 'test-challenge', + 'hub.verify_token': 'test-node-id', + } as IDataObject); + mockWebhookFunctions.getBodyData.mockReturnValue({}); + mockWebhookFunctions.getHeaderData.mockReturnValue({}); + mockWebhookFunctions.getRequestObject.mockReturnValue({ rawBody: Buffer.from('') } as any); + mockWebhookFunctions.getResponseObject.mockReturnValue(mockResponse); + mockWebhookFunctions.getCredentials.mockResolvedValue({ clientSecret: 'secret' }); + mockWebhookFunctions.getNodeParameter.mockReturnValue(''); + mockWebhookFunctions.getNode.mockReturnValue(mock({ id: 'test-node-id' })); + + const result = await node.webhook.call(mockWebhookFunctions); + + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.type).toHaveBeenCalledWith('text/plain'); + expect(mockResponse.send).toHaveBeenCalledWith('test-challenge'); + expect(mockResponse.end).toHaveBeenCalled(); + expect(result).toEqual({ noWebhookResponse: true }); + }); + }); +}); diff --git a/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts index 4a9f03e6eba..0ace3d95fb2 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/MicrosoftTeamsTrigger.node.ts @@ -398,7 +398,7 @@ export class MicrosoftTeamsTrigger implements INodeType { // Handle Microsoft Graph validation request if (req.query.validationToken) { - res.status(200).send(req.query.validationToken); + res.status(200).type('text/plain').send(req.query.validationToken); return { noWebhookResponse: true }; } diff --git a/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts index 1641918f421..528fb6458b7 100644 --- a/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts +++ b/packages/nodes-base/nodes/Microsoft/Teams/test/trigger/MicrosoftTeamTrigger.test.ts @@ -217,6 +217,7 @@ describe('Microsoft Teams Trigger Node', () => { }; const mockResponse = { status: jest.fn().mockReturnThis(), + type: jest.fn().mockReturnThis(), send: jest.fn(), }; @@ -225,6 +226,7 @@ describe('Microsoft Teams Trigger Node', () => { const result = await new MicrosoftTeamsTrigger().webhook.call(mockWebhookFunctions); expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.type).toHaveBeenCalledWith('text/plain'); expect(mockResponse.send).toHaveBeenCalledWith('validation-token'); expect(result.noWebhookResponse).toBe(true); }); diff --git a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts index 2a5c2c04aac..29b78f09684 100644 --- a/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts +++ b/packages/nodes-base/nodes/WhatsApp/WhatsAppTrigger.node.ts @@ -268,7 +268,7 @@ export class WhatsAppTrigger implements INodeType { return {}; } - res.status(200).send(query['hub.challenge']).end(); + res.status(200).type('text/plain').send(query['hub.challenge']).end(); return { noWebhookResponse: true }; } diff --git a/packages/nodes-base/nodes/WhatsApp/tests/WhatsAppTrigger.node.test.ts b/packages/nodes-base/nodes/WhatsApp/tests/WhatsAppTrigger.node.test.ts index a6de17c33fe..e4c7e3a67f7 100644 --- a/packages/nodes-base/nodes/WhatsApp/tests/WhatsAppTrigger.node.test.ts +++ b/packages/nodes-base/nodes/WhatsApp/tests/WhatsAppTrigger.node.test.ts @@ -229,6 +229,7 @@ describe('WhatsAppTrigger', () => { }; const mockResponse = { status: jest.fn().mockReturnThis(), + type: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), end: jest.fn(), } as unknown as express.Response; @@ -245,6 +246,7 @@ describe('WhatsAppTrigger', () => { expect(result).toEqual({ noWebhookResponse: true }); expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.type).toHaveBeenCalledWith('text/plain'); expect(mockResponse.send).toHaveBeenCalledWith('test-challenge'); expect(mockResponse.end).toHaveBeenCalled(); });