From 5b6ee17c81add7105f5522ac45981cf9a08894b2 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Mon, 23 Mar 2026 12:48:52 +0100 Subject: [PATCH] feat(core): Add signature validation for waiting webhooks and forms (#24159) Co-authored-by: Michael Kret <88898367+michael-radency@users.noreply.github.com> --- .../__tests__/js-task-runner.test.ts | 4 +- .../src/js-task-runner/__tests__/test-data.ts | 1 + .../workflow-execute-additional-data.test.ts | 8 +- .../__tests__/test-runner.service.ee.test.ts | 29 +- .../data-request-response-builder.test.ts | 8 +- .../data-request-response-stripper.test.ts | 17 + .../data-request-response-builder.ts | 1 + .../data-request-response-stripper.ts | 1 + .../webhooks/__tests__/waiting-forms.test.ts | 211 +++++++++++- .../__tests__/waiting-webhooks.test.ts | 316 +++++++++++++----- packages/cli/src/webhooks/waiting-forms.ts | 91 ++--- packages/cli/src/webhooks/waiting-webhooks.ts | 151 ++++++--- packages/cli/src/webhooks/webhook-helpers.ts | 8 +- .../workflow-execution.service.test.ts | 63 ++-- .../templates/form-invalid-token.handlebars | 131 ++++++++ .../form-trigger-completion.handlebars | 27 +- .../cli/templates/form-trigger.handlebars | 26 +- ...-task-runner-execution.integration.test.ts | 5 +- .../core/nodes-testing/node-test-harness.ts | 1 + .../__tests__/execution-context.test.ts | 5 +- .../__tests__/routing-node.test.ts | 5 +- ...process-process-run-execution-data.test.ts | 2 + .../__tests__/workflow-execute.test.ts | 85 ++++- .../__tests__/execute-context.test.ts | 6 +- .../__tests__/execute-single-context.test.ts | 6 +- .../__tests__/node-execution-context.test.ts | 33 +- .../__tests__/supply-data-context.test.ts | 14 +- .../node-execution-context.ts | 17 +- .../__tests__/get-additional-keys.test.ts | 34 +- .../utils/get-additional-keys.ts | 17 +- .../src/execution-engine/workflow-execute.ts | 2 + .../utils/__tests__/signature-helpers.test.ts | 26 -- packages/core/src/utils/signature-helpers.ts | 38 ++- packages/core/test/helpers/index.ts | 2 + .../src/app/stores/workflows.store.test.ts | 132 +++++++- .../src/app/stores/workflows.store.ts | 17 +- .../executions/executions.utils.test.ts | 48 +++ .../execution/executions/executions.utils.ts | 26 +- .../logs/components/LogsViewRunData.vue | 6 +- .../ndv/panel/components/InputPanel.vue | 15 +- .../ndv/panel/components/OutputPanel.vue | 2 +- packages/nodes-base/nodes/Form/Form.node.ts | 5 + .../nodes-base/nodes/Github/Github.node.ts | 3 + packages/nodes-base/nodes/Wait/Wait.node.ts | 27 +- .../nodes-base/utils/sendAndWait/utils.ts | 1 - .../testing/playwright/pages/CanvasPage.ts | 3 + .../tests/e2e/api/wait-form-resume.spec.ts | 96 ++++++ .../e2e/api/waiting-endpoint-security.spec.ts | 142 ++++++++ .../tests/e2e/nodes/send-and-wait.spec.ts | 270 +++++++++++++++ .../editor/subworkflows/wait.spec.ts | 44 ++- .../workflows/send-and-wait-approval.json | 126 +++++++ .../workflows/send-and-wait-form.json | 130 +++++++ .../workflows/wait-form-resume.json | 124 +++++++ .../workflows/wait-webhook-resume.json | 115 +++++++ packages/workflow/src/index.ts | 1 + packages/workflow/src/interfaces.ts | 14 +- .../src/run-execution-data-factory.ts | 54 +-- .../run-execution-data.v0.ts | 6 +- .../run-execution-data.v1.ts | 6 +- packages/workflow/src/utils.ts | 7 + .../test/run-execution-data-factory.test.ts | 6 +- .../run-execution-data.test.ts | 4 +- 62 files changed, 2408 insertions(+), 413 deletions(-) create mode 100644 packages/cli/templates/form-invalid-token.handlebars delete mode 100644 packages/core/src/utils/__tests__/signature-helpers.test.ts create mode 100644 packages/testing/playwright/tests/e2e/api/wait-form-resume.spec.ts create mode 100644 packages/testing/playwright/tests/e2e/api/waiting-endpoint-security.spec.ts create mode 100644 packages/testing/playwright/tests/e2e/nodes/send-and-wait.spec.ts create mode 100644 packages/testing/playwright/workflows/send-and-wait-approval.json create mode 100644 packages/testing/playwright/workflows/send-and-wait-form.json create mode 100644 packages/testing/playwright/workflows/wait-form-resume.json create mode 100644 packages/testing/playwright/workflows/wait-webhook-resume.json diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 4eebc11d0ae..20595802171 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -388,8 +388,8 @@ describe('JsTaskRunner', () => { { id: 'exec-id', mode: 'test', - resumeFormUrl: 'http://formWaitingBaseUrl/exec-id', - resumeUrl: 'http://webhookWaitingBaseUrl/exec-id', + resumeFormUrl: 'http://formwaitingbaseurl/exec-id?signature=test-resume-token', + resumeUrl: 'http://webhookwaitingbaseurl/exec-id?signature=test-resume-token', customData: { get: expect.any(Function), getAll: expect.any(Function), diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts index 814db39ce29..fa75b19925e 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/test-data.ts @@ -90,6 +90,7 @@ export const newDataRequestResponse = ( }, node: codeNode, runExecutionData: createRunExecutionData({ + resumeToken: 'test-resume-token', resultData: { runData: { [manualTriggerNode.name]: [ diff --git a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts index 86622bb0dbc..a79b629e357 100644 --- a/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts +++ b/packages/cli/src/__tests__/workflow-execute-additional-data.test.ts @@ -352,8 +352,10 @@ describe('WorkflowExecuteAdditionalData', () => { }); it('should return default data', () => { - expect(getRunData(workflow)).toEqual({ + const result = getRunData(workflow); + expect(result).toEqual({ executionData: createRunExecutionData({ + resumeToken: result.executionData?.resumeToken, executionData: { contextData: {}, metadata: {}, @@ -388,8 +390,10 @@ describe('WorkflowExecuteAdditionalData', () => { executionId: '123', workflowId: '567', }; - expect(getRunData(workflow, data, parentExecution)).toEqual({ + const result = getRunData(workflow, data, parentExecution); + expect(result).toEqual({ executionData: createRunExecutionData({ + resumeToken: result.executionData?.resumeToken, executionData: { contextData: {}, metadata: {}, diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts index 5e72e34679f..a2783bb5cfa 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts @@ -890,21 +890,24 @@ describe('TestRunnerService', () => { triggerToStartFrom: { name: triggerNodeName, }, - executionData: createRunExecutionData({ - executionData: null, - resultData: { - pinData: { - [triggerNodeName]: [testCase], + executionData: { + ...createRunExecutionData({ + executionData: null, + resultData: { + pinData: { + [triggerNodeName]: [testCase], + }, + runData: {}, }, - runData: {}, - }, - manualData: { - userId: metadata.userId, - triggerToStartFrom: { - name: triggerNodeName, + manualData: { + userId: metadata.userId, + triggerToStartFrom: { + name: triggerNodeName, + }, }, - }, - }), + }), + resumeToken: expect.any(String), + }, }), ); }); diff --git a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts index 15632cf2c74..dafd558f5a1 100644 --- a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-builder.test.ts @@ -59,6 +59,7 @@ const metadata = { }; const runExecutionData = mock({ + resumeToken: 'test-resume-token-preserved', executionData: { contextData, metadata, @@ -128,8 +129,8 @@ describe('DataRequestResponseBuilder', () => { it('clears nodeExecutionStack, waitingExecution and waitingExecutionSource from runExecutionData', () => { const result = builder.buildFromTaskData(taskData); - expect(result.runExecutionData).toStrictEqual( - createRunExecutionData({ + expect(result.runExecutionData).toStrictEqual({ + ...createRunExecutionData({ startData: runExecutionData.startData, resultData: runExecutionData.resultData, executionData: { @@ -140,6 +141,7 @@ describe('DataRequestResponseBuilder', () => { waitingExecutionSource: null, }, }), - ); + resumeToken: 'test-resume-token-preserved', + }); }); }); diff --git a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts index 739f5e7e543..6b8e9e99950 100644 --- a/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts +++ b/packages/cli/src/task-runners/task-managers/__tests__/data-request-response-stripper.test.ts @@ -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', () => { it("should filter out envProviderState when it's not requested", () => { const dataRequestResponseBuilder = new DataRequestResponseStripper( diff --git a/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts b/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts index eccd2fd3202..385d87aea61 100644 --- a/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts +++ b/packages/cli/src/task-runners/task-managers/data-request-response-builder.ts @@ -82,6 +82,7 @@ export class DataRequestResponseBuilder { waitingExecutionSource: null, } : undefined, + resumeToken: runExecutionData.resumeToken, }); } } diff --git a/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts b/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts index 38f438bb5c9..81efe65f4d6 100644 --- a/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts +++ b/packages/cli/src/task-runners/task-managers/data-request-response-stripper.ts @@ -57,6 +57,7 @@ export class DataRequestResponseStripper { // TODO: We could send `runExecutionData.contextData` only if requested, // since it's only needed if $input.context or $("node").context is used. executionData: runExecutionData.executionData, + resumeToken: runExecutionData.resumeToken, }); } diff --git a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts index d71d2fb33aa..fe48bb11e6d 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-forms.test.ts @@ -1,22 +1,42 @@ import type { IExecutionResponse, ExecutionRepository } from '@n8n/db'; import type express from 'express'; import { mock } from 'jest-mock-extended'; -import { getWebhookSandboxCSP } from 'n8n-core'; -import { FORM_NODE_TYPE, WAITING_FORMS_EXECUTION_STATUS, type Workflow } from 'n8n-workflow'; +import type { InstanceSettings } from 'n8n-core'; +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 { WaitingForms } from '@/webhooks/waiting-forms'; class TestWaitingForms extends WaitingForms { - exposeGetWorkflow(execution: IExecutionResponse): Workflow { - return this.getWorkflow(execution); + exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow { + return this.createWorkflow(workflowData); + } + + exposeValidateToken( + req: express.Request, + execution: IExecutionResponse, + ): { valid: boolean; webhookPath?: string } { + return this.validateToken(req, execution); } } describe('WaitingForms', () => { const executionRepository = mock(); - const waitingForms = new TestWaitingForms(mock(), mock(), executionRepository, mock(), mock()); + const mockInstanceSettings = mock(); + const waitingForms = new TestWaitingForms( + mock(), + mock(), + executionRepository, + mock(), + mockInstanceSettings, + ); beforeEach(() => { jest.restoreAllMocks(); @@ -302,6 +322,7 @@ describe('WaitingForms', () => { runData: {}, error: undefined, }, + resumeToken: undefined, // Old execution without token - skip validation }, workflowData: { id: 'workflow1', @@ -328,11 +349,12 @@ describe('WaitingForms', () => { executionRepository.findSingleExecution.mockResolvedValue(execution); const req = mock({ - headers: { origin: 'null' }, + headers: { origin: 'null', host: 'localhost:5678' }, params: { path: '123', suffix: undefined, }, + url: '/form-waiting/123', }); const res = mock(); @@ -362,7 +384,7 @@ describe('WaitingForms', () => { }, }); - const workflow = waitingForms.exposeGetWorkflow(execution); + const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData); 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); }); @@ -406,7 +428,7 @@ describe('WaitingForms', () => { }, }); - const workflow = waitingForms.exposeGetWorkflow(execution); + const workflow = waitingForms.exposeCreateWorkflow(execution.workflowData); 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); }); @@ -445,6 +467,7 @@ describe('WaitingForms', () => { runData: {}, error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted }, + resumeToken: undefined, // Old execution without token }, workflowData: { id: 'workflow1', @@ -471,11 +494,12 @@ describe('WaitingForms', () => { executionRepository.findSingleExecution.mockResolvedValue(execution); const req = mock({ - headers: {}, + headers: { host: 'localhost:5678' }, params: { path: '123', suffix: undefined, }, + url: '/form-waiting/123', }); const res = mock(); @@ -501,6 +525,7 @@ describe('WaitingForms', () => { runData: {}, error: undefined, // Must be explicitly set to undefined; jest-mock-extended returns a truthy mock if omitted }, + resumeToken: undefined, // Old execution without token }, workflowData: { id: 'workflow1', @@ -527,11 +552,12 @@ describe('WaitingForms', () => { executionRepository.findSingleExecution.mockResolvedValue(execution); const req = mock({ - headers: {}, + headers: { host: 'localhost:5678' }, params: { path: '123', suffix: undefined, }, + url: '/form-waiting/123', }); const res = mock(); @@ -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({ + 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({ + 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({ + 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({ + 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({ + headers: { host: 'localhost:5678' }, + params: { + path: '123', + suffix: undefined, + }, + url: '/form-waiting/123', // No token, but validation is skipped + }); + + const res = mock(); + + // 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({ + url: fullUrl, + headers: { host: opts.host ?? 'localhost:5678' }, + }); + }; + + const createMockExecution = (token: string) => + mock({ + 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 }); + }); + }); }); diff --git a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts index 472614fb7b6..35075b93d79 100644 --- a/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/waiting-webhooks.test.ts @@ -2,8 +2,9 @@ import type { IExecutionResponse, ExecutionRepository } from '@n8n/db'; import type express from 'express'; import { mock } from 'jest-mock-extended'; 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 { SEND_AND_WAIT_OPERATION } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; @@ -17,15 +18,24 @@ class TestWaitingWebhooks extends WaitingWebhooks { exposeCreateWorkflow(workflowData: IWorkflowBase): Workflow { 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', () => { - const SIGNING_SECRET = 'test-secret'; + const TEST_HMAC_SECRET = 'test-hmac-secret-key'; const executionRepository = mock(); const mockWebhookService = mock(); - const mockInstanceSettings = mock({ - hmacSignatureSecret: SIGNING_SECRET, - }); + const mockInstanceSettings = mock({ hmacSignatureSecret: TEST_HMAC_SECRET }); const waitingWebhooks = new TestWaitingWebhooks( mock(), mock(), @@ -110,110 +120,245 @@ describe('WaitingWebhooks', () => { }); }); - describe('validateSignatureInRequest', () => { + describe('validateSignature', () => { 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 }) => - mock({ - url: `/webhook/test?${WAITING_TOKEN_QUERY_PARAM}=` + opts.signature, - host: opts.host ?? EXAMPLE_HOST, - query: { [WAITING_TOKEN_QUERY_PARAM]: opts.signature }, + // Helper to generate proper HMAC signature for a URL + const generateTestSignature = (urlPath: string) => { + const crypto = require('crypto'); + return crypto.createHmac('sha256', TEST_HMAC_SECRET).update(urlPath).digest('hex'); + }; + + 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({ + url: fullUrl, headers: { host: opts.host ?? EXAMPLE_HOST }, }); + }; - it('should validate signature correctly', () => { + it('should validate signature correctly when HMAC matches', () => { /* Arrange */ - const signature = generateValidSignature(); - const mockReq = createMockRequest({ signature }); + const urlPath = '/webhook/test'; + const validSignature = generateTestSignature(urlPath); + const mockReq = createMockRequest({ signature: validSignature, path: urlPath }); /* Act */ - const result = waitingWebhooks.validateSignatureInRequest(mockReq); + const result = waitingWebhooks.exposeValidateSignature(mockReq); /* Assert */ - expect(result).toBe(true); - }); - - 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({ - 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); + expect(result).toEqual({ valid: true, webhookPath: undefined }); }); it('should return false when signature is missing', () => { /* Arrange */ - const mockReq = mock({ - url: '/webhook/test', - hostname: 'example.com', - query: {}, - }); + const mockReq = createMockRequest({}); /* Act */ - const result = waitingWebhooks.validateSignatureInRequest(mockReq); + const result = waitingWebhooks.exposeValidateSignature(mockReq); /* 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 */ - const mockReq = mock({ - 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 wrongSignature = 'b'.repeat(64); const mockReq = createMockRequest({ signature: wrongSignature }); /* Act */ - const result = waitingWebhooks.validateSignatureInRequest(mockReq); + const result = waitingWebhooks.exposeValidateSignature(mockReq); /* 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({ + 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({ + 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({ + status: mockStatus, + render: mockRender, + json: mockJson, + }); + // Request has wrong signature + const req = mock({ + 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({ + 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({ + status: mockStatus, + render: mockRender, + json: mockJson, + }); + // Request has wrong signature + const req = mock({ + 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 mockExecution.data.resultData.error = undefined; + mockExecution.data.resumeToken = undefined; executionRepository.findSingleExecution.mockResolvedValue(mockExecution); @@ -500,6 +646,7 @@ describe('WaitingWebhooks', () => { // Explicitly set error to undefined to avoid mock function mockExecution.data.resultData.error = undefined; + mockExecution.data.resumeToken = undefined; executionRepository.findSingleExecution.mockResolvedValue(mockExecution); @@ -636,6 +783,7 @@ describe('WaitingWebhooks', () => { // Explicitly set error to undefined to avoid mock function mockExecution.data.resultData.error = undefined; + mockExecution.data.resumeToken = undefined; executionRepository.findSingleExecution.mockResolvedValue(mockExecution); @@ -772,6 +920,7 @@ describe('WaitingWebhooks', () => { // Explicitly set error to undefined to avoid mock function mockExecution.data.resultData.error = undefined; + mockExecution.data.resumeToken = undefined; executionRepository.findSingleExecution.mockResolvedValue(mockExecution); @@ -903,6 +1052,7 @@ describe('WaitingWebhooks', () => { // Explicitly set error to undefined to avoid mock function mockExecution.data.resultData.error = undefined; + mockExecution.data.resumeToken = undefined; executionRepository.findSingleExecution.mockResolvedValue(mockExecution); diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index b3c66060e92..ad33cf770c0 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -11,7 +11,6 @@ import { } from 'n8n-workflow'; import { ConflictError } from '@/errors/response-errors/conflict.error'; -import { getWorkflowActiveStatusFromWorkflowData } from '@/executions/execution.utils'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; 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) { const parentNodes = workflow.getParentNodes(lastNodeExecuted); const lastNode = workflow.nodes[lastNodeExecuted]; @@ -74,7 +59,7 @@ export class WaitingForms extends WaitingWebhooks { req: WaitingWebhookRequest, res: express.Response, ): Promise { - const { path: executionId, suffix } = req.params; + const { path: executionId, suffix: routeSuffix } = req.params; this.logReceivedWebhook(req.method, executionId); @@ -85,25 +70,22 @@ export class WaitingForms extends WaitingWebhooks { const execution = await this.getExecution(executionId); - if (suffix === WAITING_FORMS_EXECUTION_STATUS) { - 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'; - } + // Validate token for forms (backwards compat: skip for old executions without resumeToken) + let webhookPath: string | undefined; + if (execution?.data.resumeToken) { + const result = this.validateToken(req, execution); + if (!result.valid) { + res.status(401).render('form-invalid-token'); + return { noWebhookResponse: true }; } - - applyCors(req, res); - - res.send(status); - return { noWebhookResponse: true }; + webhookPath = result.webhookPath; } + const suffix = routeSuffix ?? webhookPath; + + const statusResult = this.handleStatusRequest(execution, suffix, req, res); + if (statusResult) return statusResult; + if (!execution) { 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; if (execution.finished) { - // find the completion page to render - // if there is no completion page, render the default page - const workflow = this.getWorkflow(execution); - + const workflow = this.createWorkflow(execution.workflowData); const completionPage = this.findCompletionPage( workflow, execution.data.resultData.runData, @@ -138,13 +117,10 @@ export class WaitingForms extends WaitingWebhooks { message: 'Your response has been recorded', formTitle: 'Form Submitted', }); - - return { - noWebhookResponse: true, - }; - } else { - lastNodeExecuted = completionPage; + return { noWebhookResponse: true }; } + + lastNodeExecuted = completionPage; } applyCors(req, res); @@ -158,4 +134,35 @@ export class WaitingForms extends WaitingWebhooks { 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 }; + } } diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index 4734a451168..568645ae792 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -2,29 +2,22 @@ import { Logger } from '@n8n/backend-common'; import type { IExecutionResponse } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db'; import { Service } from '@n8n/di'; -import crypto from 'crypto'; +import { timingSafeEqual } from 'crypto'; import type express from 'express'; +import { InstanceSettings, WAITING_TOKEN_QUERY_PARAM, validateUrlSignature } from 'n8n-core'; import { - InstanceSettings, - WAITING_TOKEN_QUERY_PARAM, - prepareUrlForSigning, - generateUrlSignature, -} from 'n8n-core'; -import { - FORM_NODE_TYPE, type INodes, type IWorkflowBase, NodeConnectionTypes, SEND_AND_WAIT_OPERATION, - WAIT_NODE_TYPE, Workflow, } from 'n8n-workflow'; import { sanitizeWebhookRequest } from './webhook-request-sanitizer'; import { WebhookService } from './webhook.service'; import type { - IWebhookResponseCallbackData, IWebhookManager, + IWebhookResponseCallbackData, WaitingWebhookRequest, } from './webhook.types'; @@ -51,7 +44,7 @@ export class WaitingWebhooks implements IWebhookManager { protected readonly nodeTypes: NodeTypes, private readonly executionRepository: ExecutionRepository, private readonly webhookService: WebhookService, - private readonly instanceSettings: InstanceSettings, + protected readonly instanceSettings: InstanceSettings, ) {} // TODO: implement `getWebhookMethods` for CORS support @@ -103,36 +96,84 @@ export class WaitingWebhooks implements IWebhookManager { }); } - validateSignatureInRequest(req: express.Request) { - try { - const actualToken = req.query[WAITING_TOKEN_QUERY_PARAM]; + /** + * Extracts the `signature` query param and an optional webhook path + * 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; - - // req.host is set correctly even when n8n is behind a reverse proxy - // as long as N8N_PROXY_HOPS is set correctly - const parsedUrl = new URL(req.url, `http://${req.host}`); - parsedUrl.searchParams.delete(WAITING_TOKEN_QUERY_PARAM); - - 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; + // Handle backwards compat: extract webhook path if appended after the token + // e.g., ?signature=abc123/my-suffix -> token is "abc123", webhookPath is "my-suffix" + if (token?.includes('/')) { + const slashIndex = token.indexOf('/'); + webhookPath = token.slice(slashIndex + 1); + token = token.slice(0, slashIndex); } + + 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( req: WaitingWebhookRequest, res: express.Response, ): Promise { - const { path: executionId, suffix } = req.params; + const { path: executionId } = req.params; + let { suffix } = req.params; this.logReceivedWebhook(req.method, executionId); @@ -143,15 +184,31 @@ export class WaitingWebhooks implements IWebhookManager { const execution = await this.getExecution(executionId); - if (execution?.data.validateSignature) { - const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; - const lastNode = execution.workflowData.nodes.find((node) => node.name === lastNodeExecuted); - const shouldValidate = lastNode?.parameters.operation === SEND_AND_WAIT_OPERATION; + // Only validate for executions that have a resumeToken. + // Old executions created before token validation are skipped (backwards compat). + if (execution?.data.resumeToken) { + const { workflowData } = execution; + const { nodes } = this.createWorkflow(workflowData); + const isSendAndWait = this.isSendAndWaitRequest(nodes, suffix); - if (shouldValidate && !this.validateSignatureInRequest(req)) { - res.status(401).json({ error: 'Invalid token' }); + // Send-and-wait uses HMAC to protect tamper-sensitive query params (e.g. approved=true). + // 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 }; } + // Use webhook path parsed from token if not in route (backwards compat for old URL format) + if (!suffix && webhookPath) { + suffix = webhookPath; + } } if (!execution) { @@ -259,22 +316,6 @@ export class WaitingWebhooks implements IWebhookManager { 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); } diff --git a/packages/cli/src/webhooks/webhook-helpers.ts b/packages/cli/src/webhooks/webhook-helpers.ts index 8135868fdbe..44ccd9e45f2 100644 --- a/packages/cli/src/webhooks/webhook-helpers.ts +++ b/packages/cli/src/webhooks/webhook-helpers.ts @@ -10,7 +10,7 @@ import type { Project } from '@n8n/db'; import { ExecutionRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import type express from 'express'; -import { BinaryDataService, ErrorReporter } from 'n8n-core'; +import { BinaryDataService, ErrorReporter, WAITING_TOKEN_QUERY_PARAM } from 'n8n-core'; import type { IBinaryData, IDataObject, @@ -767,7 +767,11 @@ export async function executeWebhook( }); 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()); didSendResponse = true; } diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index a8a09317309..07a6f0b8d68 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -772,39 +772,42 @@ describe('WorkflowExecutionService', () => { expect(workflowRunnerMock.run).toHaveBeenCalledTimes(1); expect(workflowRunnerMock.run).toHaveBeenCalledWith({ executionMode: 'error', - executionData: createRunExecutionData({ - executionData: { - contextData: {}, - metadata: {}, - nodeExecutionStack: [ - { - node: errorTriggerNode, - data: { - main: [ - [ - { - json: workflowErrorData, - }, + executionData: { + ...createRunExecutionData({ + executionData: { + contextData: {}, + metadata: {}, + nodeExecutionStack: [ + { + node: errorTriggerNode, + data: { + main: [ + [ + { + json: workflowErrorData, + }, + ], ], - ], - }, - source: null, - metadata: { - parentExecution: { - executionId: 'execution-id', - workflowId: 'workflow-id', + }, + source: null, + metadata: { + parentExecution: { + executionId: 'execution-id', + workflowId: 'workflow-id', + }, }, }, - }, - ], - waitingExecution: {}, - waitingExecutionSource: {}, - }, - resultData: { - runData: {}, - }, - startData: {}, - }), + ], + waitingExecution: {}, + waitingExecutionSource: {}, + }, + resultData: { + runData: {}, + }, + startData: {}, + }), + resumeToken: expect.any(String), + }, workflowData: errorWorkflow, projectId: 'project-id', projectName: 'Error Project', diff --git a/packages/cli/templates/form-invalid-token.handlebars b/packages/cli/templates/form-invalid-token.handlebars new file mode 100644 index 00000000000..2cc8bc0971a --- /dev/null +++ b/packages/cli/templates/form-invalid-token.handlebars @@ -0,0 +1,131 @@ + + + + + + + + + Invalid Form Link + + + + +
+
+
+
+

Invalid Form Link

+

This form link is invalid or has expired. Please request a new link.

+
+
+ +
+
+ + + diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars index a375d95902e..522f10345b6 100644 --- a/packages/cli/templates/form-trigger-completion.handlebars +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -158,17 +158,32 @@ {{/if}}