From 3b248eedc289c62f32f16da677c75b25df0fcb9f Mon Sep 17 00:00:00 2001 From: Eugene Date: Fri, 17 Apr 2026 14:33:01 +0200 Subject: [PATCH] feat(Linear Trigger Node): Add signing secret validation (#28522) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- .../credentials/LinearApi.credentials.ts | 9 ++ .../LinearOAuth2Api.credentials.ts | 9 ++ .../nodes/Linear/LinearTrigger.node.ts | 10 ++ .../nodes/Linear/LinearTriggerHelpers.ts | 47 ++++++ .../Linear/test/LinearTriggerHelpers.test.ts | 145 ++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts create mode 100644 packages/nodes-base/nodes/Linear/test/LinearTriggerHelpers.test.ts diff --git a/packages/nodes-base/credentials/LinearApi.credentials.ts b/packages/nodes-base/credentials/LinearApi.credentials.ts index e9a0ec178e5..0765d43b0ce 100644 --- a/packages/nodes-base/credentials/LinearApi.credentials.ts +++ b/packages/nodes-base/credentials/LinearApi.credentials.ts @@ -15,6 +15,15 @@ export class LinearApi implements ICredentialType { typeOptions: { password: true }, default: '', }, + { + displayName: 'Signing Secret', + name: 'signingSecret', + type: 'string', + typeOptions: { password: true }, + default: '', + description: + 'The signing secret is used to verify the authenticity of webhook requests sent by Linear.', + }, ]; authenticate: IAuthenticateGeneric = { diff --git a/packages/nodes-base/credentials/LinearOAuth2Api.credentials.ts b/packages/nodes-base/credentials/LinearOAuth2Api.credentials.ts index e41e22ea79c..aab8dd0b944 100644 --- a/packages/nodes-base/credentials/LinearOAuth2Api.credentials.ts +++ b/packages/nodes-base/credentials/LinearOAuth2Api.credentials.ts @@ -75,5 +75,14 @@ export class LinearOAuth2Api implements ICredentialType { type: 'hidden', default: 'body', }, + { + displayName: 'Signing Secret', + name: 'signingSecret', + type: 'string', + typeOptions: { password: true }, + default: '', + description: + 'The signing secret is used to verify the authenticity of webhook requests sent by Linear.', + }, ]; } diff --git a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts index a35e2efdb53..f8a41378aad 100644 --- a/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts +++ b/packages/nodes-base/nodes/Linear/LinearTrigger.node.ts @@ -10,6 +10,7 @@ import { } from 'n8n-workflow'; import { capitalizeFirstLetter, linearApiRequest } from './GenericFunctions'; +import { verifySignature } from './LinearTriggerHelpers'; export class LinearTrigger implements INodeType { description: INodeTypeDescription = { @@ -274,6 +275,15 @@ export class LinearTrigger implements INodeType { }; async webhook(this: IWebhookFunctions): Promise { + const isSignatureValid = await verifySignature.call(this); + if (!isSignatureValid) { + const res = this.getResponseObject(); + res.status(401).send('Unauthorized').end(); + return { + noWebhookResponse: true, + }; + } + const bodyData = this.getBodyData(); return { workflowData: [this.helpers.returnJsonArray(bodyData)], diff --git a/packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts b/packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts new file mode 100644 index 00000000000..10889e62785 --- /dev/null +++ b/packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts @@ -0,0 +1,47 @@ +import { createHmac } from 'crypto'; +import type { IWebhookFunctions } from 'n8n-workflow'; + +import { verifySignature as verifySignatureGeneric } from '../../utils/webhook-signature-verification'; + +export async function verifySignature(this: IWebhookFunctions): Promise { + const authenticationMethod = this.getNodeParameter('authentication', 'apiToken') as string; + const credentialType = authenticationMethod === 'apiToken' ? 'linearApi' : 'linearOAuth2Api'; + const credential = await this.getCredentials(credentialType); + const req = this.getRequestObject(); + + const signingSecret = credential.signingSecret; + try { + return verifySignatureGeneric({ + getExpectedSignature: () => { + if (!signingSecret || typeof signingSecret !== 'string' || !req.rawBody) { + return null; + } + + const hmac = createHmac('sha256', signingSecret); + + if (Buffer.isBuffer(req.rawBody) || typeof req.rawBody === 'string') { + hmac.update(req.rawBody); + } else { + return null; + } + + return hmac.digest('hex'); + }, + skipIfNoExpectedSignature: !signingSecret || typeof signingSecret !== 'string', + getActualSignature: () => { + const actualSignature = req.header('linear-signature'); + return typeof actualSignature === 'string' ? actualSignature : null; + }, + getTimestamp: () => { + // Linear sends webhookTimestamp in the body payload (UNIX ms) + const body = this.getBodyData(); + const timestamp = body.webhookTimestamp; + return typeof timestamp === 'number' ? timestamp : null; + }, + skipIfNoTimestamp: true, + maxTimestampAgeSeconds: 60, + }); + } catch (error) { + return false; + } +} diff --git a/packages/nodes-base/nodes/Linear/test/LinearTriggerHelpers.test.ts b/packages/nodes-base/nodes/Linear/test/LinearTriggerHelpers.test.ts new file mode 100644 index 00000000000..3dad6ca1241 --- /dev/null +++ b/packages/nodes-base/nodes/Linear/test/LinearTriggerHelpers.test.ts @@ -0,0 +1,145 @@ +import { createHmac } from 'crypto'; +import { verifySignature } from '../LinearTriggerHelpers'; + +describe('LinearTriggerHelpers', () => { + let mockWebhookFunctions: any; + const testSigningSecret = 'test-linear-signing-secret'; + const testBody = '{"action":"create","type":"Issue","data":{"id":"123"}}'; + const testSignature = createHmac('sha256', testSigningSecret).update(testBody).digest('hex'); + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock Date.now() to a fixed timestamp + jest.spyOn(Date, 'now').mockImplementation(() => 1700000000000); + + mockWebhookFunctions = { + getCredentials: jest.fn(), + getRequestObject: jest.fn(), + getNodeParameter: jest.fn(), + getBodyData: jest.fn().mockReturnValue({ webhookTimestamp: 1700000000000 }), + getNode: jest.fn().mockReturnValue({ name: 'Linear Trigger' }), + }; + + mockWebhookFunctions.getNodeParameter.mockReturnValue('apiToken'); + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((header: string) => { + if (header === 'linear-signature') return testSignature; + return null; + }), + rawBody: testBody, + }); + }); + + describe('verifySignature', () => { + it('should return true when no signing secret is configured', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + apiKey: 'test-key', + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('linearApi'); + }); + + it('should use linearOAuth2Api credentials when authentication is oAuth2', async () => { + mockWebhookFunctions.getNodeParameter.mockReturnValue('oAuth2'); + mockWebhookFunctions.getCredentials.mockResolvedValue({}); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + expect(mockWebhookFunctions.getCredentials).toHaveBeenCalledWith('linearOAuth2Api'); + }); + + it('should return false when Linear-Signature header is missing', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: testSigningSecret, + }); + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(null), + rawBody: testBody, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return true when empty signing secret and no header', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: '', + }); + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockReturnValue(null), + rawBody: testBody, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return true when signature is valid', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: testSigningSecret, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + + it('should return false when signature is invalid', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: testSigningSecret, + }); + + mockWebhookFunctions.getRequestObject.mockReturnValue({ + header: jest.fn().mockImplementation((header: string) => { + if (header === 'linear-signature') return 'invalidsignature'; + return null; + }), + rawBody: testBody, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return false when webhookTimestamp is too old', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: testSigningSecret, + }); + + // Timestamp 120 seconds old (Should be within 60s window) + mockWebhookFunctions.getBodyData.mockReturnValue({ + webhookTimestamp: 1700000000000 - 120_000, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(false); + }); + + it('should return true when webhookTimestamp difference is within acceptable window', async () => { + mockWebhookFunctions.getCredentials.mockResolvedValue({ + signingSecret: testSigningSecret, + }); + + // Timestamp 30 seconds old + mockWebhookFunctions.getBodyData.mockReturnValue({ + webhookTimestamp: 1700000000000 - 30_000, + }); + + const result = await verifySignature.call(mockWebhookFunctions); + + expect(result).toBe(true); + }); + }); +});