feat(Linear Trigger Node): Add signing secret validation (#28522)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Eugene 2026-04-17 14:33:01 +02:00 committed by GitHub
parent 21317b8945
commit 3b248eedc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 220 additions and 0 deletions

View file

@ -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 = {

View file

@ -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.',
},
];
}

View file

@ -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<IWebhookResponseData> {
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)],

View file

@ -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<boolean> {
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;
}
}

View file

@ -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);
});
});
});