mirror of
https://github.com/n8n-io/n8n
synced 2026-04-21 15:47:20 +00:00
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:
parent
21317b8945
commit
3b248eedc2
5 changed files with 220 additions and 0 deletions
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
47
packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts
Normal file
47
packages/nodes-base/nodes/Linear/LinearTriggerHelpers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue