feat(core): Add signature validation for waiting webhooks and forms (#24159)

Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com>
This commit is contained in:
Elias Meire 2026-03-23 12:48:52 +01:00 committed by GitHub
parent 88f9f2ed65
commit 5b6ee17c81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 2408 additions and 413 deletions

View File

@ -388,8 +388,8 @@ describe('JsTaskRunner', () => {
{ {
id: 'exec-id', id: 'exec-id',
mode: 'test', mode: 'test',
resumeFormUrl: 'http://formWaitingBaseUrl/exec-id', resumeFormUrl: 'http://formwaitingbaseurl/exec-id?signature=test-resume-token',
resumeUrl: 'http://webhookWaitingBaseUrl/exec-id', resumeUrl: 'http://webhookwaitingbaseurl/exec-id?signature=test-resume-token',
customData: { customData: {
get: expect.any(Function), get: expect.any(Function),
getAll: expect.any(Function), getAll: expect.any(Function),

View File

@ -90,6 +90,7 @@ export const newDataRequestResponse = (
}, },
node: codeNode, node: codeNode,
runExecutionData: createRunExecutionData({ runExecutionData: createRunExecutionData({
resumeToken: 'test-resume-token',
resultData: { resultData: {
runData: { runData: {
[manualTriggerNode.name]: [ [manualTriggerNode.name]: [

View File

@ -352,8 +352,10 @@ describe('WorkflowExecuteAdditionalData', () => {
}); });
it('should return default data', () => { it('should return default data', () => {
expect(getRunData(workflow)).toEqual({ const result = getRunData(workflow);
expect(result).toEqual({
executionData: createRunExecutionData({ executionData: createRunExecutionData({
resumeToken: result.executionData?.resumeToken,
executionData: { executionData: {
contextData: {}, contextData: {},
metadata: {}, metadata: {},
@ -388,8 +390,10 @@ describe('WorkflowExecuteAdditionalData', () => {
executionId: '123', executionId: '123',
workflowId: '567', workflowId: '567',
}; };
expect(getRunData(workflow, data, parentExecution)).toEqual({ const result = getRunData(workflow, data, parentExecution);
expect(result).toEqual({
executionData: createRunExecutionData({ executionData: createRunExecutionData({
resumeToken: result.executionData?.resumeToken,
executionData: { executionData: {
contextData: {}, contextData: {},
metadata: {}, metadata: {},

View File

@ -890,21 +890,24 @@ describe('TestRunnerService', () => {
triggerToStartFrom: { triggerToStartFrom: {
name: triggerNodeName, name: triggerNodeName,
}, },
executionData: createRunExecutionData({ executionData: {
executionData: null, ...createRunExecutionData({
resultData: { executionData: null,
pinData: { resultData: {
[triggerNodeName]: [testCase], pinData: {
[triggerNodeName]: [testCase],
},
runData: {},
}, },
runData: {}, manualData: {
}, userId: metadata.userId,
manualData: { triggerToStartFrom: {
userId: metadata.userId, name: triggerNodeName,
triggerToStartFrom: { },
name: triggerNodeName,
}, },
}, }),
}), resumeToken: expect.any(String),
},
}), }),
); );
}); });

View File

@ -59,6 +59,7 @@ const metadata = {
}; };
const runExecutionData = mock<IRunExecutionData>({ const runExecutionData = mock<IRunExecutionData>({
resumeToken: 'test-resume-token-preserved',
executionData: { executionData: {
contextData, contextData,
metadata, metadata,
@ -128,8 +129,8 @@ describe('DataRequestResponseBuilder', () => {
it('clears nodeExecutionStack, waitingExecution and waitingExecutionSource from runExecutionData', () => { it('clears nodeExecutionStack, waitingExecution and waitingExecutionSource from runExecutionData', () => {
const result = builder.buildFromTaskData(taskData); const result = builder.buildFromTaskData(taskData);
expect(result.runExecutionData).toStrictEqual( expect(result.runExecutionData).toStrictEqual({
createRunExecutionData({ ...createRunExecutionData({
startData: runExecutionData.startData, startData: runExecutionData.startData,
resultData: runExecutionData.resultData, resultData: runExecutionData.resultData,
executionData: { executionData: {
@ -140,6 +141,7 @@ describe('DataRequestResponseBuilder', () => {
waitingExecutionSource: null, waitingExecutionSource: null,
}, },
}), }),
); resumeToken: 'test-resume-token-preserved',
});
}); });
}); });

View File

@ -190,6 +190,23 @@ describe('DataRequestResponseStripper', () => {
}); });
}); });
describe('resumeToken', () => {
it('should preserve resumeToken when all data is requested', () => {
const result = new DataRequestResponseStripper(taskData, allDataParam).strip();
expect(result.runExecutionData.resumeToken).toBe(taskData.runExecutionData.resumeToken);
});
it('should preserve resumeToken when stripping with partial dataOfNodes', () => {
const result = new DataRequestResponseStripper(
taskData,
newRequestParam({ dataOfNodes: [codeNode.name], prevNode: false }),
).strip();
expect(result.runExecutionData.resumeToken).toBe(taskData.runExecutionData.resumeToken);
});
});
describe('envProviderState', () => { describe('envProviderState', () => {
it("should filter out envProviderState when it's not requested", () => { it("should filter out envProviderState when it's not requested", () => {
const dataRequestResponseBuilder = new DataRequestResponseStripper( const dataRequestResponseBuilder = new DataRequestResponseStripper(

View File

@ -82,6 +82,7 @@ export class DataRequestResponseBuilder {
waitingExecutionSource: null, waitingExecutionSource: null,
} }
: undefined, : undefined,
resumeToken: runExecutionData.resumeToken,
}); });
} }
} }

View File

@ -57,6 +57,7 @@ export class DataRequestResponseStripper {
// TODO: We could send `runExecutionData.contextData` only if requested, // TODO: We could send `runExecutionData.contextData` only if requested,
// since it's only needed if $input.context or $("node").context is used. // since it's only needed if $input.context or $("node").context is used.
executionData: runExecutionData.executionData, executionData: runExecutionData.executionData,
resumeToken: runExecutionData.resumeToken,
}); });
} }

View File

@ -1,22 +1,42 @@
import type { IExecutionResponse, ExecutionRepository } from '@n8n/db'; import type { IExecutionResponse, ExecutionRepository } from '@n8n/db';
import type express from 'express'; import type express from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { getWebhookSandboxCSP } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core';
import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n8n-workflow'; import { getWebhookSandboxCSP, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import {
FORM_NODE_TYPE,
WAITING_FORMS_EXECUTION_STATUS,
type IWorkflowBase,
type Workflow,
} from 'n8n-workflow';
import type { WaitingWebhookRequest } from '../webhook.types'; import type { WaitingWebhookRequest } from '../webhook.types';
import { WaitingForms } from '@/webhooks/waiting-forms'; import { WaitingForms } from '@/webhooks/waiting-forms';
class TestWaitingForms extends WaitingForms { class TestWaitingForms extends WaitingForms {
exposeGetWorkflow(execution: IExecutionResponse): Workflow { exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow {
return this.getWorkflow(execution); return this.createWorkflow(workflowData);
}
exposeValidateToken(
req: express.Request,
execution: IExecutionResponse,
): { valid: boolean; webhookPath?: string } {
return this.validateToken(req, execution);
} }
} }
describe('WaitingForms', () => { describe('WaitingForms', () => {
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock()); const mockInstanceSettings = mock<InstanceSettings>();
const waitingForms = new TestWaitingForms(
mock(),
mock(),
executionRepository,
mock(),
mockInstanceSettings,
);
beforeEach(() => { beforeEach(() => {
jest.restoreAllMocks(); jest.restoreAllMocks();
@ -302,6 +322,7 @@ describe('WaitingForms', () => {
runData: {}, runData: {},
error: undefined, error: undefined,
}, },
resumeToken: undefined, // Old execution without token - skip validation
}, },
workflowData: { workflowData: {
id: 'workflow1', id: 'workflow1',
@ -328,11 +349,12 @@ describe('WaitingForms', () => {
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution.mockResolvedValue(execution);
const req = mock<WaitingWebhookRequest>({ const req = mock<WaitingWebhookRequest>({
headers: { origin: 'null' }, headers: { origin: 'null', host: 'localhost:5678' },
params: { params: {
path: '123', path: '123',
suffix: undefined, suffix: undefined,
}, },
url: '/form-waiting/123',
}); });
const res = mock<express.Response>(); const res = mock<express.Response>();
@ -362,7 +384,7 @@ describe('WaitingForms', () => {
}, },
}); });
const workflow = waitingForms.exposeGetWorkflow(execution); const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData);
expect(workflow.active).toBe(true); expect(workflow.active).toBe(true);
}); });
@ -384,7 +406,7 @@ describe('WaitingForms', () => {
}, },
}); });
const workflow = waitingForms.exposeGetWorkflow(execution); const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData);
expect(workflow.active).toBe(false); expect(workflow.active).toBe(false);
}); });
@ -406,7 +428,7 @@ describe('WaitingForms', () => {
}, },
}); });
const workflow = waitingForms.exposeGetWorkflow(execution); const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData);
expect(workflow.active).toBe(true); expect(workflow.active).toBe(true);
}); });
@ -428,7 +450,7 @@ describe('WaitingForms', () => {
}, },
}); });
const workflow = waitingForms.exposeGetWorkflow(execution); const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData);
expect(workflow.active).toBe(false); expect(workflow.active).toBe(false);
}); });
@ -445,6 +467,7 @@ describe('WaitingForms', () => {
runData: {}, runData: {},
error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
}, },
resumeToken: undefined, // Old execution without token
}, },
workflowData: { workflowData: {
id: 'workflow1', id: 'workflow1',
@ -471,11 +494,12 @@ describe('WaitingForms', () => {
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution.mockResolvedValue(execution);
const req = mock<WaitingWebhookRequest>({ const req = mock<WaitingWebhookRequest>({
headers: {}, headers: { host: 'localhost:5678' },
params: { params: {
path: '123', path: '123',
suffix: undefined, suffix: undefined,
}, },
url: '/form-waiting/123',
}); });
const res = mock<express.Response>(); const res = mock<express.Response>();
@ -501,6 +525,7 @@ describe('WaitingForms', () => {
runData: {}, runData: {},
error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
}, },
resumeToken: undefined, // Old execution without token
}, },
workflowData: { workflowData: {
id: 'workflow1', id: 'workflow1',
@ -527,11 +552,12 @@ describe('WaitingForms', () => {
executionRepository.findSingleExecution.mockResolvedValue(execution); executionRepository.findSingleExecution.mockResolvedValue(execution);
const req = mock<WaitingWebhookRequest>({ const req = mock<WaitingWebhookRequest>({
headers: {}, headers: { host: 'localhost:5678' },
params: { params: {
path: '123', path: '123',
suffix: undefined, suffix: undefined,
}, },
url: '/form-waiting/123',
}); });
const res = mock<express.Response>(); const res = mock<express.Response>();
@ -544,4 +570,165 @@ describe('WaitingForms', () => {
); );
}); });
}); });
describe('executeWebhook - token validation', () => {
it('should return 401 when resumeToken is set but request has no token', async () => {
const execution = mock<IExecutionResponse>({
finished: false,
status: 'waiting',
data: {
resultData: {
lastNodeExecuted: 'FormNode',
runData: {},
error: undefined,
},
resumeToken: 'a'.repeat(64),
},
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [],
connections: {},
active: false,
activeVersionId: undefined,
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
executionRepository.findSingleExecution.mockResolvedValue(execution);
const req = mock<WaitingWebhookRequest>({
headers: { host: 'localhost:5678' },
params: {
path: '123',
suffix: undefined,
},
url: '/form-waiting/123', // No token in URL
});
const mockRender = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ render: mockRender });
const res = mock<express.Response>({
status: mockStatus,
});
const result = await waitingForms.executeWebhook(req, res);
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockRender).toHaveBeenCalledWith('form-invalid-token');
expect(result).toEqual({ noWebhookResponse: true });
});
it('should skip token validation when resumeToken is undefined (backwards compat)', async () => {
const execution = mock<IExecutionResponse>({
finished: true,
status: 'success',
data: {
resultData: {
lastNodeExecuted: 'LastNode',
runData: {},
error: undefined,
},
resumeToken: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted
},
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [
{
name: 'LastNode',
type: 'other-node-type',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
activeVersionId: undefined,
settings: {},
staticData: {},
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
},
});
executionRepository.findSingleExecution.mockResolvedValue(execution);
const req = mock<WaitingWebhookRequest>({
headers: { host: 'localhost:5678' },
params: {
path: '123',
suffix: undefined,
},
url: '/form-waiting/123', // No token, but validation is skipped
});
const res = mock<express.Response>();
// Should not throw or return 401 - should proceed to render completion page
const result = await waitingForms.executeWebhook(req, res);
expect(res.setHeader).toHaveBeenCalledWith('Content-Security-Policy', getWebhookSandboxCSP());
expect(result).toEqual({ noWebhookResponse: true });
});
});
describe('validateToken - backwards compat webhook path extraction', () => {
const TEST_TOKEN = 'a'.repeat(64);
const createMockRequest = (opts: { host?: string; signature?: string; path?: string }) => {
const urlPath = opts.path ?? '/form-waiting/123';
const fullUrl = opts.signature
? `${urlPath}?${WAITING_TOKEN_QUERY_PARAM}=${opts.signature}`
: urlPath;
return mock<express.Request>({
url: fullUrl,
headers: { host: opts.host ?? 'localhost:5678' },
});
};
const createMockExecution = (token: string) =>
mock<IExecutionResponse>({
data: { resumeToken: token },
});
it('should extract webhook path from token when appended (backwards compat)', () => {
/* Arrange - URL format: ?signature=token/suffix */
const tokenWithSuffix = `${TEST_TOKEN}/my-custom-suffix`;
const mockReq = createMockRequest({ signature: tokenWithSuffix });
const mockExecution = createMockExecution(TEST_TOKEN);
/* Act */
const result = waitingForms.exposeValidateToken(mockReq, mockExecution);
/* Assert */
expect(result).toEqual({ valid: true, webhookPath: 'my-custom-suffix' });
});
it('should handle nested suffix paths in token (backwards compat)', () => {
/* Arrange - URL format: ?signature=token/path/to/suffix */
const tokenWithNestedSuffix = `${TEST_TOKEN}/path/to/suffix`;
const mockReq = createMockRequest({ signature: tokenWithNestedSuffix });
const mockExecution = createMockExecution(TEST_TOKEN);
/* Act */
const result = waitingForms.exposeValidateToken(mockReq, mockExecution);
/* Assert */
expect(result).toEqual({ valid: true, webhookPath: 'path/to/suffix' });
});
it('should reject when token does not match', () => {
const mockReq = createMockRequest({ signature: 'b'.repeat(64) });
const mockExecution = createMockExecution(TEST_TOKEN);
const result = waitingForms.exposeValidateToken(mockReq, mockExecution);
expect(result).toEqual({ valid: false, webhookPath: undefined });
});
});
}); });

View File

@ -2,8 +2,9 @@ import type { IExecutionResponse, ExecutionRepository } from '@n8n/db';
import type express from 'express'; import type express from 'express';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core';
import { generateUrlSignature, prepareUrlForSigning, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core'; import { WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import type { IWorkflowBase, Workflow } from 'n8n-workflow'; import type { IWorkflowBase, Workflow } from 'n8n-workflow';
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ConflictError } from '@/errors/response-errors/conflict.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
@ -17,15 +18,24 @@ class TestWaitingWebhooks extends WaitingWebhooks {
exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow { exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow {
return this.createWorkflow(workflowData); return this.createWorkflow(workflowData);
} }
exposeValidateSignature(req: express.Request): { valid: boolean; webhookPath?: string } {
return this.validateSignature(req);
}
exposeValidateToken(
req: express.Request,
execution: IExecutionResponse,
): { valid: boolean; webhookPath?: string } {
return this.validateToken(req, execution);
}
} }
describe('WaitingWebhooks', () => { describe('WaitingWebhooks', () => {
const SIGNING_SECRET = 'test-secret'; const TEST_HMAC_SECRET = 'test-hmac-secret-key';
const executionRepository = mock<ExecutionRepository>(); const executionRepository = mock<ExecutionRepository>();
const mockWebhookService = mock<WebhookService>(); const mockWebhookService = mock<WebhookService>();
const mockInstanceSettings = mock<InstanceSettings>({ const mockInstanceSettings = mock<InstanceSettings>({ hmacSignatureSecret: TEST_HMAC_SECRET });
hmacSignatureSecret: SIGNING_SECRET,
});
const waitingWebhooks = new TestWaitingWebhooks( const waitingWebhooks = new TestWaitingWebhooks(
mock(), mock(),
mock(), mock(),
@ -110,110 +120,245 @@ describe('WaitingWebhooks', () => {
}); });
}); });
describe('validateSignatureInRequest', () => { describe('validateSignature', () => {
const EXAMPLE_HOST = 'example.com'; const EXAMPLE_HOST = 'example.com';
const generateValidSignature = (host = EXAMPLE_HOST) =>
generateUrlSignature(
prepareUrlForSigning(new URL('/webhook/test', `http://${host}`)),
SIGNING_SECRET,
);
const createMockRequest = (opts: { host?: string; signature: string }) => // Helper to generate proper HMAC signature for a URL
mock<express.Request>({ const generateTestSignature = (urlPath: string) => {
url: `/webhook/test?${WAITING_TOKEN_QUERY_PARAM}=` + opts.signature, const crypto = require('crypto');
host: opts.host ?? EXAMPLE_HOST, return crypto.createHmac('sha256', TEST_HMAC_SECRET).update(urlPath).digest('hex');
query: { [WAITING_TOKEN_QUERY_PARAM]: opts.signature }, };
const createMockRequest = (opts: { host?: string; signature?: string; path?: string }) => {
const urlPath = opts.path ?? '/webhook/test';
const fullUrl = opts.signature
? `${urlPath}?${WAITING_TOKEN_QUERY_PARAM}=${opts.signature}`
: urlPath;
return mock<express.Request>({
url: fullUrl,
headers: { host: opts.host ?? EXAMPLE_HOST }, headers: { host: opts.host ?? EXAMPLE_HOST },
}); });
};
it('should validate signature correctly', () => { it('should validate signature correctly when HMAC matches', () => {
/* Arrange */ /* Arrange */
const signature = generateValidSignature(); const urlPath = '/webhook/test';
const mockReq = createMockRequest({ signature }); const validSignature = generateTestSignature(urlPath);
const mockReq = createMockRequest({ signature: validSignature, path: urlPath });
/* Act */ /* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq); const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */ /* Assert */
expect(result).toBe(true); expect(result).toEqual({ valid: true, webhookPath: undefined });
});
it('should validate signature correctly when host contains a port', () => {
/* Arrange */
const signature = generateValidSignature('example.com:8080');
const mockReq = createMockRequest({
signature,
host: 'example.com:8080',
});
/* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq);
/* Assert */
expect(result).toBe(true);
});
it('should validate signature correctly when n8n is behind a reverse proxy', () => {
/* Arrange */
const signature = generateValidSignature('proxy.example.com');
const mockReq = mock<express.Request>({
url: `/webhook/test?${WAITING_TOKEN_QUERY_PARAM}=` + signature,
host: 'proxy.example.com',
query: { [WAITING_TOKEN_QUERY_PARAM]: signature },
headers: {
host: 'localhost',
// eslint-disable-next-line @typescript-eslint/naming-convention
'x-forwarded-host': 'proxy.example.com',
},
});
/* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq);
/* Assert */
expect(result).toBe(true);
}); });
it('should return false when signature is missing', () => { it('should return false when signature is missing', () => {
/* Arrange */ /* Arrange */
const mockReq = mock<express.Request>({ const mockReq = createMockRequest({});
url: '/webhook/test',
hostname: 'example.com',
query: {},
});
/* Act */ /* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq); const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */ /* Assert */
expect(result).toBe(false); expect(result).toEqual({ valid: false });
}); });
it('should return false when signature is empty', () => { it('should return false when signature is invalid', () => {
/* Arrange */ /* Arrange */
const mockReq = mock<express.Request>({ const wrongSignature = 'b'.repeat(64);
url: `/webhook/test?${WAITING_TOKEN_QUERY_PARAM}=`,
hostname: 'example.com',
query: { [WAITING_TOKEN_QUERY_PARAM]: '' },
});
/* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq);
/* Assert */
expect(result).toBe(false);
});
it('should return false when signatures do not match', () => {
/* Arrange */
const wrongSignature = 'wrong-signature';
const mockReq = createMockRequest({ signature: wrongSignature }); const mockReq = createMockRequest({ signature: wrongSignature });
/* Act */ /* Act */
const result = waitingWebhooks.validateSignatureInRequest(mockReq); const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */ /* Assert */
expect(result).toBe(false); expect(result).toEqual({ valid: false, webhookPath: undefined });
});
it('should return false when signature length is wrong', () => {
/* Arrange */
const shortSignature = 'abc123';
const mockReq = createMockRequest({ signature: shortSignature });
/* Act */
const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */
expect(result).toEqual({ valid: false, webhookPath: undefined });
});
it('should extract suffix from signature when appended (backwards compat)', () => {
/* Arrange - URL format: ?signature=hmac/suffix */
const urlPath = '/webhook/test';
const validSignature = generateTestSignature(urlPath);
const signatureWithSuffix = `${validSignature}/my-suffix`;
const mockReq = createMockRequest({ signature: signatureWithSuffix, path: urlPath });
/* Act */
const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */
expect(result).toEqual({ valid: true, webhookPath: 'my-suffix' });
});
it('should handle nested suffix paths (backwards compat)', () => {
/* Arrange - URL format: ?signature=hmac/path/to/suffix */
const urlPath = '/webhook/test';
const validSignature = generateTestSignature(urlPath);
const signatureWithNestedSuffix = `${validSignature}/path/to/suffix`;
const mockReq = createMockRequest({ signature: signatureWithNestedSuffix, path: urlPath });
/* Act */
const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */
expect(result).toEqual({ valid: true, webhookPath: 'path/to/suffix' });
});
it('should validate signature correctly when nodeId suffix is part of signed URL (send-and-wait)', () => {
/* Arrange - Send-and-wait URLs include nodeId in the signed path along with query params.
* The signature is computed from pathname + query params (excluding signature itself). */
const nodeId = '3a3e4b33-52d1-41f9-9c69-2829030062ff';
const pathWithNodeId = `/webhook-waiting/123/${nodeId}`;
const pathWithParams = `${pathWithNodeId}?approved=true`;
// Signature generated for full path including nodeId and query params
const validSignature = generateTestSignature(pathWithParams);
// Construct URL with both approved and signature params
const fullUrl = `${pathWithNodeId}?approved=true&${WAITING_TOKEN_QUERY_PARAM}=${validSignature}`;
const mockReq = mock<express.Request>({
url: fullUrl,
headers: { host: EXAMPLE_HOST },
});
/* Act - Don't pass suffix because nodeId is part of signed URL */
const result = waitingWebhooks.exposeValidateSignature(mockReq);
/* Assert */
expect(result).toEqual({ valid: true, webhookPath: undefined });
});
});
describe('executeWebhook - signature validation', () => {
it('should return 401 with HTML for send-and-wait requests with invalid signature', async () => {
/* Arrange */
const nodeId = 'send-and-wait-node-id';
const execution = mock<IExecutionResponse>({
finished: false,
status: 'waiting',
data: {
resultData: {
lastNodeExecuted: 'SendAndWaitNode',
runData: {},
error: undefined,
},
resumeToken: 'a'.repeat(64),
},
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [
{
id: nodeId,
name: 'SendAndWaitNode',
type: 'n8n-nodes-base.sendAndWait',
parameters: { operation: SEND_AND_WAIT_OPERATION },
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
settings: {},
staticData: {},
},
});
executionRepository.findSingleExecution.mockResolvedValue(execution);
const mockStatus = jest.fn().mockReturnThis();
const mockRender = jest.fn();
const mockJson = jest.fn();
const res = mock<express.Response>({
status: mockStatus,
render: mockRender,
json: mockJson,
});
// Request has wrong signature
const req = mock<WaitingWebhookRequest>({
params: { path: 'execution-id', suffix: nodeId },
method: 'GET',
url: `/webhook/execution-id/${nodeId}?${WAITING_TOKEN_QUERY_PARAM}=wrong-signature`,
headers: { host: 'example.com' },
});
/* Act */
const result = await waitingWebhooks.executeWebhook(req, res);
/* Assert */
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockRender).toHaveBeenCalledWith('form-invalid-token');
expect(mockJson).not.toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
});
it('should return 401 with JSON for non-send-and-wait requests with invalid token', async () => {
/* Arrange */
const execution = mock<IExecutionResponse>({
finished: false,
status: 'waiting',
data: {
resultData: {
lastNodeExecuted: 'WaitNode',
runData: {},
error: undefined,
},
resumeToken: 'a'.repeat(64),
},
workflowData: {
id: 'workflow1',
name: 'Test Workflow',
nodes: [
{
id: 'wait-node-id',
name: 'WaitNode',
type: 'n8n-nodes-base.wait',
parameters: { operation: 'webhook' },
typeVersion: 1,
position: [0, 0],
},
],
connections: {},
active: false,
settings: {},
staticData: {},
},
});
executionRepository.findSingleExecution.mockResolvedValue(execution);
const mockStatus = jest.fn().mockReturnThis();
const mockRender = jest.fn();
const mockJson = jest.fn();
const res = mock<express.Response>({
status: mockStatus,
render: mockRender,
json: mockJson,
});
// Request has wrong signature
const req = mock<WaitingWebhookRequest>({
params: { path: 'execution-id', suffix: 'wait-node-id' },
method: 'GET',
url: `/webhook/execution-id/wait-node-id?${WAITING_TOKEN_QUERY_PARAM}=wrong-signature`,
headers: { host: 'example.com' },
});
/* Act */
const result = await waitingWebhooks.executeWebhook(req, res);
/* Assert */
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockJson).toHaveBeenCalledWith({ error: 'Invalid token' });
expect(mockRender).not.toHaveBeenCalled();
expect(result).toEqual({ noWebhookResponse: true });
}); });
}); });
@ -371,6 +516,7 @@ describe('WaitingWebhooks', () => {
// Explicitly set error to undefined to avoid mock function // Explicitly set error to undefined to avoid mock function
mockExecution.data.resultData.error = undefined; mockExecution.data.resultData.error = undefined;
mockExecution.data.resumeToken = undefined;
executionRepository.findSingleExecution.mockResolvedValue(mockExecution); executionRepository.findSingleExecution.mockResolvedValue(mockExecution);
@ -500,6 +646,7 @@ describe('WaitingWebhooks', () => {
// Explicitly set error to undefined to avoid mock function // Explicitly set error to undefined to avoid mock function
mockExecution.data.resultData.error = undefined; mockExecution.data.resultData.error = undefined;
mockExecution.data.resumeToken = undefined;
executionRepository.findSingleExecution.mockResolvedValue(mockExecution); executionRepository.findSingleExecution.mockResolvedValue(mockExecution);
@ -636,6 +783,7 @@ describe('WaitingWebhooks', () => {
// Explicitly set error to undefined to avoid mock function // Explicitly set error to undefined to avoid mock function
mockExecution.data.resultData.error = undefined; mockExecution.data.resultData.error = undefined;
mockExecution.data.resumeToken = undefined;
executionRepository.findSingleExecution.mockResolvedValue(mockExecution); executionRepository.findSingleExecution.mockResolvedValue(mockExecution);
@ -772,6 +920,7 @@ describe('WaitingWebhooks', () => {
// Explicitly set error to undefined to avoid mock function // Explicitly set error to undefined to avoid mock function
mockExecution.data.resultData.error = undefined; mockExecution.data.resultData.error = undefined;
mockExecution.data.resumeToken = undefined;
executionRepository.findSingleExecution.mockResolvedValue(mockExecution); executionRepository.findSingleExecution.mockResolvedValue(mockExecution);
@ -903,6 +1052,7 @@ describe('WaitingWebhooks', () => {
// Explicitly set error to undefined to avoid mock function // Explicitly set error to undefined to avoid mock function
mockExecution.data.resultData.error = undefined; mockExecution.data.resultData.error = undefined;
mockExecution.data.resumeToken = undefined;
executionRepository.findSingleExecution.mockResolvedValue(mockExecution); executionRepository.findSingleExecution.mockResolvedValue(mockExecution);

View File

@ -11,7 +11,6 @@ import {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { ConflictError } from '@/errors/response-errors/conflict.error'; import { ConflictError } from '@/errors/response-errors/conflict.error';
import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { WaitingWebhooks } from '@/webhooks/waiting-webhooks'; import { WaitingWebhooks } from '@/webhooks/waiting-webhooks';
@ -33,20 +32,6 @@ export class WaitingForms extends WaitingWebhooks {
} }
} }
protected getWorkflow(execution: IExecutionResponse) {
const { workflowData } = execution;
return new Workflow({
id: workflowData.id,
name: workflowData.name,
nodes: workflowData.nodes,
connections: workflowData.connections,
active: getWorkflowActiveStatusFromWorkflowData(workflowData),
nodeTypes: this.nodeTypes,
staticData: workflowData.staticData,
settings: workflowData.settings,
});
}
findCompletionPage(workflow: Workflow, runData: IRunData, lastNodeExecuted: string) { findCompletionPage(workflow: Workflow, runData: IRunData, lastNodeExecuted: string) {
const parentNodes = workflow.getParentNodes(lastNodeExecuted); const parentNodes = workflow.getParentNodes(lastNodeExecuted);
const lastNode = workflow.nodes[lastNodeExecuted]; const lastNode = workflow.nodes[lastNodeExecuted];
@ -74,7 +59,7 @@ export class WaitingForms extends WaitingWebhooks {
req: WaitingWebhookRequest, req: WaitingWebhookRequest,
res: express.Response, res: express.Response,
): Promise<IWebhookResponseCallbackData> { ): Promise<IWebhookResponseCallbackData> {
const { path: executionId, suffix } = req.params; const { path: executionId, suffix: routeSuffix } = req.params;
this.logReceivedWebhook(req.method, executionId); this.logReceivedWebhook(req.method, executionId);
@ -85,25 +70,22 @@ export class WaitingForms extends WaitingWebhooks {
const execution = await this.getExecution(executionId); const execution = await this.getExecution(executionId);
if (suffix === WAITING_FORMS_EXECUTION_STATUS) { // Validate token for forms (backwards compat: skip for old executions without resumeToken)
let status: string = execution?.status ?? 'null'; let webhookPath: string | undefined;
const { node } = execution?.data.executionData?.nodeExecutionStack[0] ?? {}; if (execution?.data.resumeToken) {
const result = this.validateToken(req, execution);
if (node && status === 'waiting') { if (!result.valid) {
if (node.type === FORM_NODE_TYPE) { res.status(401).render('form-invalid-token');
status = 'form-waiting'; return { noWebhookResponse: true };
}
if (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form') {
status = 'form-waiting';
}
} }
webhookPath = result.webhookPath;
applyCors(req, res);
res.send(status);
return { noWebhookResponse: true };
} }
const suffix = routeSuffix ?? webhookPath;
const statusResult = this.handleStatusRequest(execution, suffix, req, res);
if (statusResult) return statusResult;
if (!execution) { if (!execution) {
throw new NotFoundError(`The execution "${executionId}" does not exist.`); throw new NotFoundError(`The execution "${executionId}" does not exist.`);
} }
@ -121,10 +103,7 @@ export class WaitingForms extends WaitingWebhooks {
let lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; let lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string;
if (execution.finished) { if (execution.finished) {
// find the completion page to render const workflow = this.createWorkflow(execution.workflowData);
// if there is no completion page, render the default page
const workflow = this.getWorkflow(execution);
const completionPage = this.findCompletionPage( const completionPage = this.findCompletionPage(
workflow, workflow,
execution.data.resultData.runData, execution.data.resultData.runData,
@ -138,13 +117,10 @@ export class WaitingForms extends WaitingWebhooks {
message: 'Your response has been recorded', message: 'Your response has been recorded',
formTitle: 'Form Submitted', formTitle: 'Form Submitted',
}); });
return { noWebhookResponse: true };
return {
noWebhookResponse: true,
};
} else {
lastNodeExecuted = completionPage;
} }
lastNodeExecuted = completionPage;
} }
applyCors(req, res); applyCors(req, res);
@ -158,4 +134,35 @@ export class WaitingForms extends WaitingWebhooks {
suffix, suffix,
}); });
} }
/**
* Checks if the request is a form execution status poll and, if so,
* responds with the current execution status (e.g. 'waiting', 'form-waiting')
* and signals that no further webhook response is needed.
* Returns `undefined` for non-status requests so normal webhook handling continues.
*/
private handleStatusRequest(
execution: IExecutionResponse | undefined,
suffix: string | undefined,
req: WaitingWebhookRequest,
res: express.Response,
): IWebhookResponseCallbackData | undefined {
if (suffix !== WAITING_FORMS_EXECUTION_STATUS) return undefined;
let status: string = execution?.status ?? 'null';
const { node } = execution?.data.executionData?.nodeExecutionStack[0] ?? {};
if (node && status === 'waiting') {
if (node.type === FORM_NODE_TYPE) {
status = 'form-waiting';
}
if (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form') {
status = 'form-waiting';
}
}
applyCors(req, res);
res.send(status);
return { noWebhookResponse: true };
}
} }

View File

@ -2,29 +2,22 @@ import { Logger } from '@n8n/backend-common';
import type { IExecutionResponse } from '@n8n/db'; import type { IExecutionResponse } from '@n8n/db';
import { ExecutionRepository } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import crypto from 'crypto'; import { timingSafeEqual } from 'crypto';
import type express from 'express'; import type express from 'express';
import { InstanceSettings, WAITING_TOKEN_QUERY_PARAM, validateUrlSignature } from 'n8n-core';
import { import {
InstanceSettings,
WAITING_TOKEN_QUERY_PARAM,
prepareUrlForSigning,
generateUrlSignature,
} from 'n8n-core';
import {
FORM_NODE_TYPE,
type INodes, type INodes,
type IWorkflowBase, type IWorkflowBase,
NodeConnectionTypes, NodeConnectionTypes,
SEND_AND_WAIT_OPERATION, SEND_AND_WAIT_OPERATION,
WAIT_NODE_TYPE,
Workflow, Workflow,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { sanitizeWebhookRequest } from './webhook-request-sanitizer'; import { sanitizeWebhookRequest } from './webhook-request-sanitizer';
import { WebhookService } from './webhook.service'; import { WebhookService } from './webhook.service';
import type { import type {
IWebhookResponseCallbackData,
IWebhookManager, IWebhookManager,
IWebhookResponseCallbackData,
WaitingWebhookRequest, WaitingWebhookRequest,
} from './webhook.types'; } from './webhook.types';
@ -51,7 +44,7 @@ export class WaitingWebhooks implements IWebhookManager {
protected readonly nodeTypes: NodeTypes, protected readonly nodeTypes: NodeTypes,
private readonly executionRepository: ExecutionRepository, private readonly executionRepository: ExecutionRepository,
private readonly webhookService: WebhookService, private readonly webhookService: WebhookService,
private readonly instanceSettings: InstanceSettings, protected readonly instanceSettings: InstanceSettings,
) {} ) {}
// TODO: implement `getWebhookMethods` for CORS support // TODO: implement `getWebhookMethods` for CORS support
@ -103,36 +96,84 @@ export class WaitingWebhooks implements IWebhookManager {
}); });
} }
validateSignatureInRequest(req: express.Request) { /**
try { * Extracts the `signature` query param and an optional webhook path
const actualToken = req.query[WAITING_TOKEN_QUERY_PARAM]; * appended after it (backwards compat: `?signature=token/my-suffix`).
*/
private parseSignatureParam(req: express.Request): {
token: string | undefined;
webhookPath: string | undefined;
} {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
let token = url.searchParams.get(WAITING_TOKEN_QUERY_PARAM) ?? undefined;
let webhookPath: string | undefined;
if (typeof actualToken !== 'string') return false; // Handle backwards compat: extract webhook path if appended after the token
// e.g., ?signature=abc123/my-suffix -> token is "abc123", webhookPath is "my-suffix"
// req.host is set correctly even when n8n is behind a reverse proxy if (token?.includes('/')) {
// as long as N8N_PROXY_HOPS is set correctly const slashIndex = token.indexOf('/');
const parsedUrl = new URL(req.url, `http://${req.host}`); webhookPath = token.slice(slashIndex + 1);
parsedUrl.searchParams.delete(WAITING_TOKEN_QUERY_PARAM); token = token.slice(0, slashIndex);
const urlForSigning = prepareUrlForSigning(parsedUrl);
const expectedToken = generateUrlSignature(
urlForSigning,
this.instanceSettings.hmacSignatureSecret,
);
const valid = crypto.timingSafeEqual(Buffer.from(actualToken), Buffer.from(expectedToken));
return valid;
} catch (error) {
return false;
} }
return { token, webhookPath };
}
/**
* Validates the request by comparing the provided token against the stored
* `resumeToken` using timing-safe comparison.
*
* Used for form and webhook waiting URLs, where the token is an opaque
* random value (no query params to tamper-proof).
*/
protected validateToken(
req: express.Request,
execution: IExecutionResponse,
): { valid: boolean; webhookPath?: string } {
const { token, webhookPath } = this.parseSignatureParam(req);
const storedToken = execution.data.resumeToken;
if (!token || !storedToken || token.length !== storedToken.length) {
return { valid: false };
}
const valid = timingSafeEqual(Buffer.from(token), Buffer.from(storedToken));
return { valid, webhookPath };
}
/**
* Validates the HMAC signature in the request URL.
*
* Used exclusively for send-and-wait URLs, where query params like
* `approved=true` must be tamper-proof.
*/
protected validateSignature(req: express.Request): { valid: boolean; webhookPath?: string } {
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
const { token: providedSignature, webhookPath } = this.parseSignatureParam(req);
if (!providedSignature) {
return { valid: false };
}
// Restore the cleaned signature on the URL (without any appended webhook path)
// so that `validateUrlSignature` can re-derive the expected HMAC from the full
// URL including the node-id suffix and action params (e.g. approved=true).
url.searchParams.set(WAITING_TOKEN_QUERY_PARAM, providedSignature);
const valid = validateUrlSignature(
providedSignature,
url,
this.instanceSettings.hmacSignatureSecret,
);
return { valid, webhookPath };
} }
async executeWebhook( async executeWebhook(
req: WaitingWebhookRequest, req: WaitingWebhookRequest,
res: express.Response, res: express.Response,
): Promise<IWebhookResponseCallbackData> { ): Promise<IWebhookResponseCallbackData> {
const { path: executionId, suffix } = req.params; const { path: executionId } = req.params;
let { suffix } = req.params;
this.logReceivedWebhook(req.method, executionId); this.logReceivedWebhook(req.method, executionId);
@ -143,15 +184,31 @@ export class WaitingWebhooks implements IWebhookManager {
const execution = await this.getExecution(executionId); const execution = await this.getExecution(executionId);
if (execution?.data.validateSignature) { // Only validate for executions that have a resumeToken.
const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; // Old executions created before token validation are skipped (backwards compat).
const lastNode = execution.workflowData.nodes.find((node) => node.name === lastNodeExecuted); if (execution?.data.resumeToken) {
const shouldValidate = lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION; const { workflowData } = execution;
const { nodes } = this.createWorkflow(workflowData);
const isSendAndWait = this.isSendAndWaitRequest(nodes, suffix);
if (shouldValidate && !this.validateSignatureInRequest(req)) { // Send-and-wait uses HMAC to protect tamper-sensitive query params (e.g. approved=true).
res.status(401).json({ error: 'Invalid token' }); // All other waiting URLs use a simple random token comparison.
const { valid, webhookPath } = isSendAndWait
? this.validateSignature(req)
: this.validateToken(req, execution);
if (!valid) {
if (isSendAndWait) {
res.status(401).render('form-invalid-token');
} else {
res.status(401).json({ error: 'Invalid token' });
}
return { noWebhookResponse: true }; return { noWebhookResponse: true };
} }
// Use webhook path parsed from token if not in route (backwards compat for old URL format)
if (!suffix && webhookPath) {
suffix = webhookPath;
}
} }
if (!execution) { if (!execution) {
@ -259,22 +316,6 @@ export class WaitingWebhooks implements IWebhookManager {
return { noWebhookResponse: true }; return { noWebhookResponse: true };
} }
if (!execution.data.resultData.error && execution.status === 'waiting') {
const childNodes = workflow.getChildNodes(
execution.data.resultData.lastNodeExecuted as string,
);
const hasChildForms = childNodes.some(
(node) =>
workflow.nodes[node].type === FORM_NODE_TYPE ||
workflow.nodes[node].type === WAIT_NODE_TYPE,
);
if (hasChildForms) {
return { noWebhookResponse: true };
}
}
throw new NotFoundError(errorMessage); throw new NotFoundError(errorMessage);
} }

View File

@ -10,7 +10,7 @@ import type { Project } from '@n8n/db';
import { ExecutionRepository } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import type express from 'express'; import type express from 'express';
import { BinaryDataService, ErrorReporter } from 'n8n-core'; import { BinaryDataService, ErrorReporter, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core';
import type { import type {
IBinaryData, IBinaryData,
IDataObject, IDataObject,
@ -767,7 +767,11 @@ export async function executeWebhook(
}); });
if (responseMode === 'formPage' && !didSendResponse) { if (responseMode === 'formPage' && !didSendResponse) {
res.send({ formWaitingUrl: `${additionalData.formWaitingBaseUrl}/${executionId}` }); const formUrl = new URL(`${additionalData.formWaitingBaseUrl}/${executionId}`);
if (runExecutionData.resumeToken) {
formUrl.searchParams.set(WAITING_TOKEN_QUERY_PARAM, runExecutionData.resumeToken);
}
res.send({ formWaitingUrl: formUrl.toString() });
process.nextTick(() => res.end()); process.nextTick(() => res.end());
didSendResponse = true; didSendResponse = true;
} }

View File

@ -772,39 +772,42 @@ describe('WorkflowExecutionService', () => {
expect(workflowRunnerMock.run).toHaveBeenCalledTimes(1); expect(workflowRunnerMock.run).toHaveBeenCalledTimes(1);
expect(workflowRunnerMock.run).toHaveBeenCalledWith({ expect(workflowRunnerMock.run).toHaveBeenCalledWith({
executionMode: 'error', executionMode: 'error',
executionData: createRunExecutionData({ executionData: {
executionData: { ...createRunExecutionData({
contextData: {}, executionData: {
metadata: {}, contextData: {},
nodeExecutionStack: [ metadata: {},
{ nodeExecutionStack: [
node: errorTriggerNode, {
data: { node: errorTriggerNode,
main: [ data: {
[ main: [
{ [
json: workflowErrorData, {
}, json: workflowErrorData,
},
],
], ],
], },
}, source: null,
source: null, metadata: {
metadata: { parentExecution: {
parentExecution: { executionId: 'execution-id',
executionId: 'execution-id', workflowId: 'workflow-id',
workflowId: 'workflow-id', },
}, },
}, },
}, ],
], waitingExecution: {},
waitingExecution: {}, waitingExecutionSource: {},
waitingExecutionSource: {}, },
}, resultData: {
resultData: { runData: {},
runData: {}, },
}, startData: {},
startData: {}, }),
}), resumeToken: expect.any(String),
},
workflowData: errorWorkflow, workflowData: errorWorkflow,
projectId: 'project-id', projectId: 'project-id',
projectName: 'Error Project', projectName: 'Error Project',

View File

@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8' />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<link rel='icon' type='image/png' href='https://n8n.io/favicon.ico' />
<link href='https://fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css' />
<title>Invalid Form Link</title>
<style>
:root {
/* Fonts */
--font-family: 'Open Sans', sans-serif;
--font-weight-normal: 400;
--font-weight-bold: 600;
--font-size-body: 12px;
--font-size-link: 12px;
--font-size-header: 20px;
--font-size-subheader: 14px;
/* Colors */
--color-background: #FBFCFE;
--color-card-bg: white;
--color-card-border: #DBDFE7;
--color-card-shadow: #634DFF0F;
--color-link: #7E8186;
--color-header: #525356;
--color-header-subtext: #7E8186;
/* Spacing & dimensions */
--padding-container-top: 24px;
--container-width: 448px;
--card-padding: 24px;
--card-border-radius: 8px;
--card-margin-bottom: 16px;
}
*,
::after,
::before {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font-family);
font-weight: var(--font-weight-normal);
font-size: var(--font-size-body);
display: flex;
flex-direction: column;
justify-content: start;
background-color: var(--color-background);
}
.container {
margin: auto;
text-align: center;
padding-top: var(--padding-container-top);
width: var(--container-width);
}
.card {
padding: var(--card-padding);
background-color: var(--color-card-bg);
border: 1px solid var(--color-card-border);
border-radius: var(--card-border-radius);
box-shadow: 0px 4px 16px 0px var(--color-card-shadow);
margin-bottom: var(--card-margin-bottom);
}
.n8n-link a {
color: var(--color-link);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-link);
text-decoration: none;
}
.n8n-link svg {
display: inline-block;
vertical-align: middle;
}
.header h1 {
color: var(--color-header);
font-size: var(--font-size-header);
font-weight: var(--font-weight-normal);
padding-bottom: 8px;
}
.header p {
color: var(--color-header-subtext);
font-size: var(--font-size-subheader);
font-weight: var(--font-weight-normal);
}
</style>
</head>
<body>
<div class='container'>
<section>
<div class='card'>
<div class='header'>
<h1>Invalid Form Link</h1>
<p>This form link is invalid or has expired. Please request a new link.</p>
</div>
</div>
<div class='n8n-link'>
<a href='https://n8n.io/?utm_source=n8n-internal&amp;utm_medium=form-trigger&amp' target='_blank' rel='noopener noreferrer'>
Form automated with
<svg width='73' height='20' viewBox='0 0 73 20' fill='none' xmlns='http://www.w3.org/2000/svg'>
<path fill-rule='evenodd' clip-rule='evenodd'
d='M40.2373 4C40.2373 6.20915 38.4464 8 36.2373 8C34.3735 8 32.8074 6.72525 32.3633 5H26.7787C25.801 5 24.9666 5.70685 24.8059 6.6712L24.6415 7.6576C24.4854 8.59415 24.0116 9.40925 23.3417 10C24.0116 10.5907 24.4854 11.4058 24.6415 12.3424L24.8059 13.3288C24.9666 14.2931 25.801 15 26.7787 15H28.3633C28.8074 13.2747 30.3735 12 32.2373 12C34.4464 12 36.2373 13.7908 36.2373 16C36.2373 18.2092 34.4464 20 32.2373 20C30.3735 20 28.8074 18.7253 28.3633 17H26.7787C24.8233 17 23.1546 15.5864 22.8331 13.6576L22.6687 12.6712C22.508 11.7069 21.6736 11 20.6959 11H19.0645C18.5652 12.64 17.0406 13.8334 15.2373 13.8334C13.434 13.8334 11.9094 12.64 11.4101 11H9.06449C8.56519 12.64 7.04059 13.8334 5.2373 13.8334C3.02817 13.8334 1.2373 12.0424 1.2373 9.83335C1.2373 7.6242 3.02817 5.83335 5.2373 5.83335C7.16069 5.83335 8.76699 7.19085 9.15039 9H11.3242C11.7076 7.19085 13.3139 5.83335 15.2373 5.83335C17.1607 5.83335 18.767 7.19085 19.1504 9H20.6959C21.6736 9 22.508 8.29315 22.6687 7.3288L22.8331 6.3424C23.1546 4.41365 24.8233 3 26.7787 3H32.3633C32.8074 1.27478 34.3735 0 36.2373 0C38.4464 0 40.2373 1.79086 40.2373 4ZM38.2373 4C38.2373 5.10455 37.3419 6 36.2373 6C35.1327 6 34.2373 5.10455 34.2373 4C34.2373 2.89543 35.1327 2 36.2373 2C37.3419 2 38.2373 2.89543 38.2373 4ZM5.2373 11.8334C6.34189 11.8334 7.23729 10.9379 7.23729 9.83335C7.23729 8.72875 6.34189 7.83335 5.2373 7.83335C4.13273 7.83335 3.2373 8.72875 3.2373 9.83335C3.2373 10.9379 4.13273 11.8334 5.2373 11.8334ZM15.2373 11.8334C16.3419 11.8334 17.2373 10.9379 17.2373 9.83335C17.2373 8.72875 16.3419 7.83335 15.2373 7.83335C14.1327 7.83335 13.2373 8.72875 13.2373 9.83335C13.2373 10.9379 14.1327 11.8334 15.2373 11.8334ZM32.2373 18C33.3419 18 34.2373 17.1045 34.2373 16C34.2373 14.8954 33.3419 14 32.2373 14C31.1327 14 30.2373 14.8954 30.2373 16C30.2373 17.1045 31.1327 18 32.2373 18Z'
fill='#EA4B71'></path>
<path
d='M44.2393 15.0007H46.3277V10.5791C46.3277 9.12704 47.2088 8.49074 48.204 8.49074C49.183 8.49074 49.9498 9.14334 49.9498 10.4812V15.0007H52.038V10.057C52.038 7.91969 50.798 6.67969 48.8567 6.67969C47.633 6.67969 46.9477 7.16914 46.4582 7.80544H46.3277L46.1482 6.84284H44.2393V15.0007Z'
fill='#101330'></path>
<path
d='M60.0318 9.50205V9.40415C60.7498 9.0452 61.4678 8.4252 61.4678 7.20155C61.4678 5.43945 60.0153 4.37891 58.0088 4.37891C55.9528 4.37891 54.4843 5.5047 54.4843 7.23415C54.4843 8.4089 55.1698 9.0452 55.9203 9.40415V9.50205C55.0883 9.79575 54.0928 10.6768 54.0928 12.1452C54.0928 13.9237 55.5613 15.1637 57.9923 15.1637C60.4233 15.1637 61.8428 13.9237 61.8428 12.1452C61.8428 10.6768 60.8638 9.81205 60.0318 9.50205ZM57.9923 5.87995C58.8083 5.87995 59.4118 6.40205 59.4118 7.2831C59.4118 8.16415 58.7918 8.6863 57.9923 8.6863C57.1928 8.6863 56.5238 8.16415 56.5238 7.2831C56.5238 6.38575 57.1603 5.87995 57.9923 5.87995ZM57.9923 13.5974C57.0458 13.5974 56.2793 12.9937 56.2793 11.9658C56.2793 11.0358 56.9153 10.3342 57.9758 10.3342C59.0203 10.3342 59.6568 11.0195 59.6568 11.9984C59.6568 12.9937 58.9223 13.5974 57.9923 13.5974Z'
fill='#101330'></path>
<path
d='M63.9639 15.0007H66.0524V10.5791C66.0524 9.12704 66.9334 8.49074 67.9289 8.49074C68.9079 8.49074 69.6744 9.14334 69.6744 10.4812V15.0007H71.7629V10.057C71.7629 7.91969 70.5229 6.67969 68.5814 6.67969C67.3579 6.67969 66.6724 7.16914 66.1829 7.80544H66.0524L65.8729 6.84284H63.9639V15.0007Z'
fill='#101330'></path>
</svg>
</a>
</div>
</section>
</div>
</body>
</html>

View File

@ -158,17 +158,32 @@
<a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a> <a id='redirectUrl' href='{{redirectUrl}}' style='display: none;'></a>
{{/if}} {{/if}}
<script> <script>
// Get signature from URL for fetch requests
const url = new URL(window.location.href);
const formSignature = url.searchParams.get('signature');
let interval = 1000; let interval = 1000;
let timeoutId; let timeoutId;
const checkExecutionStatus = async () => { const checkExecutionStatus = async () => {
if (!interval) return; if (!interval) return;
try { try {
const response = await fetch(`${ window.location.href }/n8n-execution-status`); const statusUrl = new URL(window.location.href);
statusUrl.pathname = statusUrl.pathname + '/n8n-execution-status';
// Include signature in fetch request for validation
if (formSignature) {
statusUrl.searchParams.set('signature', formSignature);
}
const response = await fetch(statusUrl.toString());
const text = (await response.text()).trim(); const text = (await response.text()).trim();
if (text === "form-waiting") { if (text === "form-waiting") {
window.location.replace(window.location.href); // Redirect with signature in URL (will be cleaned after load)
const redirectUrl = new URL(window.location.href);
if (formSignature) {
redirectUrl.searchParams.set('signature', formSignature);
}
window.location.replace(redirectUrl.toString());
return; return;
} }
@ -210,7 +225,13 @@
} }
}); });
fetch('', { // Include signature in POST URL for waiting forms
let postUrl = '';
if (window.location.href.includes('form-waiting') && formSignature) {
postUrl = '?signature=' + encodeURIComponent(formSignature);
}
fetch(postUrl, {
method: 'POST', method: 'POST',
body: {} body: {}
}) })

View File

@ -692,6 +692,10 @@
</section> </section>
</div> </div>
<script> <script>
// Get signature from URL for fetch requests
const url = new URL(window.location.href);
let formSignature = url.searchParams.get('signature');
function updateError(errorElement, action = 'add', message = '') { function updateError(errorElement, action = 'add', message = '') {
if(action === 'add') { if(action === 'add') {
errorElement.textContent = message; errorElement.textContent = message;
@ -898,11 +902,23 @@
if (!interval) return; if (!interval) return;
try { try {
const response = await fetch(`${formWaitingUrl ?? window.location.href}/n8n-execution-status`); const baseUrl = formWaitingUrl ?? window.location.href;
const url = new URL(baseUrl);
url.pathname = url.pathname + '/n8n-execution-status';
// Include signature in fetch request for validation
if (formSignature) {
url.searchParams.set('signature', formSignature);
}
const response = await fetch(url.toString());
const text = (await response.text()).trim(); const text = (await response.text()).trim();
if (text === "form-waiting") { if (text === "form-waiting") {
window.location.replace(formWaitingUrl ?? window.location.href); // Redirect to form page with signature in URL (will be cleaned after load)
const redirectUrl = new URL(formWaitingUrl ?? window.location.href);
if (formSignature) {
redirectUrl.searchParams.set('signature', formSignature);
}
window.location.replace(redirectUrl.toString());
return; return;
} }
@ -991,6 +1007,9 @@
let postUrl = ''; let postUrl = '';
if (!window.location.href.includes('form-waiting')) { if (!window.location.href.includes('form-waiting')) {
postUrl = window.location.search; postUrl = window.location.search;
} else if (formSignature) {
// For waiting forms, include signature in POST URL since it was cleaned from address bar
postUrl = '?signature=' + encodeURIComponent(formSignature);
} }
const authToken = '{{{ authToken }}}'; const authToken = '{{{ authToken }}}';
@ -1017,6 +1036,9 @@
if(json?.formWaitingUrl) { if(json?.formWaitingUrl) {
formWaitingUrl = json.formWaitingUrl; formWaitingUrl = json.formWaitingUrl;
// Extract signature from URL for subsequent requests
const waitingUrl = new URL(json.formWaitingUrl);
formSignature = waitingUrl.searchParams.get('signature');
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(checkExecutionStatus, interval); timeoutId = setTimeout(checkExecutionStatus, interval);
return; return;

View File

@ -136,7 +136,10 @@ describe('JS TaskRunner execution on internal mode', () => {
}); });
return { return {
additionalData: mock<IWorkflowExecuteAdditionalData>(), additionalData: mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
executeFunctions: mock<IExecuteFunctions>(), executeFunctions: mock<IExecuteFunctions>(),
taskSettings, taskSettings,
codeNode, codeNode,

View File

@ -228,6 +228,7 @@ export class NodeTestHarness {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: '1', executionId: '1',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook', webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
formWaitingBaseUrl: 'http://localhost/waiting-form',
hooks, hooks,
// Get from node.parameters // Get from node.parameters
currentNodeParameters: undefined, currentNodeParameters: undefined,

View File

@ -15,7 +15,10 @@ import { establishExecutionContext } from '../execution-context';
describe('establishExecutionContext', () => { describe('establishExecutionContext', () => {
const mockWorkflow = mock<Workflow>({ id: 'test-workflow-id' }); const mockWorkflow = mock<Workflow>({ id: 'test-workflow-id' });
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>(); const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const mockMode: WorkflowExecuteMode = 'manual'; const mockMode: WorkflowExecuteMode = 'manual';
describe('successful context establishment', () => { describe('successful context establishment', () => {

View File

@ -99,7 +99,10 @@ const getExecuteSingleFunctions = (
describe('RoutingNode', () => { describe('RoutingNode', () => {
const nodeTypes = NodeTypes(); const nodeTypes = NodeTypes();
const additionalData = mock<IWorkflowExecuteAdditionalData>(); const additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
describe('getRequestOptionsFromParameters', () => { describe('getRequestOptionsFromParameters', () => {
const tests: Array<{ const tests: Array<{

View File

@ -35,6 +35,8 @@ describe('processRunExecutionData', () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ const additionalData = mock<IWorkflowExecuteAdditionalData>({
hooks: { runHook }, hooks: { runHook },
restartExecutionId: undefined, restartExecutionId: undefined,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const executionMode: WorkflowExecuteMode = 'trigger'; const executionMode: WorkflowExecuteMode = 'trigger';

View File

@ -1131,7 +1131,13 @@ describe('WorkflowExecute', () => {
}); });
}); });
}); });
const workflowExecute = new WorkflowExecute(mock(), 'manual'); const workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
);
it('should return null if there are no nodes', () => { it('should return null if there are no nodes', () => {
const workflow = new Workflow({ const workflow = new Workflow({
@ -1419,7 +1425,10 @@ describe('WorkflowExecute', () => {
const executionData = mock<IExecuteData>(); const executionData = mock<IExecuteData>();
const runExecutionData = mock<IRunExecutionData>(); const runExecutionData = mock<IRunExecutionData>();
const additionalData = mock<IWorkflowExecuteAdditionalData>(); const additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const abortController = new AbortController(); const abortController = new AbortController();
const workflowExecute = new WorkflowExecute(additionalData, 'manual'); const workflowExecute = new WorkflowExecute(additionalData, 'manual');
@ -1531,7 +1540,14 @@ describe('WorkflowExecute', () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
runExecutionData,
);
}); });
test('should handle undefined error data input correctly', () => { test('should handle undefined error data input correctly', () => {
@ -1709,7 +1725,14 @@ describe('WorkflowExecute', () => {
beforeEach(() => { beforeEach(() => {
runExecutionData = createRunExecutionData(); runExecutionData = createRunExecutionData();
workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
runExecutionData,
);
}); });
test('should initialize waitingExecutionSource if undefined', () => { test('should initialize waitingExecutionSource if undefined', () => {
@ -1778,7 +1801,13 @@ describe('WorkflowExecute', () => {
let workflowExecute: WorkflowExecute; let workflowExecute: WorkflowExecute;
beforeEach(() => { beforeEach(() => {
workflowExecute = new WorkflowExecute(mock(), 'manual'); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
);
}); });
test('should return true when there are no input connections', () => { test('should return true when there are no input connections', () => {
@ -1902,7 +1931,14 @@ describe('WorkflowExecute', () => {
beforeEach(() => { beforeEach(() => {
runExecutionData = createRunExecutionData(); runExecutionData = createRunExecutionData();
workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
runExecutionData,
);
}); });
test('should do nothing when there is no metadata', () => { test('should do nothing when there is no metadata', () => {
@ -1996,7 +2032,14 @@ describe('WorkflowExecute', () => {
test('should return complete IRun object with all properties correctly set', () => { test('should return complete IRun object with all properties correctly set', () => {
const runExecutionData = mock<IRunExecutionData>(); const runExecutionData = mock<IRunExecutionData>();
const workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); const workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
runExecutionData,
);
const startedAt = new Date('2023-01-01T00:00:00.000Z'); const startedAt = new Date('2023-01-01T00:00:00.000Z');
jest.useFakeTimers().setSystemTime(startedAt); jest.useFakeTimers().setSystemTime(startedAt);
@ -2196,7 +2239,13 @@ describe('WorkflowExecute', () => {
let workflowExecute: WorkflowExecute; let workflowExecute: WorkflowExecute;
beforeEach(() => { beforeEach(() => {
workflowExecute = new WorkflowExecute(mock(), 'manual'); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
);
}); });
test('should handle undefined node output', () => { test('should handle undefined node output', () => {
@ -2314,7 +2363,14 @@ describe('WorkflowExecute', () => {
data: {}, data: {},
source: null, source: null,
}; };
workflowExecute = new WorkflowExecute(mock(), 'manual', runExecutionData); workflowExecute = new WorkflowExecute(
mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}),
'manual',
runExecutionData,
);
jest.resetAllMocks(); jest.resetAllMocks();
}); });
@ -2389,7 +2445,10 @@ describe('WorkflowExecute', () => {
const nodeTypes = mock<INodeTypes>(); const nodeTypes = mock<INodeTypes>();
const runExecutionData = mock<IRunExecutionData>(); const runExecutionData = mock<IRunExecutionData>();
const additionalData = mock<IWorkflowExecuteAdditionalData>(); const additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const workflowExecute = new WorkflowExecute(additionalData, 'manual'); const workflowExecute = new WorkflowExecute(additionalData, 'manual');
const testCases: Array<{ const testCases: Array<{
@ -2530,7 +2589,10 @@ describe('WorkflowExecute', () => {
}); });
mockHooks = mock<ExecutionLifecycleHooks>(); mockHooks = mock<ExecutionLifecycleHooks>();
additionalData = mock<IWorkflowExecuteAdditionalData>(); additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
additionalData.hooks = mockHooks; additionalData.hooks = mockHooks;
additionalData.currentNodeExecutionIndex = 0; additionalData.currentNodeExecutionIndex = 0;
@ -2998,6 +3060,7 @@ describe('WorkflowExecute', () => {
waitingExecutionSource: {}, waitingExecutionSource: {},
runtimeData: { version: 1, establishedAt: 1763723652184, source: 'manual' }, runtimeData: { version: 1, establishedAt: 1763723652184, source: 'manual' },
}, },
resumeToken: runHook.mock.lastCall[1][0].data?.resumeToken,
}, },
}; };

View File

@ -66,7 +66,11 @@ describe('ExecuteContext', () => {
nullParameter: null, nullParameter: null,
}; };
const credentialsHelper = mock<ICredentialsHelper>(); const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper }); const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const mode: WorkflowExecuteMode = 'manual'; const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>(); const runExecutionData = mock<IRunExecutionData>();
const connectionInputData: INodeExecutionData[] = []; const connectionInputData: INodeExecutionData[] = [];

View File

@ -51,7 +51,11 @@ describe('ExecuteSingleContext', () => {
testParameter: 'testValue', testParameter: 'testValue',
}; };
const credentialsHelper = mock<ICredentialsHelper>(); const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper }); const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const mode: WorkflowExecuteMode = 'manual'; const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>(); const runExecutionData = mock<IRunExecutionData>();
const connectionInputData: INodeExecutionData[] = []; const connectionInputData: INodeExecutionData[] = [];

View File

@ -23,7 +23,7 @@ describe('NodeExecutionContext', () => {
const instanceSettings = mock<InstanceSettings>({ const instanceSettings = mock<InstanceSettings>({
instanceId: 'abc123', instanceId: 'abc123',
encryptionKey: 'testEncryptionKey', encryptionKey: 'testEncryptionKey',
hmacSignatureSecret: 'testHmacSignatureSecret', hmacSignatureSecret: 'test-hmac-secret',
}); });
Container.set(InstanceSettings, instanceSettings); Container.set(InstanceSettings, instanceSettings);
@ -41,6 +41,8 @@ describe('NodeExecutionContext', () => {
}); });
const additionalData = mock<IWorkflowExecuteAdditionalData>({ const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper: mock(), credentialsHelper: mock(),
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const mode: WorkflowExecuteMode = 'manual'; const mode: WorkflowExecuteMode = 'manual';
@ -228,6 +230,8 @@ describe('NodeExecutionContext', () => {
const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>({ const mockAdditionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper: mockCredentialsHelper, credentialsHelper: mockCredentialsHelper,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const contextWithCredentials = new TestContext( const contextWithCredentials = new TestContext(
@ -438,30 +442,43 @@ describe('NodeExecutionContext', () => {
mock<IWorkflowExecuteAdditionalData>({ mock<IWorkflowExecuteAdditionalData>({
executionId: '123', executionId: '123',
webhookWaitingBaseUrl: 'http://localhost/waiting-webhook', webhookWaitingBaseUrl: 'http://localhost/waiting-webhook',
formWaitingBaseUrl: 'http://localhost/form-waiting',
}), }),
mode, mode,
createRunExecutionData({ createRunExecutionData({
validateSignature: true,
resultData: { runData: {} }, resultData: { runData: {} },
}), }),
); );
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
}); });
it('should return a signed resume URL with no query parameters', () => { it('should return a resume URL with HMAC signature', () => {
const result = testContext.getSignedResumeUrl(); const result = testContext.getSignedResumeUrl();
expect(result).toBe( // URL should contain base path and a signature (64-char hex HMAC-SHA256)
'http://localhost/waiting-webhook/123/node456?signature=8e48dfd1107c1a736f70e7399493ffc50a2e8edd44f389c5f9c058da961682e7', expect(result).toMatch(
/^http:\/\/localhost\/waiting-webhook\/123\/node456\?signature=[a-f0-9]{64}$/,
); );
}); });
it('should return a signed resume URL with query parameters', () => { it('should return a resume URL with query parameters and HMAC signature', () => {
const result = testContext.getSignedResumeUrl({ approved: 'true' }); const result = testContext.getSignedResumeUrl({ approved: 'true' });
expect(result).toBe( // URL should contain base path, approved param, and a signature (64-char hex HMAC-SHA256)
'http://localhost/waiting-webhook/123/node456?approved=true&signature=11c5efc97a0d6f2ea9045dba6e397596cba29dc24adb44a9ebd3d1272c991e9b', expect(result).toMatch(
/^http:\/\/localhost\/waiting-webhook\/123\/node456\?approved=true&signature=[a-f0-9]{64}$/,
); );
}); });
it('should generate consistent HMAC signatures for the same URL', () => {
const result1 = testContext.getSignedResumeUrl();
const result2 = testContext.getSignedResumeUrl();
// Same URL should produce the same HMAC signature
const token1 = new URL(result1).searchParams.get('signature');
const token2 = new URL(result2).searchParams.get('signature');
expect(token1).toBe(token2);
});
}); });
describe('nodeFeatures', () => { describe('nodeFeatures', () => {

View File

@ -59,7 +59,11 @@ describe('SupplyDataContext', () => {
testParameter: 'testValue', testParameter: 'testValue',
}; };
const credentialsHelper = mock<ICredentialsHelper>(); const credentialsHelper = mock<ICredentialsHelper>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper }); const additionalData = mock<IWorkflowExecuteAdditionalData>({
credentialsHelper,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
});
const mode: WorkflowExecuteMode = 'manual'; const mode: WorkflowExecuteMode = 'manual';
const runExecutionData = mock<IRunExecutionData>({ const runExecutionData = mock<IRunExecutionData>({
resultData: { runData: {} }, resultData: { runData: {} },
@ -270,6 +274,8 @@ describe('SupplyDataContext', () => {
credentialsHelper, credentialsHelper,
hooks: mockHooks, hooks: mockHooks,
currentNodeExecutionIndex: 0, currentNodeExecutionIndex: 0,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const testRunExecutionData = mock<IRunExecutionData>({ const testRunExecutionData = mock<IRunExecutionData>({
resultData: { resultData: {
@ -427,6 +433,8 @@ describe('SupplyDataContext', () => {
credentialsHelper, credentialsHelper,
hooks: mockHooks, hooks: mockHooks,
currentNodeExecutionIndex: 0, currentNodeExecutionIndex: 0,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const testRunExecutionData = mock<IRunExecutionData>({ const testRunExecutionData = mock<IRunExecutionData>({
resultData: { resultData: {
@ -494,6 +502,8 @@ describe('SupplyDataContext', () => {
credentialsHelper, credentialsHelper,
hooks: mockHooks, hooks: mockHooks,
currentNodeExecutionIndex: 0, currentNodeExecutionIndex: 0,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
// Create run execution data with plain object (not mock) to avoid mock functions // Create run execution data with plain object (not mock) to avoid mock functions
@ -560,6 +570,8 @@ describe('SupplyDataContext', () => {
credentialsHelper, credentialsHelper,
hooks: mockHooks, hooks: mockHooks,
currentNodeExecutionIndex: 0, currentNodeExecutionIndex: 0,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
}); });
const testRunExecutionData = mock<IRunExecutionData>({ const testRunExecutionData = mock<IRunExecutionData>({
resultData: { resultData: {

View File

@ -41,13 +41,13 @@ import {
WAITING_TOKEN_QUERY_PARAM, WAITING_TOKEN_QUERY_PARAM,
} from '@/constants'; } from '@/constants';
import { InstanceSettings } from '@/instance-settings'; import { InstanceSettings } from '@/instance-settings';
import { generateUrlSignature, prepareUrlForSigning } from '@/utils/signature-helpers';
import { cleanupParameterData } from './utils/cleanup-parameter-data'; import { cleanupParameterData } from './utils/cleanup-parameter-data';
import { ensureType } from './utils/ensure-type'; import { ensureType } from './utils/ensure-type';
import { extractValue } from './utils/extract-value'; import { extractValue } from './utils/extract-value';
import { getAdditionalKeys } from './utils/get-additional-keys'; import { getAdditionalKeys } from './utils/get-additional-keys';
import { validateValueAgainstSchema } from './utils/validate-value-against-schema'; import { validateValueAgainstSchema } from './utils/validate-value-against-schema';
import { generateUrlSignature, prepareUrlForSigning } from '../../utils/signature-helpers';
export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> { export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCredentials'> {
protected readonly instanceSettings = Container.get(InstanceSettings); protected readonly instanceSettings = Container.get(InstanceSettings);
@ -248,10 +248,6 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
return this.instanceSettings.instanceId; return this.instanceSettings.instanceId;
} }
setSignatureValidationRequired() {
if (this.runExecutionData) this.runExecutionData.validateSignature = true;
}
getSignedResumeUrl(parameters: Record<string, string> = {}) { getSignedResumeUrl(parameters: Record<string, string> = {}) {
const { webhookWaitingBaseUrl, executionId } = this.additionalData; const { webhookWaitingBaseUrl, executionId } = this.additionalData;
@ -265,11 +261,14 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
baseURL.searchParams.set(key, value); baseURL.searchParams.set(key, value);
} }
// Sign the full URL (pathname + query params) using instance secret as HMAC key
// This ensures action parameters (like approved=true/false) cannot be tampered with
const urlForSigning = prepareUrlForSigning(baseURL); const urlForSigning = prepareUrlForSigning(baseURL);
const signature = generateUrlSignature(
const token = generateUrlSignature(urlForSigning, this.instanceSettings.hmacSignatureSecret); urlForSigning,
this.instanceSettings.hmacSignatureSecret,
baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token); );
baseURL.searchParams.set(WAITING_TOKEN_QUERY_PARAM, signature);
return baseURL.toString(); return baseURL.toString();
} }

View File

@ -1,5 +1,4 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { LoggerProxy } from 'n8n-workflow';
import type { IDataObject, IRunExecutionData, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import type { IDataObject, IRunExecutionData, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants'; import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
@ -28,7 +27,6 @@ describe('getAdditionalKeys', () => {
}); });
beforeAll(() => { beforeAll(() => {
LoggerProxy.init(mock());
externalSecretsProxy.hasProvider.mockReturnValue(true); externalSecretsProxy.hasProvider.mockReturnValue(true);
externalSecretsProxy.hasSecret.mockReturnValue(true); externalSecretsProxy.hasSecret.mockReturnValue(true);
externalSecretsProxy.getSecret.mockReturnValue('secret-value'); externalSecretsProxy.getSecret.mockReturnValue('secret-value');
@ -90,12 +88,40 @@ describe('getAdditionalKeys', () => {
}).toThrow(); }).toThrow();
}); });
it('should correctly set resume URLs', () => { it('should set plain resume URLs when runExecutionData has no resumeToken', () => {
const dataWithoutToken = mock<IRunExecutionData>({
resumeToken: undefined,
resultData: { runData: {}, metadata: {} },
});
const result = getAdditionalKeys(additionalData, 'manual', dataWithoutToken);
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123');
});
it('should append resumeToken to resume URLs when runExecutionData has a token', () => {
const dataWithToken = mock<IRunExecutionData>({
resumeToken: 'a'.repeat(64),
resultData: { runData: {}, metadata: {} },
});
const result = getAdditionalKeys(additionalData, 'manual', dataWithToken);
const resumeUrl = new URL(result.$execution?.resumeUrl ?? '');
const resumeFormUrl = new URL(result.$execution?.resumeFormUrl ?? '');
const deprecatedUrl = new URL(result.$resumeWebhookUrl ?? '');
expect(resumeUrl.searchParams.get('signature')).toBe('a'.repeat(64));
expect(resumeFormUrl.searchParams.get('signature')).toBe('a'.repeat(64));
expect(deprecatedUrl.searchParams.get('signature')).toBe('a'.repeat(64));
});
it('should set plain resume URLs when runExecutionData is null', () => {
const result = getAdditionalKeys(additionalData, 'manual', null); const result = getAdditionalKeys(additionalData, 'manual', null);
expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123'); expect(result.$execution?.resumeUrl).toBe('https://webhook.test/123');
expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123'); expect(result.$execution?.resumeFormUrl).toBe('https://form.test/123');
expect(result.$resumeWebhookUrl).toBe('https://webhook.test/123'); // Test deprecated property
}); });
it('should return test mode when manual', () => { it('should return test mode when manual', () => {

View File

@ -6,7 +6,7 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants'; import { PLACEHOLDER_EMPTY_EXECUTION_ID, WAITING_TOKEN_QUERY_PARAM } from '@/constants';
import { import {
setWorkflowExecutionMetadata, setWorkflowExecutionMetadata,
@ -16,6 +16,12 @@ import {
} from './execution-metadata'; } from './execution-metadata';
import { getSecretsProxy } from './get-secrets-proxy'; import { getSecretsProxy } from './get-secrets-proxy';
function appendResumeToken(url: string, token: string): string {
const urlObj = new URL(url);
urlObj.searchParams.set(WAITING_TOKEN_QUERY_PARAM, token);
return urlObj.toString();
}
/** Returns the additional keys for Expressions and Function-Nodes */ /** Returns the additional keys for Expressions and Function-Nodes */
export function getAdditionalKeys( export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
@ -23,8 +29,13 @@ export function getAdditionalKeys(
runExecutionData: IRunExecutionData | null, runExecutionData: IRunExecutionData | null,
): IWorkflowDataProxyAdditionalKeys { ): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID; const executionId = additionalData.executionId ?? PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
const resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`; let resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
let resumeFormUrl = `${additionalData.formWaitingBaseUrl}/${executionId}`;
if (runExecutionData?.resumeToken) {
resumeUrl = appendResumeToken(resumeUrl, runExecutionData.resumeToken);
resumeFormUrl = appendResumeToken(resumeFormUrl, runExecutionData.resumeToken);
}
return { return {
$execution: { $execution: {
id: executionId, id: executionId,

View File

@ -181,6 +181,7 @@ export class WorkflowExecute {
resultData: { resultData: {
pinData, pinData,
}, },
resumeToken: this.runExecutionData.resumeToken,
}); });
return this.processRunExecutionData(workflow); return this.processRunExecutionData(workflow);
@ -295,6 +296,7 @@ export class WorkflowExecute {
waitingExecution, waitingExecution,
waitingExecutionSource, waitingExecutionSource,
}, },
resumeToken: this.runExecutionData.resumeToken,
}); });
// Still passing the original workflow here, because the WorkflowDataProxy // Still passing the original workflow here, because the WorkflowDataProxy

View File

@ -1,26 +0,0 @@
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,16 +1,40 @@
import crypto from 'crypto'; import { createHmac, timingSafeEqual } from 'crypto';
import { WAITING_TOKEN_QUERY_PARAM } from '../constants';
/** /**
* Generate signature token from url and secret * Generate HMAC-SHA256 signature from URL and secret.
* Used to create tamper-proof resume URLs that include action parameters.
*/ */
export function generateUrlSignature(url: string, secret: string) { export function generateUrlSignature(url: string, secret: string): string {
const token = crypto.createHmac('sha256', secret).update(url).digest('hex'); return createHmac('sha256', secret).update(url).digest('hex');
return token;
} }
/** /**
* Prepare url for signing * Prepare url for signing
*/ */
export function prepareUrlForSigning(url: URL) { export function prepareUrlForSigning(url: URL): string {
return `${url.host}${url.pathname}${url.search}`; const urlForSigning = new URL(url.toString());
urlForSigning.searchParams.delete(WAITING_TOKEN_QUERY_PARAM);
return `${urlForSigning.pathname}${urlForSigning.search}`;
}
/**
* Validate that a provided signature matches the expected HMAC signature for the URL.
* Uses timing-safe comparison to prevent timing attacks.
*
* @param providedSignature - The signature from the request
* @param url - The full request URL
* @param secret - The HMAC secret (instance signature secret)
* @returns true if signature is valid
*/
export function validateUrlSignature(providedSignature: string, url: URL, secret: string): boolean {
const urlString = prepareUrlForSigning(url);
const expectedSignature = generateUrlSignature(urlString, secret);
if (providedSignature.length !== expectedSignature.length) {
return false;
}
return timingSafeEqual(Buffer.from(providedSignature), Buffer.from(expectedSignature));
} }

View File

@ -57,6 +57,8 @@ export function WorkflowExecuteAdditionalData(
return mock<IWorkflowExecuteAdditionalData>({ return mock<IWorkflowExecuteAdditionalData>({
hooks, hooks,
currentNodeExecutionIndex: 0, currentNodeExecutionIndex: 0,
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
// Not setting this to undefined would set it to a mock which would trigger // Not setting this to undefined would set it to a mock which would trigger
// conditions in the WorkflowExecute which only check if a property exists, // conditions in the WorkflowExecute which only check if a property exists,
// e.g. `if (!this.additionalData.restartExecutionId)`. This would for // e.g. `if (!this.additionalData.restartExecutionId)`. This would for

View File

@ -100,6 +100,17 @@ vi.mock('@n8n/permissions', () => ({
})), })),
})); }));
vi.mock('@/features/execution/executions/executions.utils', async (importOriginal) => {
const original = await importOriginal<object>();
return {
...original,
openFormPopupWindow: vi.fn(),
};
});
// Import the mocked function after the mock is set up
import { openFormPopupWindow } from '@/features/execution/executions/executions.utils';
describe('useWorkflowsStore', () => { describe('useWorkflowsStore', () => {
let workflowsStore: ReturnType<typeof useWorkflowsStore>; let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let workflowsListStore: ReturnType<typeof useWorkflowsListStore>; let workflowsListStore: ReturnType<typeof useWorkflowsListStore>;
@ -1264,14 +1275,17 @@ describe('useWorkflowsStore', () => {
expect(workflowsStore.workflowExecutionData).toEqual({ expect(workflowsStore.workflowExecutionData).toEqual({
...runWithExistingRunData, ...runWithExistingRunData,
data: createRunExecutionData({ data: {
resultData: { ...createRunExecutionData({
lastNodeExecuted: 'When clicking Execute workflow', resultData: {
runData: { lastNodeExecuted: 'When clicking Execute workflow',
[successEvent.nodeName]: [successEvent.data], runData: {
[successEvent.nodeName]: [successEvent.data],
},
}, },
}, }),
}), resumeToken: expect.any(String),
},
}); });
}); });
@ -1319,14 +1333,17 @@ describe('useWorkflowsStore', () => {
expect(workflowsStore.workflowExecutionData).toEqual({ expect(workflowsStore.workflowExecutionData).toEqual({
...executionResponse, ...executionResponse,
data: createRunExecutionData({ data: {
resultData: { ...createRunExecutionData({
lastNodeExecuted: 'When clicking Execute workflow', resultData: {
runData: { lastNodeExecuted: 'When clicking Execute workflow',
[successEvent.nodeName]: [successEventWithExecutionIndex.data], runData: {
[successEvent.nodeName]: [successEventWithExecutionIndex.data],
},
}, },
}, }),
}), resumeToken: expect.any(String),
},
}); });
}); });
}); });
@ -2448,6 +2465,93 @@ describe('useWorkflowsStore', () => {
}); });
}); });
describe('updateNodeExecutionStatus - form popup', () => {
beforeEach(() => {
vi.mocked(openFormPopupWindow).mockClear();
});
it('should open form popup using metadata.resumeFormUrl when present', () => {
const nodeName = 'Wait';
const executionId = 'exec-123';
const signedFormUrl = 'http://localhost:5678/form-waiting/exec-123?signature=abc123';
// Setup workflow with a node
workflowsStore.setNodes([createTestNode({ name: nodeName, type: WAIT_NODE_TYPE })]);
// Initialize execution data directly
workflowsStore.workflowExecutionData = {
id: executionId,
workflowData: createTestWorkflow(),
finished: false,
mode: 'manual',
startedAt: new Date(),
createdAt: new Date(),
status: 'running',
data: createEmptyRunExecutionData(),
} as IExecutionResponse;
// Call updateNodeExecutionStatus with waiting status and metadata.resumeFormUrl
workflowsStore.updateNodeExecutionStatus({
executionId,
nodeName,
data: {
executionStatus: 'waiting',
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
source: [],
hints: [],
metadata: {
resumeFormUrl: signedFormUrl,
},
},
itemCountByConnectionType: {},
});
// Should open form popup with the signed URL from metadata
expect(openFormPopupWindow).toHaveBeenCalledWith(signedFormUrl);
});
it('should not open form popup when metadata.resumeFormUrl is not present', () => {
const nodeName = 'Wait';
const executionId = 'exec-456';
// Setup workflow with a node
workflowsStore.setNodes([createTestNode({ name: nodeName, type: WAIT_NODE_TYPE })]);
// Initialize execution data directly
workflowsStore.workflowExecutionData = {
id: executionId,
workflowData: createTestWorkflow(),
finished: false,
mode: 'manual',
startedAt: new Date(),
createdAt: new Date(),
status: 'running',
data: createEmptyRunExecutionData(),
} as IExecutionResponse;
// Call updateNodeExecutionStatus with waiting status but NO metadata.resumeFormUrl
workflowsStore.updateNodeExecutionStatus({
executionId,
nodeName,
data: {
executionStatus: 'waiting',
startTime: Date.now(),
executionTime: 0,
executionIndex: 0,
source: [],
hints: [],
// No metadata
},
itemCountByConnectionType: {},
});
// Should NOT open form popup
expect(openFormPopupWindow).not.toHaveBeenCalled();
});
});
describe('getNodeTypes() - getByNameAndVersion', () => { describe('getNodeTypes() - getByNameAndVersion', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()); setActivePinia(createPinia());

View File

@ -1164,13 +1164,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
} }
} }
function getFormResumeUrl(node: INode, executionId: string) {
const { webhookSuffix } = (node.parameters.options ?? {}) as IDataObject;
const suffix = webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : '';
const testUrl = `${rootStore.formWaitingUrl}/${executionId}${suffix}`;
return testUrl;
}
function addNodeExecutionStartedData(data: NodeExecuteBefore['data']): void { function addNodeExecutionStartedData(data: NodeExecuteBefore['data']): void {
const currentData = const currentData =
workflowExecutionStartedData.value?.[0] === data.executionId workflowExecutionStartedData.value?.[0] === data.executionId
@ -1191,7 +1184,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
throw new Error('The "workflowExecutionData" is not initialized!'); throw new Error('The "workflowExecutionData" is not initialized!');
} }
const { nodeName, data, executionId } = pushData; const { nodeName, data } = pushData;
const isNodeWaiting = data.executionStatus === 'waiting'; const isNodeWaiting = data.executionStatus === 'waiting';
const node = getNodeByName(nodeName); const node = getNodeByName(nodeName);
if (!node) return; if (!node) return;
@ -1207,12 +1200,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
tasksData.push(data); tasksData.push(data);
workflowExecutionResultDataLastUpdate.value = Date.now(); workflowExecutionResultDataLastUpdate.value = Date.now();
if ( if (data.metadata?.resumeFormUrl) {
node.type === FORM_NODE_TYPE || openFormPopupWindow(data.metadata.resumeFormUrl);
(node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form')
) {
const testUrl = getFormResumeUrl(node, executionId);
openFormPopupWindow(testUrl);
} }
} else { } else {
// If we process items in parallel on subnodes we get several placeholder taskData items. // If we process items in parallel on subnodes we get several placeholder taskData items.

View File

@ -538,6 +538,54 @@ describe('waitingNodeTooltip', () => {
// Test without workflow - should return the raw tooltip string // Test without workflow - should return the raw tooltip string
expect(waitingNodeTooltip(node)).toBe('Waiting for approval...'); expect(waitingNodeTooltip(node)).toBe('Waiting for approval...');
}); });
it('should use metadata.resumeUrl when provided for webhook resume type', () => {
const node: INodeUi = {
id: '1',
name: 'Wait',
type: 'n8n-nodes-base.wait',
typeVersion: 1,
position: [0, 0],
parameters: {
resume: 'webhook',
},
};
const metadata = { resumeUrl: 'http://signed.com/wait/123?signature=abc123' };
const result = waitingNodeTooltip(node, mockWorkflow, metadata);
expect(result).toContain('http://signed.com/wait/123?signature=abc123');
});
it('should use metadata.resumeFormUrl when provided for form resume type', () => {
const node: INodeUi = {
id: '1',
name: 'Wait',
type: 'n8n-nodes-base.wait',
typeVersion: 1,
position: [0, 0],
parameters: {
resume: 'form',
},
};
const metadata = { resumeFormUrl: 'http://signed.com/form/123?signature=xyz789' };
const result = waitingNodeTooltip(node, mockWorkflow, metadata);
expect(result).toContain('http://signed.com/form/123?signature=xyz789');
});
it('should fall back to constructed URL when metadata is not provided', () => {
const node: INodeUi = {
id: '1',
name: 'Wait',
type: 'n8n-nodes-base.wait',
typeVersion: 1,
position: [0, 0],
parameters: {
resume: 'webhook',
},
};
// No metadata passed - should use constructed URL from store
const result = waitingNodeTooltip(node, mockWorkflow);
expect(result).toContain('http://localhost:5678/webhook-waiting/123');
});
}); });
const executionErrorFactory = (error: Record<string, unknown>) => const executionErrorFactory = (error: Record<string, unknown>) =>

View File

@ -185,31 +185,41 @@ export async function displayForm({
} }
} }
export const waitingNodeTooltip = (node: INodeUi | null | undefined, workflow?: Workflow) => { export const waitingNodeTooltip = (
node: INodeUi | null | undefined,
workflow?: Workflow,
metadata?: { resumeUrl?: string; resumeFormUrl?: string },
) => {
if (!node) return ''; if (!node) return '';
try { try {
const waitingNodeTooltip = useNodeTypesStore().getNodeType(node.type)?.waitingNodeTooltip; const waitingNodeTooltipFromNodeType = useNodeTypesStore().getNodeType(
if (waitingNodeTooltip) { node.type,
)?.waitingNodeTooltip;
if (waitingNodeTooltipFromNodeType) {
const activeExecutionId = useWorkflowsStore().activeExecutionId as string; const activeExecutionId = useWorkflowsStore().activeExecutionId as string;
// Use signed URLs from metadata if available
// otherwise fall back to constructing URLs without token
const additionalData: IWorkflowDataProxyAdditionalKeys = { const additionalData: IWorkflowDataProxyAdditionalKeys = {
$execution: { $execution: {
id: activeExecutionId, id: activeExecutionId,
mode: 'test', mode: 'test',
resumeUrl: `${useRootStore().webhookWaitingUrl}/${activeExecutionId}`, resumeUrl:
resumeFormUrl: `${useRootStore().formWaitingUrl}/${activeExecutionId}`, metadata?.resumeUrl ?? `${useRootStore().webhookWaitingUrl}/${activeExecutionId}`,
resumeFormUrl:
metadata?.resumeFormUrl ?? `${useRootStore().formWaitingUrl}/${activeExecutionId}`,
}, },
}; };
if (workflow) { if (workflow) {
const tooltip = workflow.expression.getSimpleParameterValue( const tooltip = workflow.expression.getSimpleParameterValue(
node, node,
waitingNodeTooltip, waitingNodeTooltipFromNodeType,
'internal', 'internal',
additionalData, additionalData,
); );
return String(tooltip); return String(tooltip);
} else if (waitingNodeTooltip) { } else if (waitingNodeTooltipFromNodeType) {
return waitingNodeTooltip; return waitingNodeTooltipFromNodeType;
} }
} }
} catch (error) { } catch (error) {

View File

@ -121,7 +121,11 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
<template #node-waiting> <template #node-waiting>
<NDVEmptyState :title="locale.baseText('ndv.output.waitNodeWaiting.title')" wide> <NDVEmptyState :title="locale.baseText('ndv.output.waitNodeWaiting.title')" wide>
<span v-n8n-html="waitingNodeTooltip(logEntry.node, logEntry.workflow)" /> <span
v-n8n-html="
waitingNodeTooltip(logEntry.node, logEntry.workflow, logEntry.runData?.metadata)
"
/>
</NDVEmptyState> </NDVEmptyState>
</template> </template>

View File

@ -249,12 +249,15 @@ const activeNodeType = computed(() => {
const waitingMessage = computed(() => { const waitingMessage = computed(() => {
const parentNode = parentNodes.value[0]; const parentNode = parentNodes.value[0];
return ( if (!parentNode) return '';
parentNode &&
waitingNodeTooltip( const runData = workflowsStore.getWorkflowExecution?.data?.resultData?.runData;
workflowDocumentStore?.value?.getNodeByName(parentNode.name) ?? null, const parentRunData = runData?.[parentNode.name]?.[0];
props.workflowObject,
) return waitingNodeTooltip(
workflowDocumentStore?.value?.getNodeByName(parentNode.name) ?? null,
props.workflowObject,
parentRunData?.metadata,
); );
}); });

View File

@ -471,7 +471,7 @@ function handleChangeCollapsingColumn(columnName: string | null) {
<template #node-waiting> <template #node-waiting>
<NDVEmptyState :title="i18n.baseText('ndv.output.waitNodeWaiting.title')" wide> <NDVEmptyState :title="i18n.baseText('ndv.output.waitNodeWaiting.title')" wide>
<span v-n8n-html="waitingNodeTooltip(node, workflowObject)" /> <span v-n8n-html="waitingNodeTooltip(node, workflowObject, runTaskData?.metadata)" />
</NDVEmptyState> </NDVEmptyState>
</template> </template>

View File

@ -438,6 +438,11 @@ export class Form extends Node {
} }
const waitTill = configureWaitTillDate(context, 'root'); const waitTill = configureWaitTillDate(context, 'root');
// Add signed resumeFormUrl to metadata for frontend to use when opening form popup
const resumeFormUrl = context.evaluateExpression('{{ $execution.resumeFormUrl }}', 0) as string;
context.setMetadata({ resumeFormUrl });
await context.putExecutionToWait(waitTill); await context.putExecutionToWait(waitTill);
context.sendResponse({ context.sendResponse({

View File

@ -2373,6 +2373,9 @@ export class Github implements INodeType {
throw new NodeApiError(this.getNode(), error as JsonObject); throw new NodeApiError(this.getNode(), error as JsonObject);
} }
// Add signed resumeUrl to metadata for frontend to use in waiting tooltip
this.setMetadata({ resumeUrl });
await this.putExecutionToWait(WAIT_INDEFINITELY); await this.putExecutionToWait(WAIT_INDEFINITELY);
return [this.getInputData()]; return [this.getInputData()];
} }

View File

@ -233,12 +233,22 @@ const waitingTooltip = (
) => { ) => {
const resume = parameters.resume; const resume = parameters.resume;
if (['webhook', 'form'].includes(resume as string)) { if (['webhook', 'form'].includes(resume)) {
const { webhookSuffix } = (parameters.options ?? {}) as { webhookSuffix: string }; const { webhookSuffix } = (parameters.options ?? {}) as { webhookSuffix: string };
const suffix = webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : ''; const suffix = webhookSuffix && typeof webhookSuffix !== 'object' ? `/${webhookSuffix}` : '';
let message = ''; let message = '';
const url = `${resume === 'form' ? formResumeUrl : resumeUrl}${suffix}`; const baseUrl = resume === 'form' ? formResumeUrl : resumeUrl;
// Insert suffix before query parameters if present (for URLs with ?signature=token)
// Note: Cannot use URL class here because it is not available in expressions
let url: string;
const queryIndex = baseUrl.indexOf('?');
if (queryIndex !== -1) {
url = baseUrl.slice(0, queryIndex) + suffix + baseUrl.slice(queryIndex);
} else {
url = baseUrl + suffix;
}
if (resume === 'form') { if (resume === 'form') {
message = 'Execution will continue when form is submitted on '; message = 'Execution will continue when form is submitted on ';
@ -502,10 +512,23 @@ export class Wait extends Webhook {
let hasFormTrigger = false; let hasFormTrigger = false;
if (resume === 'form') { if (resume === 'form') {
// Add signed resumeFormUrl to metadata for frontend to use when opening form popup
const resumeFormUrl = context.evaluateExpression(
'{{ $execution.resumeFormUrl }}',
0,
) as string;
context.setMetadata({ resumeFormUrl });
const parentNodes = context.getParentNodes(context.getNode().name); const parentNodes = context.getParentNodes(context.getNode().name);
hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE); hasFormTrigger = parentNodes.some((node) => node.type === FORM_TRIGGER_NODE_TYPE);
} }
if (resume === 'webhook') {
// Add signed resumeUrl to metadata for frontend to use in waiting tooltip
const resumeUrl = context.evaluateExpression('{{ $execution.resumeUrl }}', 0) as string;
context.setMetadata({ resumeUrl });
}
const returnData = await this.configureAndPutToWait(context); const returnData = await this.configureAndPutToWait(context);
if (resume === 'form' && hasFormTrigger) { if (resume === 'form' && hasFormTrigger) {

View File

@ -487,7 +487,6 @@ export function getSendAndWaitConfig(context: IExecuteFunctions): SendAndWaitCon
const responseType = context.getNodeParameter('responseType', 0, 'approval') as string; const responseType = context.getNodeParameter('responseType', 0, 'approval') as string;
context.setSignatureValidationRequired();
const approvedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'true' }); const approvedSignedResumeUrl = context.getSignedResumeUrl({ approved: 'true' });
if (responseType === 'freeText' || responseType === 'customForm') { if (responseType === 'freeText' || responseType === 'customForm') {

View File

@ -172,6 +172,9 @@ export class CanvasPage extends BasePage {
} }
async clickExecuteWorkflowButton(triggerNodeName?: string): Promise<void> { async clickExecuteWorkflowButton(triggerNodeName?: string): Promise<void> {
if (triggerNodeName) {
await this.nodeByName(triggerNodeName).hover();
}
await this.getExecuteWorkflowButton(triggerNodeName).click(); await this.getExecuteWorkflowButton(triggerNodeName).click();
} }

View File

@ -0,0 +1,96 @@
import flatted from 'flatted';
import { test, expect } from '../../../fixtures/base';
test.describe('Wait Node Form Resume', () => {
test.describe('signature validation', () => {
test('should reject unsigned and tampered requests, accept valid signature', async ({
api,
}) => {
const { webhookPath, workflowId } =
await api.workflows.importWorkflowFromFile('wait-form-resume.json');
// Trigger workflow via webhook to reach waiting state
const triggerResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
});
expect(triggerResponse.ok()).toBe(true);
// Wait for workflow to reach waiting state
const execution = await api.workflows.waitForWorkflowStatus(workflowId, 'waiting');
expect(execution).toBeDefined();
const unsignedResponse = await api.webhooks.trigger(`/form-waiting/${execution.id}`);
expect(unsignedResponse.status()).toBe(401);
// Verify HTML error page (not JSON)
const unsignedText = await unsignedResponse.text();
expect(unsignedText).toContain('<!DOCTYPE html>');
expect(unsignedText).toContain('Invalid Form Link');
// Try tampered signature - should also get 401
const tamperedResponse = await api.webhooks.trigger(
`/form-waiting/${execution.id}?signature=tampered_invalid_signature_abc123`,
);
expect(tamperedResponse.status()).toBe(401);
// Get the valid signed URL from execution data
const fullExecution = await api.workflows.getExecution(execution.id);
expect(fullExecution.data).toBeDefined();
const executionData = flatted.parse(fullExecution.data);
const captureNodeOutput = executionData.resultData.runData['Capture Form URL'];
expect(captureNodeOutput).toBeDefined();
const formUrl = captureNodeOutput[0].data.main[0][0].json.formUrl;
expect(formUrl).toBeDefined();
expect(formUrl).toContain('signature=');
// Extract path with signature and make valid request
const urlObj = new URL(formUrl);
const signedPath = `${urlObj.pathname}${urlObj.search}`;
const signedResponse = await api.webhooks.trigger(signedPath, {
maxNotFoundRetries: 0,
});
expect(signedResponse.ok()).toBe(true);
});
});
test.describe('manual execution form popup', () => {
test('should open form when Wait node enters waiting state', async ({ n8n }) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.nodeCreatorItemByName('Trigger manually').click();
await n8n.canvas.addNode('Wait', { closeNDV: false });
await n8n.ndv.setParameterDropdown('resume', 'On Form Submitted');
await n8n.ndv.fillParameterInput('Form Title', 'Test Wait Form');
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Test Field');
await n8n.ndv.clickBackToCanvasButton();
const popupPromise = n8n.page.context().waitForEvent('page');
await n8n.canvas.clickExecuteWorkflowButton();
const formPage = await popupPromise;
await expect(formPage.getByText('Test Wait Form')).toBeVisible();
await expect(formPage.getByLabel('Test Field')).toBeVisible();
const url = formPage.url();
expect(url).toContain('form-waiting');
expect(url).toContain('signature=');
await formPage.getByLabel('Test Field').fill('test value');
await formPage.getByRole('button', { name: 'Submit' }).click();
await expect(formPage.getByText('Your response has been recorded')).toBeVisible();
await formPage.close();
await n8n.canvas.openNode('Wait');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toContainText('test value');
});
});
});

View File

@ -0,0 +1,142 @@
import flatted from 'flatted';
import { test, expect } from '../../../fixtures/base';
/**
* Security tests for waiting endpoint signature validation.
*
* These tests verify that form-waiting and webhook-waiting endpoints
* require valid HMAC signatures to prevent session interception attacks.
*
* Note: Send and Wait approval flow tests are in nodes/send-and-wait.spec.ts
*/
test.describe('Waiting Endpoint Security', () => {
test.describe('webhook-waiting signature validation', () => {
test('should reject unsigned and tampered requests, accept valid signature', async ({
api,
}) => {
const { webhookPath, workflowId } = await api.workflows.importWorkflowFromFile(
'wait-webhook-resume.json',
);
const triggerResponse = await api.webhooks.trigger(`/webhook/${webhookPath}`, {
method: 'POST',
});
expect(triggerResponse.ok()).toBe(true);
const execution = await api.workflows.waitForWorkflowStatus(workflowId, 'waiting');
expect(execution).toBeDefined();
const unsignedResponse = await api.webhooks.trigger(`/webhook-waiting/${execution.id}`, {
maxNotFoundRetries: 0,
});
expect(unsignedResponse.status()).toBe(401);
const tamperedResponse = await api.webhooks.trigger(
`/webhook-waiting/${execution.id}?signature=tampered_invalid_signature_abc123`,
{ maxNotFoundRetries: 0 },
);
expect(tamperedResponse.status()).toBe(401);
const fullExecution = await api.workflows.getExecution(execution.id);
expect(fullExecution.data).toBeDefined();
const executionData = flatted.parse(fullExecution.data);
const captureNodeOutput = executionData.resultData.runData['Capture Resume URL'];
expect(captureNodeOutput).toBeDefined();
const resumeUrl = captureNodeOutput[0].data.main[0][0].json.resumeUrl;
expect(resumeUrl).toBeDefined();
expect(resumeUrl).toContain('signature=');
// Accept valid signature
const urlObj = new URL(resumeUrl);
const signedPath = `${urlObj.pathname}${urlObj.search}`;
const signedResponse = await api.webhooks.trigger(signedPath, {
maxNotFoundRetries: 0,
});
expect(signedResponse.ok()).toBe(true);
const completedExecution = await api.workflows.waitForWorkflowStatus(
workflowId,
'success',
10000,
);
expect(completedExecution.status).toBe('success');
});
});
test.describe('form-waiting signature validation', () => {
test('should reject unsigned and tampered requests, accept valid signature', async ({
n8n,
}) => {
await n8n.start.fromBlankCanvas();
await n8n.canvas.clickNodeCreatorPlusButton();
await n8n.canvas.nodeCreatorItemByName('On form submission').click();
await n8n.ndv.fillParameterInput('Form Title', 'Security Test Form');
await n8n.ndv.fillParameterInput('Form Description', 'Testing signature validation');
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'First field');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.addNode('n8n Form', { closeNDV: false, action: 'Next Form Page' });
await n8n.ndv.addFixedCollectionItem();
await n8n.ndv.fillParameterInputByName('fieldLabel', 'Second field');
await n8n.ndv.clickBackToCanvasButton();
await n8n.canvas.clickExecuteWorkflowButton();
await expect(n8n.canvas.getExecuteWorkflowButton()).toHaveText('Waiting for trigger event');
await n8n.canvas.openNode('On form submission');
const formUrlLocator = n8n.page.locator('text=/form-test\\/[a-f0-9-]+/');
await expect(formUrlLocator).toHaveText(/form-test\/[a-f0-9-]+/);
const formUrl = await formUrlLocator.textContent();
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
await formPage.getByLabel('First field').fill('test value');
const responsePromise = formPage.waitForResponse(
(resp) => resp.url().includes('/form-test/') && resp.request().method() === 'POST',
);
await formPage.getByRole('button', { name: 'Submit' }).click();
const postResponse = await responsePromise;
const responseBody = await postResponse.json();
expect(responseBody.formWaitingUrl).toBeDefined();
const waitingUrl: string = responseBody.formWaitingUrl;
expect(waitingUrl).toContain('signature=');
const urlObj = new URL(waitingUrl);
const executionId = urlObj.pathname.split('/').pop()!;
const validSignature = urlObj.searchParams.get('signature');
await expect(async () => {
const execution = await n8n.api.workflows.getExecution(executionId);
expect(execution.status).toBe('waiting');
}).toPass();
const unsignedResponse = await n8n.api.webhooks.trigger(`/form-waiting/${executionId}`, {
maxNotFoundRetries: 0,
});
expect(unsignedResponse.status()).toBe(401);
const tamperedResponse = await n8n.api.webhooks.trigger(
`/form-waiting/${executionId}?signature=tampered_fake_signature_xyz789`,
{ maxNotFoundRetries: 0 },
);
expect(tamperedResponse.status()).toBe(401);
const signedResponse = await n8n.api.webhooks.trigger(
`/form-waiting/${executionId}?signature=${validSignature}`,
{ maxNotFoundRetries: 0 },
);
expect(signedResponse.ok()).toBe(true);
await formPage.close();
});
});
});

View File

@ -0,0 +1,270 @@
import type { Page } from '@playwright/test';
import type { ProxyServer } from 'n8n-containers/services/proxy';
import type { IWorkflowBase } from 'n8n-workflow';
import { test as base, expect } from '../../../fixtures/base';
import type { CredentialResponse } from '../../../services/credential-api-helper';
interface SlackBlock {
type: string;
elements?: Array<{ type: string; url?: string }>;
}
type SendAndWaitFixtures = {
slackCredential: CredentialResponse;
slackProxySetup: undefined;
};
const test = base.extend<SendAndWaitFixtures>({
slackProxySetup: [
async ({ services }, use) => {
await services.proxy.clearAllExpectations();
await services.proxy.createExpectation({
httpRequest: { method: 'POST', path: '/api/chat.postMessage' },
httpResponse: {
statusCode: 200,
headers: { 'Content-Type': ['application/json'] },
body: JSON.stringify({
ok: true,
channel: 'C12345678',
ts: '1234567890.123456',
message: { text: 'Approval Request' },
}),
},
times: { unlimited: true },
});
await use(undefined);
},
{ auto: true },
],
slackCredential: async ({ n8n }, use) => {
const credential = await n8n.api.credentials.createCredential({
name: `Slack Test ${crypto.randomUUID().slice(0, 8)}`,
type: 'slackApi',
data: { accessToken: 'xoxb-fake-token-for-testing' },
});
await use(credential);
await n8n.api.credentials.deleteCredential(credential.id);
},
});
const NOT_A_BOT_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0';
function getBlocksFromBody(body: { blocks?: SlackBlock[] } | undefined): SlackBlock[] | null {
return body?.blocks ?? null;
}
function extractApprovalUrls(blocks: SlackBlock[]): { approveUrl?: string; rejectUrl?: string } {
const buttons = blocks
.filter((b) => b.type === 'actions' && b.elements)
.flatMap((b) => b.elements?.filter((el) => el.type === 'button' && el.url) ?? []);
return {
approveUrl: buttons.find((btn) => btn.url?.includes('approved=true'))?.url,
rejectUrl: buttons.find((btn) => btn.url?.includes('approved=false'))?.url,
};
}
function extractFormUrl(blocks: SlackBlock[]): string | undefined {
const buttons = blocks
.filter((b) => b.type === 'actions' && b.elements)
.flatMap((b) => b.elements?.filter((el) => el.type === 'button' && el.url) ?? []);
// For custom forms, there's a single button with the form URL (contains signature)
return buttons.find((btn) => btn.url?.includes('signature='))?.url;
}
async function waitForSlackRequest(proxyServer: ProxyServer) {
await expect(async () => {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
expect(slackRequest).toBeDefined();
}).toPass();
}
async function getApprovalUrlsFromSlack(proxyServer: ProxyServer) {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
const blocks = getBlocksFromBody(slackRequest?.httpRequest?.body as { blocks?: SlackBlock[] });
return extractApprovalUrls(blocks!);
}
async function getFormUrlFromSlack(proxyServer: ProxyServer) {
const requests = await proxyServer.getAllRequestsMade();
const slackRequest = requests.find(
(r) => r.httpRequest?.path === '/api/chat.postMessage' && r.httpRequest?.method === 'POST',
);
const blocks = getBlocksFromBody(slackRequest?.httpRequest?.body as { blocks?: SlackBlock[] });
return extractFormUrl(blocks!);
}
async function clickApprovalLink(page: Page, url: string) {
console.log('Clicking approval URL:', url);
const response = await page.request.get(url, {
headers: { 'User-Agent': NOT_A_BOT_USER_AGENT },
});
console.log('Response status:', response.status());
const body = await response.text();
console.log('Response body preview:', body.substring(0, 500));
expect(response.ok()).toBe(true);
}
function withSlackCredential(credential: CredentialResponse) {
return (workflow: Partial<IWorkflowBase>) => {
workflow.nodes?.forEach((node) => {
if (node.type === 'n8n-nodes-base.slack') {
node.credentials = { slackApi: { id: credential.id, name: credential.name } };
}
});
return workflow;
};
}
test.use({ capability: 'proxy' });
test.describe('Send and Wait @capability:proxy', () => {
test('should complete approval flow when clicking approve URL', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { approveUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(approveUrl).toContain('signature=');
await clickApprovalLink(n8n.page, approveUrl!);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
});
test('should complete rejection flow when clicking reject URL', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { rejectUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(rejectUrl).toContain('signature=');
await clickApprovalLink(n8n.page, rejectUrl!);
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
});
test('should reject requests with invalid signatures', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton('Manual Trigger');
await waitForSlackRequest(services.proxy);
const execution = await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const unsignedResponse = await n8n.api.webhooks.trigger(
`/webhook-waiting/${execution.id}/slack-send-wait`,
);
expect(unsignedResponse.status()).toBe(401);
const tamperedResponse = await n8n.api.webhooks.trigger(
`/webhook-waiting/${execution.id}/slack-send-wait?approved=true&signature=tampered`,
);
expect(tamperedResponse.status()).toBe(401);
});
test('should complete form submission flow', async ({ n8n, services, slackCredential }) => {
const { workflowId } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-form.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.clickExecuteWorkflowButton();
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const formUrl = await getFormUrlFromSlack(services.proxy);
expect(formUrl).toContain('signature=');
const formPage = await n8n.page.context().newPage();
await formPage.goto(formUrl!);
await expect(formPage.getByText('Test Form')).toBeVisible({ timeout: 10000 });
await expect(formPage.getByText('Please provide your information')).toBeVisible();
await formPage.getByLabel('Name').fill('John Doe');
await formPage.getByLabel('Email').fill('john@example.com');
await formPage.getByLabel('Comments').fill('This is a test comment');
await formPage.getByRole('button', { name: 'Submit Form' }).click();
await expect(formPage.getByText('Got it, thanks')).toBeVisible({ timeout: 10000 });
await formPage.close();
await expect(n8n.canvas.getNodeSuccessStatusIndicator('Capture Result')).toBeVisible();
await n8n.canvas.openNode('Capture Result');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 0)).toHaveText('John Doe');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 1)).toHaveText('john@example.com');
await expect(n8n.ndv.outputPanel.getTbodyCell(0, 2)).toHaveText('This is a test comment');
});
test('should complete approval flow in production mode (activated workflow)', async ({
n8n,
services,
slackCredential,
}) => {
const { workflowId, webhookPath } = await n8n.api.workflows.importWorkflowFromFile(
'send-and-wait-approval.json',
{ transform: withSlackCredential(slackCredential) },
);
await n8n.navigate.toWorkflow(workflowId);
await n8n.canvas.publishWorkflow();
const triggerResponse = await n8n.api.webhooks.trigger(`/webhook/${webhookPath}`);
expect(triggerResponse.ok()).toBe(true);
await waitForSlackRequest(services.proxy);
await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'waiting', 10000);
const { approveUrl } = await getApprovalUrlsFromSlack(services.proxy);
expect(approveUrl).toContain('signature=');
await clickApprovalLink(n8n.page, approveUrl!);
const execution = await n8n.api.workflows.waitForWorkflowStatus(workflowId, 'success', 10000);
expect(execution.status).toBe('success');
});
});

View File

@ -3,9 +3,25 @@ import { readFileSync } from 'fs';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import { test, expect } from '../../../../../fixtures/base'; import { test, expect } from '../../../../../fixtures/base';
import type { ApiHelpers } from '../../../../../services/api-helper';
import { resolveFromRoot } from '../../../../../utils/path-helper'; import { resolveFromRoot } from '../../../../../utils/path-helper';
import { retryUntil } from '../../../../../utils/retry-utils'; import { retryUntil } from '../../../../../utils/retry-utils';
/**
* Extracts the resume URL from an execution's node output.
*/
async function getResumeUrl(
api: ApiHelpers,
executionId: string,
nodeName: string,
fieldName: string,
): Promise<string> {
const fullExecution = await api.workflows.getExecution(executionId);
const executionData = flatted.parse(fullExecution.data);
const nodeOutput = executionData.resultData.runData[nodeName];
return nodeOutput[0].data.main[0][0].json[fieldName] as string;
}
test.describe( test.describe(
'Parent that does not wait for sub-workflow', 'Parent that does not wait for sub-workflow',
{ {
@ -132,10 +148,10 @@ test.describe('CAT-1801: Parent receives correct data from child with wait node'
{ timeoutMs: 10000, intervalMs: 200 }, { timeoutMs: 10000, intervalMs: 200 },
); );
// Trigger the wait webhook to resume child using child execution ID // Trigger the wait webhook to resume child using the resume URL
const waitWebhookResponse = await api.webhooks.trigger( // The child workflow captures $execution.resumeUrl in the "Edit Fields" node's "webhook" field
`/webhook-waiting/${childExecution!.id}`, const resumeUrl = await getResumeUrl(api, childExecution!.id, 'Edit Fields', 'webhook');
); const waitWebhookResponse = await api.webhooks.trigger(resumeUrl);
expect(waitWebhookResponse.ok()).toBe(true); expect(waitWebhookResponse.ok()).toBe(true);
// Wait for parent to complete // Wait for parent to complete
@ -193,8 +209,14 @@ test.describe('CAT-1929: Parent should not resume until child with multiple wait
// Verify parent is also waiting at this point // Verify parent is also waiting at this point
await api.workflows.waitForWorkflowStatus(parentWorkflowId, 'waiting'); await api.workflows.waitForWorkflowStatus(parentWorkflowId, 'waiting');
// Resume first wait node // Resume first wait node using URL from "Edit Fields - Before First Wait" node
const firstWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`); const firstResumeUrl = await getResumeUrl(
api,
childExecution.id,
'Edit Fields - Before First Wait',
'webhook1',
);
const firstWaitResponse = await api.webhooks.trigger(firstResumeUrl);
expect(firstWaitResponse.ok()).toBe(true); expect(firstWaitResponse.ok()).toBe(true);
// Wait for child to reach the second wait node // Wait for child to reach the second wait node
@ -207,8 +229,14 @@ test.describe('CAT-1929: Parent should not resume until child with multiple wait
); );
expect(parentExecAfterFirstWait.status).toBe('waiting'); expect(parentExecAfterFirstWait.status).toBe('waiting');
// Resume second wait node // Resume second wait node using URL from "Edit Fields - After First Wait" node
const secondWaitResponse = await api.webhooks.trigger(`/webhook-waiting/${childExecution.id}`); const secondResumeUrl = await getResumeUrl(
api,
childExecution.id,
'Edit Fields - After First Wait',
'webhook2',
);
const secondWaitResponse = await api.webhooks.trigger(secondResumeUrl);
expect(secondWaitResponse.ok()).toBe(true); expect(secondWaitResponse.ok()).toBe(true);
// Now parent should complete // Now parent should complete

View File

@ -0,0 +1,126 @@
{
"name": "Send and Wait Approval Test",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [300, 200]
},
{
"parameters": {
"path": "send-and-wait-approval-test",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [300, 400],
"webhookId": "send-and-wait-approval-webhook"
},
{
"parameters": {
"authentication": "accessToken",
"resource": "message",
"operation": "sendAndWait",
"select": "channel",
"channelId": {
"__rl": true,
"value": "C12345678",
"mode": "id"
},
"subject": "Approval Request",
"message": "Please approve this test request",
"responseType": "approval",
"approvalOptions": {
"values": {
"approvalType": "double",
"approveLabel": "Approve",
"buttonApprovalStyle": "primary",
"disapproveLabel": "Reject",
"buttonDisapprovalStyle": "secondary"
}
},
"options": {}
},
"id": "slack-send-wait",
"name": "Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [500, 300],
"credentials": {
"slackApi": {
"id": "PLACEHOLDER_CREDENTIAL_ID",
"name": "Slack Test Credential"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result-field",
"name": "approvalResult",
"value": "={{ $json.data.approved }}",
"type": "boolean"
}
]
},
"options": {}
},
"id": "capture-result",
"name": "Capture Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [700, 300]
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Slack",
"type": "main",
"index": 0
}
]
]
},
"Webhook": {
"main": [
[
{
"node": "Slack",
"type": "main",
"index": 0
}
]
]
},
"Slack": {
"main": [
[
{
"node": "Capture Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": null,
"pinData": {},
"versionId": null,
"triggerCount": 0,
"tags": []
}

View File

@ -0,0 +1,130 @@
{
"name": "Send and Wait Form Test",
"nodes": [
{
"parameters": {},
"id": "manual-trigger",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [300, 300]
},
{
"parameters": {
"authentication": "accessToken",
"resource": "message",
"operation": "sendAndWait",
"select": "channel",
"channelId": {
"__rl": true,
"value": "C12345678",
"mode": "id"
},
"subject": "Form Request",
"message": "Please fill out this form",
"responseType": "customForm",
"defineForm": "fields",
"formFields": {
"values": [
{
"fieldLabel": "Name",
"fieldType": "text",
"requiredField": true
},
{
"fieldLabel": "Email",
"fieldType": "email",
"requiredField": true
},
{
"fieldLabel": "Comments",
"fieldType": "textarea",
"requiredField": false
}
]
},
"options": {
"responseFormTitle": "Test Form",
"responseFormDescription": "Please provide your information",
"responseFormButtonLabel": "Submit Form"
}
},
"id": "slack-send-wait",
"name": "Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [500, 300],
"credentials": {
"slackApi": {
"id": "PLACEHOLDER_CREDENTIAL_ID",
"name": "Slack Test Credential"
}
}
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "name-field",
"name": "submittedName",
"value": "={{ $json.data.Name }}",
"type": "string"
},
{
"id": "email-field",
"name": "submittedEmail",
"value": "={{ $json.data.Email }}",
"type": "string"
},
{
"id": "comments-field",
"name": "submittedComments",
"value": "={{ $json.data.Comments }}",
"type": "string"
}
]
},
"options": {}
},
"id": "capture-result",
"name": "Capture Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [700, 300]
}
],
"connections": {
"Manual Trigger": {
"main": [
[
{
"node": "Slack",
"type": "main",
"index": 0
}
]
]
},
"Slack": {
"main": [
[
{
"node": "Capture Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": null,
"pinData": {},
"versionId": null,
"triggerCount": 0,
"tags": []
}

View File

@ -0,0 +1,124 @@
{
"name": "Wait Form Resume Test",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "start-wait-form-test",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [300, 300],
"webhookId": "start-wait-form-test-id"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "capture-form-url",
"name": "formUrl",
"value": "={{ $execution.resumeFormUrl }}",
"type": "string"
}
]
},
"options": {}
},
"id": "capture-url",
"name": "Capture Form URL",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [500, 300]
},
{
"parameters": {
"resume": "form",
"formTitle": "Test Form",
"formFields": {
"values": [
{
"fieldLabel": "Name",
"fieldType": "text",
"requiredField": true
}
]
},
"options": {}
},
"id": "wait-form",
"name": "Wait for Form",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [700, 300]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result",
"name": "result",
"value": "Form submitted successfully",
"type": "string"
}
]
},
"options": {}
},
"id": "result",
"name": "Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [900, 300]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Capture Form URL",
"type": "main",
"index": 0
}
]
]
},
"Capture Form URL": {
"main": [
[
{
"node": "Wait for Form",
"type": "main",
"index": 0
}
]
]
},
"Wait for Form": {
"main": [
[
{
"node": "Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": null,
"pinData": {},
"versionId": null,
"triggerCount": 0,
"tags": []
}

View File

@ -0,0 +1,115 @@
{
"name": "Wait Webhook Resume Test",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "start-wait-test",
"options": {}
},
"id": "webhook-trigger",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [300, 300],
"webhookId": "start-wait-test-id"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "capture-resume-url",
"name": "resumeUrl",
"value": "={{ $execution.resumeUrl }}",
"type": "string"
}
]
},
"options": {}
},
"id": "capture-url",
"name": "Capture Resume URL",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [400, 300]
},
{
"parameters": {
"resume": "webhook",
"options": {}
},
"id": "wait-node",
"name": "Wait",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [600, 300],
"webhookId": "wait-webhook-id"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "result-field",
"name": "result",
"value": "Wait completed successfully",
"type": "string"
}
]
},
"options": {}
},
"id": "set-result",
"name": "Set Result",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [800, 300]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Capture Resume URL",
"type": "main",
"index": 0
}
]
]
},
"Capture Resume URL": {
"main": [
[
{
"node": "Wait",
"type": "main",
"index": 0
}
]
]
},
"Wait": {
"main": [
[
{
"node": "Set Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1"
},
"staticData": null,
"meta": null,
"pinData": {},
"versionId": null,
"triggerCount": 0,
"tags": []
}

View File

@ -57,6 +57,7 @@ export {
isCommunityPackageName, isCommunityPackageName,
dedupe, dedupe,
sanitizeFilename, sanitizeFilename,
generateSecureToken,
} from './utils'; } from './utils';
export { export {
isINodeProperties, isINodeProperties,

View File

@ -963,8 +963,6 @@ export interface FunctionsBase {
getInstanceId(): string; getInstanceId(): string;
/** Get the waiting resume url signed with the signature token */ /** Get the waiting resume url signed with the signature token */
getSignedResumeUrl(parameters?: Record<string, string>): string; getSignedResumeUrl(parameters?: Record<string, string>): string;
/** Set requirement in the execution for signature token validation */
setSignatureValidationRequired(): void;
getChildNodes( getChildNodes(
nodeName: string, nodeName: string,
options?: { includeNodeParameters?: boolean }, options?: { includeNodeParameters?: boolean },
@ -2754,6 +2752,18 @@ export interface ITaskMetadata {
/** Time saved in minutes */ /** Time saved in minutes */
minutes: number; minutes: number;
}; };
/**
* Signed URL for resuming form-based waiting executions.
* Contains token for security validation.
*/
resumeFormUrl?: string;
/**
* Signed URL for resuming webhook-based waiting executions.
* Contains token for security validation.
*/
resumeUrl?: string;
} }
/** The data that gets returned when a node execution starts */ /** The data that gets returned when a node execution starts */

View File

@ -14,6 +14,7 @@ import type {
} from './interfaces'; } from './interfaces';
import type { IRunExecutionData } from './run-execution-data/run-execution-data'; import type { IRunExecutionData } from './run-execution-data/run-execution-data';
import type { IRunExecutionDataV1 } from './run-execution-data/run-execution-data.v1'; import type { IRunExecutionDataV1 } from './run-execution-data/run-execution-data.v1';
import { generateSecureToken } from './utils';
export interface CreateFullRunExecutionDataOptions { export interface CreateFullRunExecutionDataOptions {
startData?: { startData?: {
@ -40,12 +41,39 @@ export interface CreateFullRunExecutionDataOptions {
runtimeData?: IExecutionContext; runtimeData?: IExecutionContext;
} | null; } | null;
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
validateSignature?: boolean; resumeToken?: string;
waitTill?: Date; waitTill?: Date;
manualData?: IRunExecutionData['manualData']; manualData?: IRunExecutionData['manualData'];
pushRef?: IRunExecutionData['pushRef']; pushRef?: IRunExecutionData['pushRef'];
} }
function buildResultData(
resultData: CreateFullRunExecutionDataOptions['resultData'],
): IRunExecutionDataV1['resultData'] {
return {
error: resultData?.error,
// @ts-expect-error CAT-752
runData: resultData?.runData === null ? undefined : (resultData?.runData ?? {}),
pinData: resultData?.pinData,
lastNodeExecuted: resultData?.lastNodeExecuted,
metadata: resultData?.metadata,
};
}
function buildExecutionData(
executionData: CreateFullRunExecutionDataOptions['executionData'],
): IRunExecutionDataV1['executionData'] {
if (executionData === null) return undefined;
return {
contextData: executionData?.contextData ?? {},
nodeExecutionStack: executionData?.nodeExecutionStack ?? [],
metadata: executionData?.metadata ?? {},
waitingExecution: executionData?.waitingExecution ?? {},
waitingExecutionSource: executionData?.waitingExecutionSource ?? {},
runtimeData: executionData?.runtimeData,
};
}
/** /**
* Creates a complete IRunExecutionData object with all properties initialized. * Creates a complete IRunExecutionData object with all properties initialized.
* You can pass `executionData: null` and `resultData.runData: null` if you * You can pass `executionData: null` and `resultData.runData: null` if you
@ -57,28 +85,10 @@ export function createRunExecutionData(
return { return {
version: 1, version: 1,
startData: options.startData ?? {}, startData: options.startData ?? {},
resultData: { resultData: buildResultData(options.resultData),
error: options.resultData?.error, executionData: buildExecutionData(options.executionData),
// @ts-expect-error CAT-752
runData:
options.resultData?.runData === null ? undefined : (options.resultData?.runData ?? {}),
pinData: options.resultData?.pinData,
lastNodeExecuted: options.resultData?.lastNodeExecuted,
metadata: options.resultData?.metadata,
},
executionData:
options.executionData === null
? undefined
: {
contextData: options.executionData?.contextData ?? {},
nodeExecutionStack: options.executionData?.nodeExecutionStack ?? [],
metadata: options.executionData?.metadata ?? {},
waitingExecution: options.executionData?.waitingExecution ?? {},
waitingExecutionSource: options.executionData?.waitingExecutionSource ?? {},
runtimeData: options.executionData?.runtimeData,
},
parentExecution: options.parentExecution, parentExecution: options.parentExecution,
validateSignature: options.validateSignature, resumeToken: options.resumeToken ?? generateSecureToken(),
waitTill: options.waitTill, waitTill: options.waitTill,
manualData: options.manualData, manualData: options.manualData,
pushRef: options.pushRef, pushRef: options.pushRef,

View File

@ -41,10 +41,10 @@ export interface IRunExecutionDataV0 {
}; };
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
/** /**
* This is used to prevent breaking change * Random token used to validate waiting webhook/form requests.
* for waiting executions started before signature validation was added * Generated when execution starts. Presence signals validation is required.
*/ */
validateSignature?: boolean; resumeToken?: string;
waitTill?: Date; waitTill?: Date;
pushRef?: string; pushRef?: string;

View File

@ -56,10 +56,10 @@ export interface IRunExecutionDataV1 {
}; };
parentExecution?: RelatedExecution; parentExecution?: RelatedExecution;
/** /**
* This is used to prevent breaking change * Random token used to validate waiting webhook/form requests.
* for waiting executions started before signature validation was added * Generated when execution starts. Presence signals validation is required.
*/ */
validateSignature?: boolean; resumeToken?: string;
waitTill?: Date; waitTill?: Date;
pushRef?: string; pushRef?: string;

View File

@ -510,3 +510,10 @@ export function sanitizeFilename(fileName: string): string {
return sanitized; return sanitized;
} }
/** Generates a cryptographically secure 64-character hex token (256 bits). */
export function generateSecureToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return bytes.reduce((hex, byte) => hex + byte.toString(16).padStart(2, '0'), '');
}

View File

@ -19,7 +19,7 @@ describe('RunExecutionDataFactory', () => {
manualData: undefined, manualData: undefined,
parentExecution: undefined, parentExecution: undefined,
pushRef: undefined, pushRef: undefined,
validateSignature: undefined, resumeToken: expect.stringMatching(/^[a-f0-9]{64}$/),
waitTill: undefined, waitTill: undefined,
resultData: { resultData: {
error: undefined, error: undefined,
@ -61,7 +61,7 @@ describe('RunExecutionDataFactory', () => {
executionId: 'parent-123', executionId: 'parent-123',
workflowId: 'workflow-456', workflowId: 'workflow-456',
}, },
validateSignature: true, resumeToken: 'custom-token-for-test',
waitTill: new Date('2023-01-01'), waitTill: new Date('2023-01-01'),
} satisfies CreateFullRunExecutionDataOptions; } satisfies CreateFullRunExecutionDataOptions;
@ -75,7 +75,7 @@ describe('RunExecutionDataFactory', () => {
); );
expect(result.executionData?.runtimeData).toEqual(options.executionData.runtimeData); expect(result.executionData?.runtimeData).toEqual(options.executionData.runtimeData);
expect(result.parentExecution).toEqual(options.parentExecution); expect(result.parentExecution).toEqual(options.parentExecution);
expect(result.validateSignature).toBe(true); expect(result.resumeToken).toBe('custom-token-for-test');
expect(result.waitTill).toEqual(options.waitTill); expect(result.waitTill).toEqual(options.waitTill);
}); });

View File

@ -24,7 +24,7 @@ describe('migrateRunExecutionData', () => {
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: null, waitingExecutionSource: null,
}, },
validateSignature: true, resumeToken: 'test-token-123',
pushRef: 'test-ref', pushRef: 'test-ref',
}; };
@ -74,7 +74,7 @@ describe('migrateRunExecutionData', () => {
waitingExecution: {}, waitingExecution: {},
waitingExecutionSource: null, waitingExecutionSource: null,
}, },
validateSignature: true, resumeToken: 'test-token-123',
pushRef: 'test-ref', pushRef: 'test-ref',
}; };