feat: Add token to sendAndWait operations links to walidate in webhook (#17566)

This commit is contained in:
Michael Kret
2025-08-06 17:28:50 +03:00
committed by GitHub
parent 6495e08c79
commit 9cb5754f33
30 changed files with 277 additions and 92 deletions

View File

@@ -210,6 +210,8 @@ export class NodeTestHarness {
hooks.addHandler('workflowExecuteAfter', (fullRunData) => waitPromise.resolve(fullRunData));
const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: '1',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
hooks,
// Get from node.parameters
currentNodeParameters: undefined,

View File

@@ -19,3 +19,5 @@ export const CREDENTIAL_ERRORS = {
INVALID_JSON: 'Decrypted credentials data is not valid JSON.',
INVALID_DATA: 'Credentials data is not in a valid format.',
};
export const WAITING_TOKEN_QUERY_PARAM = 'signature';

View File

@@ -19,7 +19,11 @@ import { NodeExecutionContext } from '../node-execution-context';
class TestContext extends NodeExecutionContext {}
describe('NodeExecutionContext', () => {
const instanceSettings = mock<InstanceSettings>({ instanceId: 'abc123' });
const instanceSettings = mock<InstanceSettings>({
instanceId: 'abc123',
encryptionKey: 'testEncryptionKey',
hmacSignatureSecret: 'testHmacSignatureSecret',
});
Container.set(InstanceSettings, instanceSettings);
const node = mock<INode>();
@@ -363,4 +367,41 @@ describe('NodeExecutionContext', () => {
expect(result).toEqual([node1]);
});
});
describe('getSignedResumeUrl', () => {
beforeEach(() => {
jest.clearAllMocks();
testContext = new TestContext(
workflow,
mock<INode>({
id: 'node456',
}),
mock<IWorkflowExecuteAdditionalData>({
executionId: '123',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
}),
mode,
{
validateSignature: true,
resultData: { runData: {} },
},
);
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
});
it('should return a signed resume URL with no query parameters', () => {
const result = testContext.getSignedResumeUrl();
expect(result).toBe(
'http://localhost/waiting-webhook/123/node456?signature=8e48dfd1107c1a736f70e7399493ffc50a2e8edd44f389c5f9c058da961682e7',
);
});
it('should return a signed resume URL with query parameters', () => {
const result = testContext.getSignedResumeUrl({ approved: 'true' });
expect(result).toBe(
'http://localhost/waiting-webhook/123/node456?approved=true&signature=11c5efc97a0d6f2ea9045dba6e397596cba29dc24adb44a9ebd3d1272c991e9b',
);
});
});
});

View File

@@ -30,12 +30,14 @@ import {
ExpressionError,
NodeHelpers,
NodeOperationError,
UnexpectedError,
} from 'n8n-workflow';
import {
HTTP_REQUEST_AS_TOOL_NODE_TYPE,
HTTP_REQUEST_NODE_TYPE,
HTTP_REQUEST_TOOL_NODE_TYPE,
WAITING_TOKEN_QUERY_PARAM,
} from '@/constants';
import { InstanceSettings } from '@/instance-settings';
@@ -44,6 +46,7 @@ import { ensureType } from './utils/ensure-type';
import { extractValue } from './utils/extract-value';
import { getAdditionalKeys } from './utils/get-additional-keys';
import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
import { generateUrlSignature, prepareUrlForSigning } from '../../utils/signature-helpers';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings);
@@ -200,6 +203,32 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.instanceSettings.instanceId;
}
setSignatureValidationRequired() {
if (this.runExecutionData) this.runExecutionData.validateSignature = true;
}
getSignedResumeUrl(parameters: Record<string, string> = {}) {
const { webhookWaitingBaseUrl, executionId } = this.additionalData;
if (typeof executionId !== 'string') {
throw new UnexpectedError('Execution id is missing');
}
const baseURL = new URL(`${webhookWaitingBaseUrl}/${executionId}/${this.node.id}`);
for (const [key, value] of Object.entries(parameters)) {
baseURL.searchParams.set(key, value);
}
const urlForSigning = prepareUrlForSigning(baseURL);
const token = generateUrlSignature(urlForSigning, this.instanceSettings.hmacSignatureSecret);
baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);
return baseURL.toString();
}
getTimezone() {
return this.workflow.timezone;
}

View File

@@ -24,7 +24,6 @@ import { getInputConnectionData } from './utils/get-input-connection-data';
import { getRequestHelperFunctions } from './utils/request-helper-functions';
import { returnJsonArray } from './utils/return-json-array';
import { getNodeWebhookUrl } from './utils/webhook-helper-functions';
export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions {
readonly helpers: IWebhookFunctions['helpers'];

View File

@@ -52,6 +52,8 @@ export class InstanceSettings {
*/
readonly instanceId: string;
readonly hmacSignatureSecret: string;
readonly instanceType: InstanceType;
constructor(
@@ -64,6 +66,7 @@ export class InstanceSettings {
this.hostId = `${this.instanceType}-${this.isDocker ? os.hostname() : nanoid()}`;
this.settings = this.loadOrCreate();
this.instanceId = this.generateInstanceId();
this.hmacSignatureSecret = this.getOrGenerateHmacSignatureSecret();
}
/**
@@ -225,6 +228,14 @@ export class InstanceSettings {
.digest('hex');
}
private getOrGenerateHmacSignatureSecret() {
const hmacSignatureSecretFromEnv = process.env.N8N_HMAC_SIGNATURE_SECRET;
if (hmacSignatureSecretFromEnv) return hmacSignatureSecretFromEnv;
const { encryptionKey } = this;
return createHash('sha256').update(`hmac-signature:${encryptionKey}`).digest('hex');
}
private save(settings: Settings) {
this.settings = settings;
writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, '\t'), {

View File

@@ -0,0 +1,26 @@
import { generateUrlSignature } from '../signature-helpers';
describe('signature-helpers', () => {
const secret = 'test-secret';
const baseUrl = 'http://localhost:5678';
describe('generateUrlSignature', () => {
it('should generate a signature token', () => {
const url = `${baseUrl}/webhook/abc`;
const token = generateUrlSignature(url, secret);
expect(token).toBe('fe7f1e4c11f875b2d24681e0b28d0bfed6d66381af5b0ab9633da2202a895243');
});
it('should generate a different token for a different url', () => {
const url = `${baseUrl}/webhook/def`;
const token = generateUrlSignature(url, secret);
expect(token).toBe('ab8e72e7a0e47689596a6550283cbef9e2797b7370b0d6d99c89ee7c2394ea8f');
});
it('should generate a different token for a different secret', () => {
const url = `${baseUrl}/webhook/abc`;
const token = generateUrlSignature(url, 'different-secret');
expect(token).toBe('84a99b6950e12ffcf1fcf8e0fc0986c0c8a46df331932efd79b17e0c11801bd2');
});
});
});

View File

@@ -1 +1,2 @@
export * from './serialized-buffer';
export * from './signature-helpers';

View File

@@ -0,0 +1,16 @@
import crypto from 'crypto';
/**
* Generate signature token from url and secret
*/
export function generateUrlSignature(url: string, secret: string) {
const token = crypto.createHmac('sha256', secret).update(url).digest('hex');
return token;
}
/**
* Prepare url for signing
*/
export function prepareUrlForSigning(url: URL) {
return `${url.host}${url.pathname}${url.search}`;
}