From cae5e093d3613f4affdf95aa37563b83353d6b72 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 08:16:39 +0300 Subject: [PATCH] chore: Bundle 2.x (#37586) Co-authored-by: Matsu --- .../__tests__/utils/http-proxy-agent.test.ts | 126 ++- packages/@n8n/ai-utilities/src/index.ts | 2 + .../src/utils/http-proxy-agent.ts | 84 +- .../instance-ai-confirm-request.dto.test.ts | 8 + .../instance-ai-confirm-request.dto.ts | 7 + packages/@n8n/api-types/src/index.ts | 5 + .../__tests__/agent-run-reducer.test.ts | 33 + .../__tests__/instance-ai.schema.test.ts | 33 + .../src/schemas/agent-run-reducer.ts | 30 +- .../src/schemas/instance-ai.schema.ts | 71 +- .../@n8n/backend-network/src/ssrf/index.ts | 1 + .../src/ssrf/passthrough-egress-filter.ts | 16 + .../config/src/configs/security.config.ts | 2 +- packages/@n8n/config/test/config.test.ts | 2 +- packages/@n8n/constants/src/nodes.ts | 5 +- .../shared-workflow.repository.ts | 18 + .../db/src/repositories/user.repository.ts | 29 + .../src/__tests__/integration.test.ts | 16 + .../src/bridge/isolated-vm-bridge.ts | 6 +- .../src/bridge/quickjs-bridge.ts | 8 +- .../__tests__/array-extensions.test.ts | 134 +++ .../__tests__/function-extensions.test.ts | 23 + .../__tests__/object-extensions.test.ts | 44 + .../src/extensions/array-extensions.ts | 80 +- .../src/extensions/function-extensions.ts | 12 +- .../src/extensions/object-extensions.ts | 24 +- .../credentials/__tests__/seeder.test.ts | 2 + .../evaluations/credentials/seeder.ts | 1 + .../__tests__/confirmation-payload.test.ts | 10 + .../src/runtime/confirmation-payload.ts | 6 + .../src/runtime/run-state-registry.ts | 6 +- .../src/stream/__tests__/map-chunk.test.ts | 12 + .../@n8n/instance-ai/src/stream/map-chunk.ts | 6 + .../tools/__tests__/credentials.tool.test.ts | 13 +- .../tools/__tests__/workflows.tool.test.ts | 257 ++++- .../instance-ai/src/tools/credentials.tool.ts | 37 +- .../instance-ai/src/tools/workflows.tool.ts | 200 +++- .../__tests__/credential-utils.test.ts | 14 + .../__tests__/setup-workflow.service.test.ts | 9 +- .../__tests__/summarize-workflow.test.ts | 77 ++ .../src/tools/workflows/credential-utils.ts | 13 +- .../tools/workflows/setup-workflow.schema.ts | 2 + .../tools/workflows/setup-workflow.service.ts | 17 +- .../src/tools/workflows/summarize-workflow.ts | 10 +- .../nodes/ModelSelector/ModelSelector.node.ts | 21 +- .../test/ModelSelector.headers.test.ts | 85 ++ .../OpenAiAssistant/OpenAiAssistant.node.ts | 11 +- .../EmbeddingsOllama/EmbeddingsOllama.node.ts | 18 +- .../test/EmbeddingsOllama.node.test.ts | 126 +++ .../EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts | 11 +- .../methods/__tests__/searchModels.test.ts | 34 +- .../LMChatAnthropic/methods/searchModels.ts | 3 +- .../llms/LMChatOllama/LmChatOllama.node.ts | 14 +- .../test/LmChatOllama.node.test.ts | 125 +++ .../llms/LMChatOpenAi/LmChatOpenAi.node.ts | 10 +- .../methods/__tests__/loadModels.test.ts | 114 ++- .../llms/LMChatOpenAi/methods/loadModels.ts | 13 +- .../nodes/llms/LMOllama/LmOllama.node.ts | 16 +- .../llms/LMOllama/test/LmOllama.node.test.ts | 125 +++ .../nodes/llms/LMOpenAi/LmOpenAi.node.ts | 24 +- .../LmChatMistralCloud.node.ts | 3 +- .../nodes/llms/N8nNonEstimatingTracing.ts | 13 +- .../nodes/llms/test/LmChatAnthropic.test.ts | 21 +- .../llms/test/N8nNonEstimatingTracing.test.ts | 89 ++ .../__test__/McpClientTool.node.test.ts | 62 +- .../nodes/mcp/shared/__test__/utils.test.ts | 33 +- .../nodes/mcp/shared/runtime.test.ts | 18 + .../nodes-langchain/nodes/mcp/shared/utils.ts | 22 +- .../__tests__/microsoft-utils.test.ts | 96 +- .../vendors/Microsoft/microsoft-utils.ts | 27 +- .../OpenAi/helpers/credentials.test.ts | 95 ++ .../vendors/OpenAi/helpers/credentials.ts | 30 + .../v1/actions/assistant/message.operation.ts | 10 +- .../src/roles/custom-role-scopes.ee.ts | 4 +- .../@n8n/tournament/src/ExpressionBuilder.ts | 30 +- .../@n8n/tournament/src/VariablePolyfill.ts | 22 +- .../tournament/test/VariablePolyfill.test.ts | 49 + .../src/ast-interpreter/errors.ts | 15 + .../workflow-sdk/src/ast-interpreter/index.ts | 1 + .../src/ast-interpreter/interpreter.test.ts | 283 ++++++ .../src/ast-interpreter/interpreter.ts | 394 ++++++-- .../src/ast-interpreter/validators.ts | 1 + .../src/codegen/parse-workflow-code.test.ts | 20 + .../__tests__/active-workflow-manager.test.ts | 232 ++++- packages/cli/src/active-workflow-manager.ts | 71 +- .../__tests__/chat-execution-manager.test.ts | 281 +++++- ...chat-resume-node-types.integration.test.ts | 216 +++++ .../src/chat/__tests__/chat-service.test.ts | 10 +- packages/cli/src/chat/__tests__/utils.test.ts | 168 +++- .../cli/src/chat/chat-execution-manager.ts | 131 ++- packages/cli/src/chat/chat-service.ts | 55 +- packages/cli/src/chat/utils.ts | 43 +- .../__tests__/role.controller.test.ts | 97 +- .../cli/src/controllers/role.controller.ts | 36 +- .../__tests__/credentials.service.test.ts | 34 +- .../src/credentials/credentials.service.ts | 33 +- .../errors/subworkflow-policy-denial.error.ts | 39 +- ...ution-recovery.service.integration.test.ts | 78 ++ .../executions/execution-recovery.service.ts | 16 +- .../subworkflow-policy-checker.test.ts | 46 + .../subworkflow-policy-checker.ts | 73 +- .../__tests__/workflow-tool-factory.test.ts | 2 + .../agent-runtime-reconstruction.service.ts | 6 +- .../__tests__/workflow-tool-factory.test.ts | 16 + .../agents/tools/workflow-tool-factory.ts | 6 +- .../chat-hub.service.integration.test.ts | 151 +++ .../src/modules/chat-hub/chat-hub.service.ts | 17 + ...mmunity-packages.lifecycle.service.test.ts | 26 + .../community-packages.service.test.ts | 19 + .../community-packages.service.ts | 9 +- .../eval/__tests__/execution.service.test.ts | 43 + .../instance-ai/eval/execution.service.ts | 7 +- .../log-streaming.controller.test.ts | 96 +- .../destination-credentials-access.test.ts | 62 ++ .../destination-credentials-access.ts | 36 + .../log-streaming.controller.ts | 25 +- .../mcp/tools/workflow-builder/constants.ts | 3 + .../create-workflow-from-code.tool.ts | 9 +- .../validate-workflow-code.tool.ts | 5 +- .../__tests__/oauth-server.service.test.ts | 120 +++ .../oauth-server/oauth-server.service.ts | 61 ++ .../oauth-server/oauth-session.service.ts | 8 +- .../__tests__/source-control.service.test.ts | 187 +++- .../source-control.service.ee.ts | 86 +- .../__tests__/oidc.controller.ee.test.ts | 25 + .../__tests__/oidc.service.ee.test.ts | 39 + .../modules/sso-oidc/oidc.controller.ee.ts | 1 + .../src/modules/sso-oidc/oidc.service.ee.ts | 8 + .../log-streaming/log-streaming.handler.ts | 29 +- packages/cli/src/push/abstract.push.ts | 3 +- packages/cli/src/server.ts | 67 +- .../active-workflows.service.test.ts | 48 +- .../credentials-tester.service.test.ts | 19 + .../src/services/active-workflows.service.ts | 30 +- .../services/credentials-tester.service.ts | 12 +- .../task-broker-auth.service.test.ts | 20 + .../auth/task-broker-auth.service.ts | 6 +- .../src/utils/__tests__/request-path.test.ts | 94 ++ packages/cli/src/utils/request-path.ts | 69 ++ .../slack-interaction-webhooks.test.ts | 25 +- .../telegram-interaction-webhooks.test.ts | 38 + .../webhook-request-sanitizer.test.ts | 30 + .../webhook-response-headers.test.ts | 72 ++ .../src/webhooks/hitl-interaction-webhooks.ts | 11 +- .../webhooks/slack-interaction-webhooks.ts | 2 + .../webhooks/telegram-interaction-webhooks.ts | 2 + .../src/webhooks/webhook-request-sanitizer.ts | 27 +- .../src/webhooks/webhook-response-headers.ts | 21 +- .../workflow-push-notifier.service.test.ts | 42 + .../workflow-sharing.service.test.ts | 111 +++ .../publication-status-reporter.test.ts | 149 ++- .../publication-status-reporter.ts | 31 +- .../workflow-push-notifier.service.ts | 20 + .../src/workflows/workflow-sharing.service.ts | 47 +- .../access-control/instance-roles.test.ts | 22 +- .../controllers/role.controller-db.test.ts | 118 ++- .../log-streaming.controller.test.ts | 69 +- .../policy/content-import.policy.test.ts | 398 -------- .../integration/server-static-assets.test.ts | 277 ++++++ .../services/active-workflows.service.test.ts | 48 + .../workflow-sharing.service.test.ts | 39 + .../file-system-helper-functions.test.ts | 20 +- .../utils/__tests__/ssrf-integration.test.ts | 8 +- .../utils/request-helpers/factory.ts | 4 +- .../frontend/@n8n/i18n/src/locales/en.json | 7 + .../InstanceAiConfirmationPanel.test.ts | 66 +- .../__tests__/instanceAi.liveRunState.test.ts | 18 + .../instanceAi.threadRuntime.test.ts | 30 +- .../InstanceAiConfirmationPanel.vue | 48 +- .../ai/instanceAi/instanceAi.liveRunState.ts | 1 + .../ai/instanceAi/instanceAi.threadRuntime.ts | 1 + .../__tests__/useCredentialForm.test.ts | 2 + .../composables/useCredentialForm.ts | 1 + .../roles/project/ProjectRoleView.vue | 6 +- .../roles/project/RoleAssignmentsTab.test.ts | 45 + .../roles/project/RoleAssignmentsTab.vue | 124 ++- .../HttpTemplatedCustomAuth.credentials.ts | 8 + ...ttpTemplatedCustomAuth.credentials.test.ts | 42 + .../ElasticSecurity/ElasticSecurity.node.ts | 39 +- .../ElasticSecurity/GenericFunctions.ts | 4 +- .../tests/GenericFunctions.test.ts | 10 + .../Elasticsearch/Elasticsearch.node.ts | 35 +- .../Elastic/Elasticsearch/GenericFunctions.ts | 14 +- .../tests/GenericFunctions.test.ts | 31 +- .../nodes-base/nodes/Git/GenericFunctions.ts | 220 ++++- packages/nodes-base/nodes/Git/Git.node.ts | 186 +++- .../Git/__test__/GenericFunctions.test.ts | 347 ++++++- .../nodes/Git/test/Git.node.security.test.ts | 845 ++++++++++++++++- .../nodes/Git/test/Git.node.test.ts | 184 +++- .../nodes/Github/GithubTrigger.node.ts | 127 ++- .../nodes/Github/GithubTriggerHelpers.ts | 13 +- .../__tests__/GithubTriggerHelpers.test.ts | 15 +- .../__tests__/node/GithubTrigger.node.test.ts | 426 ++++++++- .../nodes/Kafka/test/Kafka.node.test.ts | 16 +- .../Kafka/test/KafkaTrigger.node.test.ts | 22 +- .../nodes-base/nodes/Kafka/test/utils.test.ts | 28 +- .../Kafka/test/v2/KafkaTriggerV2.node.test.ts | 4 +- .../nodes/Kafka/test/v2/KafkaV2.node.test.ts | 16 +- packages/nodes-base/nodes/Kafka/utils.ts | 4 - .../nodes-base/test/nodes/TriggerHelpers.ts | 4 +- packages/nodes-base/utils/url.test.ts | 32 + packages/nodes-base/utils/url.ts | 19 + .../workflow/src/expression-sandboxing.ts | 53 ++ .../src/extensions/array-extensions.ts | 80 +- .../src/extensions/expression-extension.ts | 9 +- .../src/extensions/extended-functions.ts | 12 +- .../src/extensions/object-extensions.ts | 24 +- packages/workflow/src/interfaces.ts | 6 +- .../array-extensions.test.ts | 133 +++ .../expression-extension.test.ts | 23 + .../object-extensions.test.ts | 31 + .../test/expression-compatibility.test.ts | 890 ++++++++++++++++++ packages/workflow/test/expression.test.ts | 156 +++ 213 files changed, 11228 insertions(+), 1588 deletions(-) create mode 100644 packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts create mode 100644 packages/@n8n/expression-runtime/src/extensions/__tests__/array-extensions.test.ts create mode 100644 packages/@n8n/expression-runtime/src/extensions/__tests__/function-extensions.test.ts create mode 100644 packages/@n8n/expression-runtime/src/extensions/__tests__/object-extensions.test.ts create mode 100644 packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.headers.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/llms/test/N8nNonEstimatingTracing.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts create mode 100644 packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts create mode 100644 packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts create mode 100644 packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts create mode 100644 packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts create mode 100644 packages/cli/src/utils/__tests__/request-path.test.ts create mode 100644 packages/cli/src/utils/request-path.ts create mode 100644 packages/cli/src/workflows/__tests__/workflow-push-notifier.service.test.ts create mode 100644 packages/cli/src/workflows/__tests__/workflow-sharing.service.test.ts create mode 100644 packages/cli/src/workflows/workflow-push-notifier.service.ts delete mode 100644 packages/cli/test/integration/policy/content-import.policy.test.ts create mode 100644 packages/cli/test/integration/server-static-assets.test.ts create mode 100644 packages/cli/test/integration/services/active-workflows.service.test.ts create mode 100644 packages/nodes-base/credentials/test/HttpTemplatedCustomAuth.credentials.test.ts create mode 100644 packages/nodes-base/nodes/Elastic/ElasticSecurity/tests/GenericFunctions.test.ts create mode 100644 packages/nodes-base/utils/url.test.ts create mode 100644 packages/nodes-base/utils/url.ts create mode 100644 packages/workflow/test/expression-compatibility.test.ts diff --git a/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts b/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts index 9728497be6a..6577db7ba6d 100644 --- a/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts +++ b/packages/@n8n/ai-utilities/src/__tests__/utils/http-proxy-agent.test.ts @@ -1,3 +1,4 @@ +import { lookup as dnsLookup } from 'node:dns'; import { Agent, ProxyAgent, fetch as undiciFetch } from 'undici'; import type { MockedFunction } from 'vitest'; @@ -14,6 +15,12 @@ vi.mock('undici', () => ({ fetch: vi.fn(), })); +const DEFAULT_AGENT_OPTIONS = { + headersTimeout: 3600000, + bodyTimeout: 3600000, + connect: { lookup: dnsLookup }, +}; + describe('getProxyAgent', () => { // Store original environment variables const originalEnv = { ...process.env }; @@ -36,20 +43,33 @@ describe('getProxyAgent', () => { process.env = originalEnv; }); - describe('backward compatible behavior (no timeout options)', () => { - it('should return undefined when no proxy environment variables are set and no timeout options', () => { + describe('default behavior (no timeout options)', () => { + it('should return an Agent with the default lookup when no proxy environment variables are set and no timeout options', () => { const agent = getProxyAgent(); - expect(agent).toBeUndefined(); + + expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(ProxyAgent).not.toHaveBeenCalled(); - expect(Agent).not.toHaveBeenCalled(); }); - it('should return undefined when no proxy is configured for target URL and no timeout options', () => { + it('should return an Agent when no proxy is configured for target URL and no timeout options', () => { const agent = getProxyAgent('https://api.openai.com/v1'); - expect(agent).toBeUndefined(); + expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(ProxyAgent).not.toHaveBeenCalled(); - expect(Agent).not.toHaveBeenCalled(); + }); + + it('should reuse a single Agent across calls when no proxy, timeout options nor custom lookup are given', () => { + const first = getProxyAgent('https://api.openai.com/v1'); + const second = getProxyAgent('https://api.anthropic.com/v1'); + + expect(second).toBe(first); + }); + + it('should build a fresh Agent when timeout options or a custom lookup are given', () => { + const shared = getProxyAgent('https://api.openai.com/v1'); + + expect(getProxyAgent('https://api.openai.com/v1', {})).not.toBe(shared); + expect(getProxyAgent('https://api.openai.com/v1', undefined, vi.fn())).not.toBe(shared); }); it('should create ProxyAgent with default timeouts when HTTPS_PROXY is set', () => { @@ -137,7 +157,7 @@ describe('getProxyAgent', () => { const agent = getProxyAgent('http://localhost:3000'); - expect(agent).toBeUndefined(); + expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(ProxyAgent).not.toHaveBeenCalled(); }); @@ -148,7 +168,7 @@ describe('getProxyAgent', () => { const agent = getProxyAgent('https://api.internal.company.com'); - expect(agent).toBeUndefined(); + expect(agent).toEqual({ type: 'Agent', options: DEFAULT_AGENT_OPTIONS }); expect(ProxyAgent).not.toHaveBeenCalled(); }); @@ -199,20 +219,18 @@ describe('getProxyAgent', () => { expect(Agent).toHaveBeenCalledWith({ headersTimeout: 120000, bodyTimeout: 180000, + connect: { lookup: dnsLookup }, }); expect(agent).toEqual({ type: 'Agent', - options: { headersTimeout: 120000, bodyTimeout: 180000 }, + options: { headersTimeout: 120000, bodyTimeout: 180000, connect: { lookup: dnsLookup } }, }); }); it('should use default timeouts when empty timeout options object is passed', () => { getProxyAgent('https://api.openai.com/v1', {}); - expect(Agent).toHaveBeenCalledWith({ - headersTimeout: 3600000, - bodyTimeout: 3600000, - }); + expect(Agent).toHaveBeenCalledWith(DEFAULT_AGENT_OPTIONS); }); it('should include connectTimeout when provided', () => { @@ -226,6 +244,7 @@ describe('getProxyAgent', () => { headersTimeout: 60000, bodyTimeout: 60000, connectTimeout: 30000, + connect: { lookup: dnsLookup }, }); }); @@ -243,7 +262,8 @@ describe('getProxyAgent', () => { expect(Agent).toHaveBeenCalled(); }); - it('should return an Agent instead of undefined when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => { + it('should build a fresh Agent when N8N_AI_TIMEOUT_MAX is set, even without a proxy or explicit timeout options', () => { + const shared = getProxyAgent('https://api.openai.com/v1'); process.env.N8N_AI_TIMEOUT_MAX = '120000'; const agent = getProxyAgent('https://api.openai.com/v1'); @@ -251,8 +271,8 @@ describe('getProxyAgent', () => { // DEFAULT_TIMEOUT was captured from the env at module load time (before this test set it), // so the value here reflects that capture, not '120000' — the module-reset test below // covers the env value actually being picked up end to end. - expect(Agent).toHaveBeenCalled(); expect(agent).toEqual(expect.objectContaining({ type: 'Agent' })); + expect(agent).not.toBe(shared); expect(ProxyAgent).not.toHaveBeenCalled(); }); @@ -268,10 +288,15 @@ describe('getProxyAgent', () => { expect(undici.Agent).toHaveBeenCalledWith({ headersTimeout: 120000, bodyTimeout: 120000, + connect: { lookup: dnsLookup }, }); expect(agent).toEqual({ type: 'Agent', - options: { headersTimeout: 120000, bodyTimeout: 120000 }, + options: { + headersTimeout: 120000, + bodyTimeout: 120000, + connect: { lookup: dnsLookup }, + }, }); }); }); @@ -335,18 +360,39 @@ describe('proxyFetch', () => { }); describe('with no proxy configured', () => { - it('should call fetch with undefined dispatcher when no proxy is set and no timeout options', async () => { + it('should call fetch with an Agent dispatcher when no proxy is set and no timeout options', async () => { const url = 'https://api.openai.com/v1'; - await proxyFetch(url); + await proxyFetch({ input: url, lookup: dnsLookup }); expect(mockFetch).toHaveBeenCalledWith(url, { - dispatcher: undefined, + dispatcher: expect.objectContaining({ type: 'Agent' }), }); }); + it('should reuse the same dispatcher across plain calls', async () => { + await proxyFetch({ input: 'https://api.openai.com/v1', lookup: dnsLookup }); + await proxyFetch({ input: 'https://api.anthropic.com/v1', lookup: dnsLookup }); + + const [[, first], [, second]] = mockFetch.mock.calls as unknown as Array< + [unknown, { dispatcher: unknown }] + >; + expect(second.dispatcher).toBe(first.dispatcher); + }); + + it('should build the dispatcher with the supplied lookup', async () => { + const lookup = vi.fn(); + await proxyFetch({ input: 'https://api.openai.com/v1', lookup }); + + expect(Agent).toHaveBeenCalledWith(expect.objectContaining({ connect: { lookup } })); + }); + it('should call fetch with Agent dispatcher when timeout options are provided', async () => { const url = 'https://api.openai.com/v1'; - await proxyFetch(url, undefined, { headersTimeout: 60000 }); + await proxyFetch({ + input: url, + timeoutOptions: { headersTimeout: 60000 }, + lookup: dnsLookup, + }); expect(Agent).toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledWith(url, { @@ -362,20 +408,20 @@ describe('proxyFetch', () => { body: JSON.stringify({ test: 'data' }), }; - await proxyFetch(url, init); + await proxyFetch({ input: url, init, lookup: dnsLookup }); expect(mockFetch).toHaveBeenCalledWith(url, { ...init, - dispatcher: undefined, + dispatcher: expect.objectContaining({ type: 'Agent' }), }); }); it('should handle URL objects', async () => { const url = new URL('https://api.openai.com/v1'); - await proxyFetch(url); + await proxyFetch({ input: url, lookup: dnsLookup }); expect(mockFetch).toHaveBeenCalledWith(url, { - dispatcher: undefined, + dispatcher: expect.objectContaining({ type: 'Agent' }), }); }); @@ -385,10 +431,10 @@ describe('proxyFetch', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ test: 'data' }), }); - await proxyFetch(request); + await proxyFetch({ input: request, lookup: dnsLookup }); expect(mockFetch).toHaveBeenCalledWith(request, { - dispatcher: undefined, + dispatcher: expect.objectContaining({ type: 'Agent' }), }); }); }); @@ -399,7 +445,7 @@ describe('proxyFetch', () => { process.env.HTTPS_PROXY = proxyUrl; const url = 'https://api.openai.com/v1'; - await proxyFetch(url); + await proxyFetch({ input: url, lookup: dnsLookup }); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockFetch).toHaveBeenCalledWith(url, { @@ -417,7 +463,7 @@ describe('proxyFetch', () => { headers: { Authorization: 'Bearer token123' }, }; - await proxyFetch(url, init); + await proxyFetch({ input: url, init, lookup: dnsLookup }); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockFetch).toHaveBeenCalledWith(url, { @@ -431,7 +477,7 @@ describe('proxyFetch', () => { process.env.HTTP_PROXY = proxyUrl; const url = new URL('http://api.example.com/data'); - await proxyFetch(url); + await proxyFetch({ input: url, lookup: dnsLookup }); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockFetch).toHaveBeenCalledWith(url, { @@ -444,7 +490,7 @@ describe('proxyFetch', () => { process.env.HTTPS_PROXY = proxyUrl; const request = new Request('https://api.openai.com/v1'); - await proxyFetch(request); + await proxyFetch({ input: request, lookup: dnsLookup }); expect(ProxyAgent).toHaveBeenCalledWith(expect.objectContaining({ uri: proxyUrl })); expect(mockFetch).toHaveBeenCalledWith(request, { @@ -458,11 +504,12 @@ describe('proxyFetch', () => { process.env.NO_PROXY = 'localhost,127.0.0.1'; const url = 'https://localhost:3000/api'; - await proxyFetch(url); + await proxyFetch({ input: url, lookup: dnsLookup }); // Should not create ProxyAgent for localhost + expect(ProxyAgent).not.toHaveBeenCalled(); expect(mockFetch).toHaveBeenCalledWith(url, { - dispatcher: undefined, + dispatcher: expect.objectContaining({ type: 'Agent' }), }); }); @@ -471,7 +518,11 @@ describe('proxyFetch', () => { process.env.HTTPS_PROXY = proxyUrl; const url = 'https://api.openai.com/v1'; - await proxyFetch(url, undefined, { headersTimeout: 300000, bodyTimeout: 300000 }); + await proxyFetch({ + input: url, + timeoutOptions: { headersTimeout: 300000, bodyTimeout: 300000 }, + lookup: dnsLookup, + }); expect(ProxyAgent).toHaveBeenCalledWith({ uri: proxyUrl, @@ -490,7 +541,7 @@ describe('proxyFetch', () => { mockFetch.mockResolvedValueOnce(expectedResponse); const url = 'https://api.openai.com/v1'; - const result = await proxyFetch(url); + const result = await proxyFetch({ input: url, lookup: dnsLookup }); expect(result).toBe(expectedResponse); }); @@ -501,7 +552,7 @@ describe('proxyFetch', () => { const url = 'https://api.openai.com/v1'; - await expect(proxyFetch(url)).rejects.toThrow('Network error'); + await expect(proxyFetch({ input: url, lookup: dnsLookup })).rejects.toThrow('Network error'); }); it('should return error responses without throwing', async () => { @@ -512,10 +563,9 @@ describe('proxyFetch', () => { mockFetch.mockResolvedValueOnce(errorResponse); const url = 'https://api.openai.com/v1'; - const result = await proxyFetch(url); + const result = await proxyFetch({ input: url, lookup: dnsLookup }); expect(result).toBe(errorResponse); - expect(result.status).toBe(404); }); }); }); diff --git a/packages/@n8n/ai-utilities/src/index.ts b/packages/@n8n/ai-utilities/src/index.ts index 74a3c9d044b..cf8dac6ecb0 100644 --- a/packages/@n8n/ai-utilities/src/index.ts +++ b/packages/@n8n/ai-utilities/src/index.ts @@ -15,6 +15,7 @@ export { N8nBinaryLoader } from './utils/n8n-binary-loader'; export { N8nJsonLoader } from './utils/n8n-json-loader'; export { N8nPdfLoader } from './utils/loaders/n8n-pdf-loader'; export { N8nLlmTracing } from './utils/n8n-llm-tracing'; +export { redactHeaderValues } from './utils/redact-headers'; export { TextEditorDocument, NoMatchFoundError, @@ -55,6 +56,7 @@ export { getNodeProxyAgent, proxyFetch, type AgentTimeoutOptions, + type ProxyFetchOptions, } from './utils/http-proxy-agent'; export { braveSearch, searxngSearch, type BraveSearchOptions } from './web-search'; export type { WebSearchOptions, WebSearchResponse, WebSearchResult } from './web-search'; diff --git a/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts b/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts index ca87e657b17..7b97c28844c 100644 --- a/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts +++ b/packages/@n8n/ai-utilities/src/utils/http-proxy-agent.ts @@ -17,6 +17,7 @@ * and left these runner-side ones in place). */ import { createHttpsProxyAgent, resolveProxyUrl } from '@n8n/backend-network/proxy'; // `@n8n/backend-network/proxy` is a DI-free subpath: it pulls in only the proxy-agent libs +import { lookup as defaultLookup } from 'node:dns'; import type { AgentOptions } from 'node:https'; import type { LookupFunction } from 'node:net'; /* eslint-disable n8n-local-rules/no-uncentralized-http -- raw dispatchers for AI SDK `fetchOptions`; see block comment above */ @@ -43,30 +44,33 @@ const DEFAULT_TIMEOUT = parseInt(process.env.N8N_AI_TIMEOUT_MAX ?? '3600000', 10 */ const PROXY_FALLBACK_TARGET = 'https://example.nonexistent/'; +/** + * Agent shared by every caller that needs no proxy, no timeout overrides and the + * plain system DNS lookup, so those callers keep a single connection pool instead + * of building an agent (and a pool) per request. + */ +let sharedAgent: Agent | undefined; + /** * Returns an undici Agent or ProxyAgent with configured timeouts based on the environment variables and target URL. * When target URL is not provided, NO_PROXY environment variable is not respected. * * @param targetUrl - The target URL to check proxy configuration for (optional) - * @param timeoutOptions - Optional timeout configuration to override defaults. When provided, - * always returns an Agent/ProxyAgent (even without proxy) to ensure timeouts are applied. - * @param lookup - Optional DNS lookup to pin the resolved address at connect time (e.g. an egress - * filter's secure lookup). When provided (without a proxy) an Agent is always returned. - * @returns An Agent (no proxy with timeout options, a lookup, or `N8N_AI_TIMEOUT_MAX` set) or ProxyAgent - * (with proxy) configured with timeouts, or undefined if no proxy, timeout options, lookup, nor - * `N8N_AI_TIMEOUT_MAX` are provided/set (backward compatible behavior). + * @param timeoutOptions - Optional timeout configuration to override defaults. + * @param lookup - Connect-time DNS lookup, applied to every Agent this builds. + * Defaults to the plain system lookup. + * @returns A ProxyAgent when a proxy applies to the target, otherwise an Agent. Callers using the + * defaults (no timeout options, no `N8N_AI_TIMEOUT_MAX`, plain lookup) share one Agent. * * @remarks - * When timeoutOptions are provided, this function always returns an agent to ensure timeouts are properly configured. - * The default undici timeouts (5 minutes) are too short for many AI operations. - * When timeoutOptions are NOT provided, this still returns an agent if `N8N_AI_TIMEOUT_MAX` is set, - * so the env override isn't silently ignored just because no proxy is configured. Otherwise, returns - * undefined if no proxy is configured (backward compatible). + * The default undici timeouts (5 minutes) are too short for many AI operations, so an agent is + * always returned rather than falling back to undici's global dispatcher. `N8N_AI_TIMEOUT_MAX` + * is honoured even when no proxy and no explicit timeout options are configured. */ export function getProxyAgent( targetUrl?: string, timeoutOptions?: AgentTimeoutOptions, - lookup?: LookupFunction, + lookup: LookupFunction = defaultLookup, ) { const proxyUrl = resolveProxyUrl(targetUrl, PROXY_FALLBACK_TARGET); @@ -78,37 +82,45 @@ export function getProxyAgent( }), }; - if (!proxyUrl) { - if (lookup) { - return new Agent({ ...agentOptions, connect: { lookup } }); - } - if (timeoutOptions) { - return new Agent(agentOptions); - } - if (process.env.N8N_AI_TIMEOUT_MAX) { - return new Agent(agentOptions); - } - return undefined; + if (proxyUrl) { + return new ProxyAgent({ uri: proxyUrl, ...agentOptions }); } - return new ProxyAgent({ uri: proxyUrl, ...agentOptions }); + const isDefaultCase = + !timeoutOptions && !process.env.N8N_AI_TIMEOUT_MAX && lookup === defaultLookup; + + if (isDefaultCase) { + sharedAgent ??= new Agent({ ...agentOptions, connect: { lookup } }); + return sharedAgent; + } + + return new Agent({ ...agentOptions, connect: { lookup } }); +} + +/** + * Options for {@link proxyFetch}. + */ +export interface ProxyFetchOptions { + /** The URL to fetch */ + input: RequestInfo | URL; + /** Standard fetch RequestInit options */ + init?: RequestInit; + /** Optional timeout configuration to override defaults */ + timeoutOptions?: AgentTimeoutOptions; + /** Connect-time DNS lookup, e.g. an egress filter's secure lookup */ + lookup: LookupFunction; } /** * Make a fetch() request with an Agent/ProxyAgent that has configured timeouts. * If proxy environment variables are set, uses ProxyAgent; otherwise uses Agent. - * - * @param input - The URL to fetch - * @param init - Standard fetch RequestInit options - * @param timeoutOptions - Optional timeout configuration to override defaults - * @param lookup - Optional connect-time DNS lookup (e.g. an egress filter's secure lookup) */ -export async function proxyFetch( - input: RequestInfo | URL, - init?: RequestInit, - timeoutOptions?: AgentTimeoutOptions, - lookup?: LookupFunction, -): Promise { +export async function proxyFetch({ + input, + init, + timeoutOptions, + lookup, +}: ProxyFetchOptions): Promise { const targetUrl = input instanceof Request ? input.url : input.toString(); const dispatcher = getProxyAgent(targetUrl, timeoutOptions, lookup); diff --git a/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-confirm-request.dto.test.ts b/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-confirm-request.dto.test.ts index 7e5f3d50e31..d72c9dde5de 100644 --- a/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-confirm-request.dto.test.ts +++ b/packages/@n8n/api-types/src/dto/instance-ai/__tests__/instance-ai-confirm-request.dto.test.ts @@ -63,6 +63,14 @@ describe('InstanceAiConfirmRequestDto', () => { 'credentialAutoSetup with attempt id', { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'attempt-1' }, ], + [ + 'credential destination approval', + { + kind: 'credentialDestination', + approved: true, + origin: 'https://api.example.com', + }, + ], // DomainAccessApproval: handleAction (primary path — with action) [ 'domainAccessApprove with allow_domain', diff --git a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts index 39ca205abe7..43a39b7c933 100644 --- a/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts +++ b/packages/@n8n/api-types/src/dto/instance-ai/instance-ai-confirm-request.dto.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { + credentialDestinationDecisionSchema, domainAccessActionSchema, instanceAiApprovalResumeSchema, instanceGatewayResourceDecisionSchema, @@ -49,6 +50,11 @@ const credentialAutoSetupConfirmSchema = z.object({ attemptId: z.string().trim().min(1).max(64).optional(), }); +const credentialDestinationConfirmSchema = credentialDestinationDecisionSchema.extend({ + kind: z.literal('credentialDestination'), + approved: z.boolean(), +}); + /** Domain-access approval — `domainAccessAction` carries which scope the user picked. */ const domainAccessApproveSchema = z.object({ kind: z.literal('domainAccessApprove'), @@ -106,6 +112,7 @@ export const InstanceAiConfirmRequestDto = z.discriminatedUnion('kind', [ questionsConfirmSchema, credentialSelectionConfirmSchema, credentialAutoSetupConfirmSchema, + credentialDestinationConfirmSchema, domainAccessApproveSchema, domainAccessDenySchema, planDenySchema, diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index d2bb62adf92..c5391b55b03 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -306,6 +306,7 @@ export { export { buildRunWorkflowSessionGrantKey, buildUpdateWorkflowSessionGrantKey, + buildCredentialDestinationGrantKey, buildDataTablesSessionGrantKey, buildSetupSkipGrantKey, parseSetupSkipGrants, @@ -369,6 +370,8 @@ export { instanceAiApprovalResumeSchema, webSearchMetaSchema, credentialFlowSchema, + credentialDestinationSchema, + credentialDestinationDecisionSchema, instanceAiCredentialHandoffContextSchema, instanceAiAgentPreviewHandoffContextSchema, instanceAiHandoffContextSchema, @@ -538,6 +541,8 @@ export type { DomainAccessMeta, WebSearchMeta, InstanceAiCredentialFlow, + InstanceAiCredentialDestination, + InstanceAiCredentialDestinationDecision, InstanceAiCredentialHandoffContext, InstanceAiAgentPreviewHandoffContext, InstanceAiHandoffContext, diff --git a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts index cc7c718760c..153d7927712 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/agent-run-reducer.test.ts @@ -778,6 +778,39 @@ describe('agent-run-reducer', () => { }); }); + it('confirmation-request passes through credential destination metadata when present', () => { + const state = stateWithRun('run-1', 'root'); + reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'workflows')); + reduceEvent(state, { + type: 'confirmation-request', + runId: 'run-1', + agentId: 'root', + payload: { + requestId: 'req-destination', + toolCallId: 'tc-1', + toolName: 'workflows', + args: { action: 'setup' }, + severity: 'warning', + message: 'Review where this credential will be used', + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch data'], + }, + }, + }); + + const tc = state.toolCallsById['tc-1']; + expect(tc.confirmation).toEqual({ + requestId: 'req-destination', + severity: 'warning', + message: 'Review where this credential will be used', + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch data'], + }, + }); + }); + it('confirmation-request passes through projectId when present', () => { const state = stateWithRun('run-1', 'root'); reduceEvent(state, makeToolCall('run-1', 'root', 'tc-1', 'setup-credentials')); diff --git a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts index e24d8792196..ca1e567e6c5 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/instance-ai.schema.test.ts @@ -10,6 +10,7 @@ import { instanceAiFileAttachmentSchema, MAX_ATTACHMENT_BASE64_BYTES, applyBranchReadOnlyOverrides, + buildCredentialDestinationGrantKey, buildDataTablesSessionGrantKey, buildUpdateWorkflowSessionGrantKey, buildSetupSkipGrantKey, @@ -280,6 +281,30 @@ describe('confirmationRequestPayloadSchema', () => { expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); }); + + it('preserves a credential destination requiring approval', () => { + const payload = makeConfirmation({ + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, + }); + + expect(confirmationRequestPayloadSchema.parse(payload)).toEqual(payload); + }); + + it('requires a credential destination to be an exact HTTP origin', () => { + const result = confirmationRequestPayloadSchema.safeParse( + makeConfirmation({ + credentialDestination: { + origin: 'https://api.example.com/v1', + nodeNames: ['Fetch account'], + }, + }), + ); + + expect(result.success).toBe(false); + }); }); describe('isDisplayableConfirmationRequest', () => { @@ -515,6 +540,14 @@ describe('workflow update session grant keys', () => { }); }); +describe('credential destination session grant keys', () => { + it('encodes the workflow and exact origin', () => { + expect(buildCredentialDestinationGrantKey('workflow:1', 'https://api.example.com:8443')).toBe( + 'credential-destination:workflow%3A1:https%3A%2F%2Fapi.example.com%3A8443', + ); + }); +}); + describe('workflow-setup skip keys', () => { it('round-trips credential types and ignores unrelated keys', () => { const keys = new Set([ diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts index 2fe93eea562..7945d39ca51 100644 --- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts +++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts @@ -471,29 +471,13 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent if (!isSafeObjectKey(event.payload.toolCallId)) break; const tc = state.toolCallsById[event.payload.toolCallId]; if (tc) { - tc.confirmation = { - requestId: event.payload.requestId, - inputThreadId: event.payload.inputThreadId, - severity: event.payload.severity, - message: event.payload.message, - targetApproval: event.payload.targetApproval, - credentialRequests: event.payload.credentialRequests, - requireUserSelection: event.payload.requireUserSelection, - projectId: event.payload.projectId, - inputType: event.payload.inputType, - domainAccess: event.payload.domainAccess, - webSearch: event.payload.webSearch, - credentialFlow: event.payload.credentialFlow, - setupRequests: event.payload.setupRequests, - workflowId: event.payload.workflowId, - planItems: event.payload.planItems, - questions: event.payload.questions, - introMessage: event.payload.introMessage, - tasks: event.payload.tasks, - resourceDecision: event.payload.resourceDecision, - channelConfig: event.payload.channelConfig, - mcpConnectRequest: event.payload.mcpConnectRequest, - }; + const { + toolCallId: _toolCallId, + toolName: _toolName, + args: _args, + ...confirmation + } = event.payload; + tc.confirmation = confirmation; } break; } diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index f7924b708cc..ce73119976d 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -64,6 +64,11 @@ export function buildUpdateWorkflowSessionGrantKey(workflowId: string): string { return `workflows:update:${workflowId}`; } +/** Builds the thread-level grant for using a credential with one exact service origin. */ +export function buildCredentialDestinationGrantKey(workflowId: string, origin: string): string { + return `credential-destination:${encodeURIComponent(workflowId)}:${encodeURIComponent(origin)}`; +} + /** * Builds the thread-level "always allow" grant key for a data-tables action * (e.g. `create`, `insert-rows`). Must match the frontend key @@ -413,6 +418,18 @@ export const credentialPlaceholderDefSchema = z.object({ }); export type InstanceAiCredentialPlaceholderDef = z.infer; +const exactHttpOriginSchema = z + .string() + .max(300) + .refine((value) => { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol) && url.origin === value; + } catch { + return false; + } + }, 'Expected an exact HTTP origin'); + /** * Agent-supplied recipe for creating a Templated Custom Auth credential: the * auth request parts with `{{placeholder}}` markers where user-provided values @@ -439,6 +456,9 @@ export const credentialSetupHintSchema = z.object({ * being set up (never model-supplied). Stamped into the created credential * so setup surfaces only offer it to nodes calling the same service. */ serviceHost: z.string().optional(), + /** Exact origin of the API the recipe targets, derived server-side from the + * node being set up. Used to bind automatic credential tests to that API. */ + serviceOrigin: exactHttpOriginSchema.optional(), }); export type InstanceAiCredentialSetupHint = z.infer; @@ -458,6 +478,19 @@ export const credentialFlowSchema = z.object({ }); export type InstanceAiCredentialFlow = z.infer; +export const credentialDestinationSchema = z.object({ + origin: exactHttpOriginSchema, + nodeNames: z.array(z.string().trim().min(1).max(255)).min(1), +}); +export type InstanceAiCredentialDestination = z.infer; + +export const credentialDestinationDecisionSchema = credentialDestinationSchema.pick({ + origin: true, +}); +export type InstanceAiCredentialDestinationDecision = z.infer< + typeof credentialDestinationDecisionSchema +>; + export const workflowSetupNodeSchema = z.object({ node: z.object({ name: z.string(), @@ -719,6 +752,11 @@ export const confirmationRequestPayloadSchema = z.object({ .describe( 'Credential flow stage — finalize renders post-verification credential picker with different copy', ), + credentialDestination: credentialDestinationSchema + .optional() + .describe( + 'Exact destination that must be approved before a workflow credential setup card opens', + ), setupRequests: z .array(workflowSetupNodeSchema) .optional() @@ -1428,35 +1466,10 @@ export interface InstanceAiSendMessageResponse { // Frontend store types (shared so both sides agree on structure) // --------------------------------------------------------------------------- -export interface InstanceAiConfirmation { - requestId: string; - inputThreadId?: string; - severity: InstanceAiConfirmationSeverity; - message: string; - targetApproval?: InstanceAiTargetApproval; - credentialRequests?: InstanceAiCredentialRequest[]; - requireUserSelection?: boolean; - projectId?: string; - inputType?: 'approval' | 'text' | 'questions' | 'plan-review' | 'resource-decision' | 'continue'; - domainAccess?: DomainAccessMeta; - webSearch?: WebSearchMeta; - credentialFlow?: InstanceAiCredentialFlow; - setupRequests?: InstanceAiWorkflowSetupNode[]; - workflowId?: string; - planItems?: PlannedTaskArg[]; - questions?: Array<{ - id: string; - question: string; - type: 'single' | 'multi' | 'text'; - options?: string[]; - }>; - introMessage?: string; - tasks?: TaskList; - resourceDecision?: GatewayConfirmationRequiredPayload; - channelConfig?: InstanceAiChannelConfig; - mcpConnectRequest?: InstanceAiMcpConnectRequest; - expired?: boolean; -} +export type InstanceAiConfirmation = Omit< + InstanceAiConfirmationRequestPayload, + 'toolCallId' | 'toolName' | 'args' +> & { expired?: boolean }; export interface InstanceAiToolCallState { toolCallId: string; diff --git a/packages/@n8n/backend-network/src/ssrf/index.ts b/packages/@n8n/backend-network/src/ssrf/index.ts index b018b815b35..8ba08f84db4 100644 --- a/packages/@n8n/backend-network/src/ssrf/index.ts +++ b/packages/@n8n/backend-network/src/ssrf/index.ts @@ -2,3 +2,4 @@ export { SsrfProtectionService } from './ssrf-protection.service'; export type { SsrfBridge } from './ssrf-protection.service'; export { SsrfBlockedIpError } from './ssrf-blocked-ip.error'; export { SsrfBlockedHostnameError } from './ssrf-blocked-hostname.error'; +export { passthroughEgressFilter } from './passthrough-egress-filter'; diff --git a/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts b/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts new file mode 100644 index 00000000000..2f604dbebc6 --- /dev/null +++ b/packages/@n8n/backend-network/src/ssrf/passthrough-egress-filter.ts @@ -0,0 +1,16 @@ +import { createResultOk } from '@n8n/utils/result'; +import type { NodeEgressFilter } from 'n8n-workflow'; +import { lookup } from 'node:dns'; +import type { LookupFunction } from 'node:net'; + +/** + * Passthrough `NodeEgressFilter` used when no egress policy is configured. + * `validateUrl` always resolves ok, and `createSecureLookup` returns the + * plain system DNS lookup, so callers get an unfiltered transport without + * having to special-case a missing filter. + */ +export const passthroughEgressFilter: NodeEgressFilter = { + validateUrl: async () => await Promise.resolve(createResultOk(undefined)), + // Same function reference every call, so callers can compare it by identity. + createSecureLookup: (): LookupFunction => lookup, +}; diff --git a/packages/@n8n/config/src/configs/security.config.ts b/packages/@n8n/config/src/configs/security.config.ts index 1ed8d5e8b71..97bc0d85632 100644 --- a/packages/@n8n/config/src/configs/security.config.ts +++ b/packages/@n8n/config/src/configs/security.config.ts @@ -61,7 +61,7 @@ export class SecurityConfig { * Separate multiple patterns with semicolons. Default blocks `.git`. Set to empty to disable pattern-based blocking. */ @Env('N8N_BLOCK_FILE_PATTERNS') - blockFilePatterns: string = '^(.*\\/)*\\.git(\\/.*)*$'; + blockFilePatterns: string = '^(?:[^/]*/)*\\.git(?:/.*)?$'; /** * In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed. diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index bf0e3ff17ec..b4fd5798789 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -507,7 +507,7 @@ describe('GlobalConfig', () => { security: { restrictFileAccessTo: '~/.n8n-files', blockFileAccessToN8nFiles: true, - blockFilePatterns: '^(.*\\/)*\\.git(\\/.*)*$', + blockFilePatterns: '^(?:[^/]*/)*\\.git(?:/.*)?$', daysAbandonedWorkflow: 90, contentSecurityPolicy: undefined, contentSecurityPolicyReportOnly: DEFAULT_CONTENT_SECURITY_POLICY, diff --git a/packages/@n8n/constants/src/nodes.ts b/packages/@n8n/constants/src/nodes.ts index 4f76bc95be2..a4a1868048a 100644 --- a/packages/@n8n/constants/src/nodes.ts +++ b/packages/@n8n/constants/src/nodes.ts @@ -1,2 +1,5 @@ -/** Packages whose node type definitions are generated to disk at build time. */ +/** + * Packages whose node type definitions are generated to disk at build time. + * These names are also reserved and cannot be installed as community packages. + */ export const BUILTIN_NODES_PACKAGES = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'] as const; diff --git a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts index 04ef439eab1..7314de8af6f 100644 --- a/packages/@n8n/db/src/repositories/shared-workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/shared-workflow.repository.ts @@ -170,6 +170,24 @@ export class SharedWorkflowRepository extends BaseRepository { return [...new Set(projectIds)]; } + /** + * Find the IDs of all the projects where a workflow is shared with one of + * the given sharing roles. + */ + async findProjectIdsByRole(workflowId: string, roles: string[]) { + const rows = await this.find({ + where: { workflowId, role: In(roles) }, + select: ['projectId'], + }); + + const projectIds = rows.reduce((acc, row) => { + if (row.projectId) acc.push(row.projectId); + return acc; + }, []); + + return [...new Set(projectIds)]; + } + /** * Pass `ctx` when calling from inside a transaction — the read then runs on that * transaction's connection instead of checking out a second one, which would diff --git a/packages/@n8n/db/src/repositories/user.repository.ts b/packages/@n8n/db/src/repositories/user.repository.ts index 7b4c6e63271..c45c8097e39 100644 --- a/packages/@n8n/db/src/repositories/user.repository.ts +++ b/packages/@n8n/db/src/repositories/user.repository.ts @@ -226,6 +226,35 @@ export class UserRepository extends Repository { }); } + /** + * IDs of enabled users who either hold one of `globalRoleSlugs` globally, + * or hold one of `projectRoleSlugs` in one of `projectIds`. + */ + async findIdsWithGlobalOrProjectRoles({ + projectIds, + projectRoleSlugs, + globalRoleSlugs, + }: { + projectIds: string[]; + projectRoleSlugs: string[]; + globalRoleSlugs: string[]; + }): Promise { + const where: Array> = []; + if (globalRoleSlugs.length > 0) { + where.push({ disabled: false, role: { slug: In(globalRoleSlugs) } }); + } + if (projectIds.length > 0 && projectRoleSlugs.length > 0) { + where.push({ + disabled: false, + projectRelations: { projectId: In(projectIds), role: { slug: In(projectRoleSlugs) } }, + }); + } + if (where.length === 0) return []; + + const users = await this.find({ where, select: ['id'] }); + return [...new Set(users.map(({ id }) => id))]; + } + /** * Find the user that owns the personal project that owns the workflow. * diff --git a/packages/@n8n/expression-runtime/src/__tests__/integration.test.ts b/packages/@n8n/expression-runtime/src/__tests__/integration.test.ts index ffb8c35f3ed..dcbeb6c7e62 100644 --- a/packages/@n8n/expression-runtime/src/__tests__/integration.test.ts +++ b/packages/@n8n/expression-runtime/src/__tests__/integration.test.ts @@ -146,6 +146,22 @@ describe(`Integration: ExpressionEvaluator (${engineName})`, () => { expect(result).toBe(150); }); + it('should render the timezone independent of the global JSON.stringify', async () => { + const data = { $json: { x: 'ok' } }; + const original = JSON.stringify; + let result: unknown; + try { + JSON.stringify = (() => 'REPLACED_STRINGIFY_OUTPUT') as unknown as typeof JSON.stringify; + result = evaluator.evaluate('{{ $json.x }}', data, caller, { + timezone: 'America/New_York', + }); + } finally { + JSON.stringify = original; + } + + expect(result).toBe('ok'); + }); + it('should use provided timezone for DateTime operations', async () => { const data = { $json: { ts: 1704067200000 }, // 2024-01-01T00:00:00Z diff --git a/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts b/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts index fd12b9fcd1e..60f5cb985aa 100644 --- a/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts +++ b/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts @@ -22,6 +22,10 @@ function getIvm(): IsolatedVm { const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js'); +// Captured at module load so values rendered into generated code stay stable +// even if the global is later replaced. +const safeStringify = JSON.stringify; + /** Check if a value is an error sentinel returned by serializeError. */ function isErrorSentinel(value: unknown): value is ErrorSentinel { return ( @@ -749,7 +753,7 @@ export class IsolatedVmBridge implements RuntimeBridge { const callHost = this.createCallHostRef(data); try { - const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined'; + const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined'; // Wrap transformed code so 'this' === the closure-scoped context. // Tournament generates: this.$json.email, this.$items(), etc. diff --git a/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts b/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts index c181741c171..f9c02d2b829 100644 --- a/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts +++ b/packages/@n8n/expression-runtime/src/bridge/quickjs-bridge.ts @@ -21,6 +21,10 @@ async function getQuickJSModule(): Promise { const BUNDLE_RELATIVE_PATH = path.join('dist', 'bundle', 'runtime.iife.js'); +// Captured at module load so values rendered into generated code stay stable +// even if the global is later replaced. +const safeStringify = JSON.stringify; + // ============================================================================ // Sentinel helpers // @@ -265,7 +269,7 @@ function hostValueToJson(value: unknown): string { if (value === undefined) return 'undefined'; if (value === null) return 'null'; try { - return JSON.stringify(wrapSpecialValuesForGuest(value)); + return safeStringify(wrapSpecialValuesForGuest(value)); } catch { return 'undefined'; } @@ -1156,7 +1160,7 @@ export class QuickJsBridge implements RuntimeBridge { // nested call can't leave the outer budget extended. this.deadlines.push(Date.now() + timeout); try { - const timezone = options?.timezone ? JSON.stringify(options.timezone) : 'undefined'; + const timezone = options?.timezone ? safeStringify(options.timezone) : 'undefined'; // The callback impls arrive as function arguments (scoped to this call), // never as globals — see createCallbackHandles(). The runtime calls them diff --git a/packages/@n8n/expression-runtime/src/extensions/__tests__/array-extensions.test.ts b/packages/@n8n/expression-runtime/src/extensions/__tests__/array-extensions.test.ts new file mode 100644 index 00000000000..c8c724a26e7 --- /dev/null +++ b/packages/@n8n/expression-runtime/src/extensions/__tests__/array-extensions.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from 'vitest'; + +import { arrayExtensions } from '../array-extensions'; + +const smartJoin = arrayExtensions.functions.smartJoin as ( + value: unknown[], + extraArgs: string[], +) => Record; + +const renameKeys = arrayExtensions.functions.renameKeys as ( + value: unknown[], + extraArgs: string[], +) => Array>; + +const unique = arrayExtensions.functions.unique as ( + value: unknown[], + extraArgs: string[], +) => unknown[]; + +const merge = arrayExtensions.functions.merge as ( + value: unknown[], + extraArgs: unknown[][], +) => Record; + +const mergeIntoObject = arrayExtensions.functions.mergeIntoObject as ( + value: unknown[], + extraArgs: unknown[][], +) => Record; + +describe('smartJoin', () => { + it('should join own key and value fields', () => { + expect( + smartJoin( + [ + { field: 'age', value: 2 }, + { field: 'city', value: 'Berlin' }, + ], + ['field', 'value'], + ), + ).toEqual({ age: 2, city: 'Berlin' }); + }); + + it('should ignore inherited key and value fields', () => { + const source = Object.create({ field: 'age', value: 2 }) as object; + + expect(smartJoin([source], ['field', 'value'])).toEqual({}); + }); + + it('should read key and value fields served by a proxy without a getOwnPropertyDescriptor trap', () => { + const fields: Record = { field: 'age', value: 2 }; + const source = new Proxy( + {}, + { + has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key), + get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined), + }, + ); + + expect(smartJoin([source], ['field', 'value'])).toEqual({ age: 2 }); + }); + + it('should treat a key named __proto__ as an ordinary field', () => { + const result = smartJoin( + [{ field: '__proto__', value: { marker: 'set' } }], + ['field', 'value'], + ); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true); + expect(({} as Record).marker).toBeUndefined(); + }); +}); + +describe('renameKeys', () => { + it('should rename nothing when the source name is not an own field', () => { + const [result] = renameKeys([{ name: 'test' }], ['toString', 'renamed']); + + expect(result).toEqual({ name: 'test' }); + expect(Object.prototype.hasOwnProperty.call(result, 'renamed')).toBe(false); + }); + + it('should treat a target name of __proto__ as an ordinary field', () => { + const [result] = renameKeys([{ name: { marker: 'set' } }], ['name', '__proto__']); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true); + expect(({} as Record).marker).toBeUndefined(); + }); +}); + +describe('unique', () => { + it('should keep an element that does not own the compared field', () => { + const inherited = Object.create({ name: 'Nathan' }) as object; + + expect(unique([{ name: 'Nathan' }, inherited], ['name'])).toHaveLength(2); + }); + + it('should read a compared field served by a proxy without a getOwnPropertyDescriptor trap', () => { + const proxyFor = (name: string) => { + const fields: Record = { name }; + return new Proxy( + {}, + { + has: (_target, key) => typeof key === 'string' && Object.hasOwn(fields, key), + get: (_target, key) => (typeof key === 'string' ? fields[key] : undefined), + }, + ); + }; + + expect(unique([proxyFor('Nathan'), proxyFor('Jan')], ['name'])).toHaveLength(2); + }); +}); + +describe.each([ + ['merge', merge], + ['mergeIntoObject', mergeIntoObject], +])('%s', (_name, mergeFn) => { + it('should merge an incoming field named toString', () => { + const result = mergeFn([{ a: 1 }], [[{ toString: 'value' }]]); + + expect(Object.prototype.hasOwnProperty.call(result, 'toString')).toBe(true); + expect(result.toString).toBe('value'); + }); + + it('should treat an own __proto__ field of a source object as an ordinary field', () => { + const source = JSON.parse('{"__proto__": {"marker": "set"}}') as Record; + + const result = mergeFn([source], [[{ a: 1 }]]); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true); + expect(({} as Record).marker).toBeUndefined(); + }); +}); diff --git a/packages/@n8n/expression-runtime/src/extensions/__tests__/function-extensions.test.ts b/packages/@n8n/expression-runtime/src/extensions/__tests__/function-extensions.test.ts new file mode 100644 index 00000000000..5eb25c32c19 --- /dev/null +++ b/packages/@n8n/expression-runtime/src/extensions/__tests__/function-extensions.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { extendedFunctions } from '../function-extensions'; + +const zip = extendedFunctions.zip; + +describe('zip', () => { + it('should pair keys with values', () => { + expect(zip(['test1', 'test2', 'test3'], [1, 2, 3])).toEqual({ + test1: 1, + test2: 2, + test3: 3, + }); + }); + + it('should treat a key named __proto__ as an ordinary field', () => { + const result = zip(['__proto__'], [{ marker: 'set' }]) as Record; + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true); + expect(({} as Record).marker).toBeUndefined(); + }); +}); diff --git a/packages/@n8n/expression-runtime/src/extensions/__tests__/object-extensions.test.ts b/packages/@n8n/expression-runtime/src/extensions/__tests__/object-extensions.test.ts new file mode 100644 index 00000000000..f38165c9be7 --- /dev/null +++ b/packages/@n8n/expression-runtime/src/extensions/__tests__/object-extensions.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; + +import { objectExtensions } from '../object-extensions'; + +const compact = objectExtensions.functions.compact as (value: object) => object; +const hasField = objectExtensions.functions.hasField as ( + value: object, + extraArgs: string[], +) => boolean; + +describe('hasField', () => { + it('should return true for a key served through has/get traps without an own property descriptor', () => { + const target = { test1: 1 }; + const proxy = new Proxy(target, { + has(_target, key) { + return key === 'test2'; + }, + get(_target, key) { + return key === 'test2' ? 'proxied value' : undefined; + }, + }); + + expect(hasField(proxy, ['test2'])).toBe(true); + }); +}); + +describe('compact', () => { + it('should remove empty values', () => { + expect(compact({ test1: 1, test2: '2', test3: undefined, test4: null })).toEqual({ + test1: 1, + test2: '2', + }); + }); + + it('should keep an own __proto__ field as an ordinary field', () => { + const value = JSON.parse('{"test1": 1, "__proto__": {"marker": "set"}}') as object; + + const result = compact(value); + + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(true); + expect(({} as Record).marker).toBeUndefined(); + }); +}); diff --git a/packages/@n8n/expression-runtime/src/extensions/array-extensions.ts b/packages/@n8n/expression-runtime/src/extensions/array-extensions.ts index ac1059c041a..30f0f00f026 100644 --- a/packages/@n8n/expression-runtime/src/extensions/array-extensions.ts +++ b/packages/@n8n/expression-runtime/src/extensions/array-extensions.ts @@ -14,6 +14,23 @@ function randomInt(max: number): number { return random(0, max - 1); } +// Define an own data field rather than assigning through an inherited setter. +function defineField(target: Record, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + +// Report whether a field resolves on the object itself and not through its prototype chain. +function hasReadableField(value: object, field: string): boolean { + if (Object.hasOwn(value, field)) return true; + const proto = Object.getPrototypeOf(value) as object | null; + return field in value && (proto === null || !(field in proto)); +} + function first(value: unknown[]): unknown { return value[0]; } @@ -75,7 +92,8 @@ function unique(value: unknown[], extraArgs: string[]): unknown[] { const mapForEqualityCheck = (item: unknown): unknown => { if (extraArgs.length > 0 && item && typeof item === 'object') { return extraArgs.reduce>((acc, key) => { - acc[key] = (item as Record)[key]; + const source = item as Record; + defineField(acc, key, hasReadableField(source, key) ? source[key] : undefined); return acc; }, {}); } @@ -169,13 +187,16 @@ function smartJoin(value: unknown[], extraArgs: string[]): object { 'smartJoin(): expected two string args, e.g. .smartJoin("name", "value")', ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - return value.reduce((o, v) => { - if (typeof v === 'object' && v !== null && keyField in v && valueField in v) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any - o[(v as any)[keyField]] = (v as any)[valueField]; + return value.reduce>((o, v) => { + if ( + typeof v === 'object' && + v !== null && + hasReadableField(v, keyField) && + hasReadableField(v, valueField) + ) { + const entry = v as Record; + defineField(o, entry[keyField] as PropertyKey, entry[valueField]); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return o; }, {}); } @@ -202,18 +223,14 @@ function renameKeys(value: unknown[], extraArgs: string[]): unknown[] { if (typeof v !== 'object' || v === null) { return v; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any - const newObj = { ...(v as any) }; + const newObj: Record = { ...(v as Record) }; const chunkedArgs = chunk(extraArgs, [2]) as string[][]; chunkedArgs.forEach(([from, to]) => { - if (from in newObj) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - newObj[to] = newObj[from]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (Object.hasOwn(newObj, from)) { + defineField(newObj, to, newObj[from]); delete newObj[from]; } }); - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return newObj; }); } @@ -231,8 +248,8 @@ function mergeObjects(value: Record, extraArgs: unknown[]): unk const newObject = { ...value }; for (const [key, val] of Object.entries(other)) { - if (!(key in newObject)) { - newObject[key] = val; + if (!Object.hasOwn(newObject, key)) { + defineField(newObject, key, val); } } return newObject; @@ -258,14 +275,17 @@ function merge(value: unknown[], extraArgs: unknown[][]): unknown { ); } const listLength = value.length > others.length ? value.length : others.length; - let merged = {}; + const merged: Record = {}; for (let i = 0; i < listLength; i++) { if (value[i] !== undefined) { if (typeof value[i] === 'object' && typeof others[i] === 'object') { - merged = Object.assign( - merged, - mergeObjects(value[i] as Record, [others[i]]), - ); + const pair = mergeObjects(value[i] as Record, [others[i]]) as Record< + string, + unknown + >; + for (const [key, val] of Object.entries(pair)) { + defineField(merged, key, val); + } } } } @@ -281,17 +301,23 @@ function mergeIntoObject(value: unknown[], extraArgs: unknown[][]): unknown { ); } const listLength = value.length > others.length ? value.length : others.length; - let merged = {}; + const merged: Record = {}; for (let i = 0; i < listLength; i++) { const baseIsObject = value[i] !== null && typeof value[i] === 'object'; const otherIsObject = others[i] !== null && typeof others[i] === 'object'; + let source: Record | undefined; if (baseIsObject) { - merged = Object.assign( - merged, - mergeObjects(value[i] as Record, otherIsObject ? [others[i]] : []), - ); + source = mergeObjects( + value[i] as Record, + otherIsObject ? [others[i]] : [], + ) as Record; } else if (otherIsObject) { - merged = Object.assign(merged, others[i] as Record); + source = others[i] as Record; + } + if (source) { + for (const [key, val] of Object.entries(source)) { + defineField(merged, key, val); + } } } return merged; diff --git a/packages/@n8n/expression-runtime/src/extensions/function-extensions.ts b/packages/@n8n/expression-runtime/src/extensions/function-extensions.ts index 8f2dbd3f14b..6b2636fd170 100644 --- a/packages/@n8n/expression-runtime/src/extensions/function-extensions.ts +++ b/packages/@n8n/expression-runtime/src/extensions/function-extensions.ts @@ -1,6 +1,16 @@ import { average as aAverage } from './array-extensions'; import { ExpressionExtensionError } from './expression-extension-error'; +// Define an own data field rather than assigning through an inherited setter. +function defineField(target: Record, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + const min = Math.min; const max = Math.max; @@ -29,7 +39,7 @@ const zip = (keys: unknown[], values: unknown[]): unknown => { } const result: Record = {}; for (let i = 0; i < keys.length; i++) { - result[keys[i] as string] = values[i]; + defineField(result, keys[i] as PropertyKey, values[i]); } return result; }; diff --git a/packages/@n8n/expression-runtime/src/extensions/object-extensions.ts b/packages/@n8n/expression-runtime/src/extensions/object-extensions.ts index 1db0dd07909..2f22a3dffd4 100644 --- a/packages/@n8n/expression-runtime/src/extensions/object-extensions.ts +++ b/packages/@n8n/expression-runtime/src/extensions/object-extensions.ts @@ -1,6 +1,16 @@ import type { ExtensionMap } from './extensions'; import { ExpressionExtensionError } from './expression-extension-error'; +// Define an own data field rather than assigning through an inherited setter. +function defineField(target: Record, key: PropertyKey, value: unknown): void { + Object.defineProperty(target, key, { + value, + writable: true, + enumerable: true, + configurable: true, + }); +} + function isEmpty(value: object): boolean { return Object.keys(value).length === 0; } @@ -66,21 +76,17 @@ function keepFieldsContaining(value: object, extraArgs: string[]): object { } export function compact(value: object): object { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const newObj: any = {}; - for (const [key, val] of Object.entries(value)) { + const newObj: Record = {}; + for (const [key, val] of Object.entries(value) as Array<[string, unknown]>) { if (val !== null && val !== undefined && val !== 'nil' && val !== '') { if (typeof val === 'object') { - if (Object.keys(val as object).length === 0) continue; - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument - newObj[key] = compact(val); + if (Object.keys(val).length === 0) continue; + defineField(newObj, key, compact(val)); } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - newObj[key] = val; + defineField(newObj, key, val); } } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return newObj; } diff --git a/packages/@n8n/instance-ai/evaluations/credentials/__tests__/seeder.test.ts b/packages/@n8n/instance-ai/evaluations/credentials/__tests__/seeder.test.ts index d5359946ba5..3e3726892ea 100644 --- a/packages/@n8n/instance-ai/evaluations/credentials/__tests__/seeder.test.ts +++ b/packages/@n8n/instance-ai/evaluations/credentials/__tests__/seeder.test.ts @@ -25,6 +25,7 @@ const baseHint: InstanceAiCredentialSetupHint = { { name: 'workspace_id', title: 'Workspace ID', type: 'plain', optional: true }, ], serviceHost: 'api.example.com', + serviceOrigin: 'https://api.example.com', docsUrl: 'https://example.com/docs', testUrl: 'https://api.example.com/me', acceptedStatusCodes: [401], @@ -71,6 +72,7 @@ describe('createOneCredential — httpTemplatedCustomAuth', () => { expect(Object.keys(parsedValues)).toHaveLength(baseHint.placeholders.length); expect(data.serviceHost).toBe(baseHint.serviceHost); + expect(data.serviceOrigin).toBe(baseHint.serviceOrigin); expect(typeof data.acceptedStatusCodes).toBe('string'); expect(jsonParse(data.acceptedStatusCodes as string)).toEqual(baseHint.acceptedStatusCodes); diff --git a/packages/@n8n/instance-ai/evaluations/credentials/seeder.ts b/packages/@n8n/instance-ai/evaluations/credentials/seeder.ts index 4b5ef598909..c3688836d6b 100644 --- a/packages/@n8n/instance-ai/evaluations/credentials/seeder.ts +++ b/packages/@n8n/instance-ai/evaluations/credentials/seeder.ts @@ -223,6 +223,7 @@ async function createTemplatedCustomAuthCredential( placeholderDefs: JSON.stringify(hint.placeholders), placeholderValues: JSON.stringify(placeholderValues), serviceHost: hint.serviceHost ?? '', + serviceOrigin: hint.serviceOrigin ?? '', docsUrl: hint.docsUrl ?? '', testUrl: hint.testUrl ?? '', acceptedStatusCodes: hint.acceptedStatusCodes ? JSON.stringify(hint.acceptedStatusCodes) : '', diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/confirmation-payload.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/confirmation-payload.test.ts index 2229bb37935..af0e61dddf6 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/confirmation-payload.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/confirmation-payload.test.ts @@ -129,6 +129,15 @@ describe('confirmation payload → tool resume schema contract', () => { request: { kind: 'credentialAutoSetup', credentialType: 'firecrawlApi', attemptId: 'a-1' }, targets: [['credentials', credentialsResumeSchema]], }, + { + label: 'credential destination approval', + request: { + kind: 'credentialDestination', + approved: true, + origin: 'https://api.example.com', + }, + targets: [['workflows (setup wizard)', workflowsResumeSchema]], + }, { label: 'domain access approval', request: { kind: 'domainAccessApprove', domainAccessAction: 'allow_domain' }, @@ -196,6 +205,7 @@ describe('confirmation payload → tool resume schema contract', () => { questions: true, credentialSelection: true, credentialAutoSetup: true, + credentialDestination: true, domainAccessApprove: true, domainAccessDeny: true, planDeny: true, diff --git a/packages/@n8n/instance-ai/src/runtime/confirmation-payload.ts b/packages/@n8n/instance-ai/src/runtime/confirmation-payload.ts index 03fbbfc848b..5491e4acbab 100644 --- a/packages/@n8n/instance-ai/src/runtime/confirmation-payload.ts +++ b/packages/@n8n/instance-ai/src/runtime/confirmation-payload.ts @@ -44,6 +44,11 @@ export function toConfirmationData(request: InstanceAiConfirmRequest): Confirmat approved: true, autoSetup: { credentialType: request.credentialType, attemptId: request.attemptId }, }; + case 'credentialDestination': + return { + approved: request.approved, + credentialDestination: { origin: request.origin }, + }; case 'resourceDecision': return { approved: true, resourceDecision: request.resourceDecision }; case 'mcpConnect': @@ -88,6 +93,7 @@ export function buildResumeData(data: ConfirmationData): Record ...(data.resourceDecision ? { resourceDecision: data.resourceDecision } : {}), ...(data.scope ? { scope: data.scope } : {}), ...(data.autoSetup ? { autoSetup: data.autoSetup } : {}), + ...(data.credentialDestination ? { credentialDestination: data.credentialDestination } : {}), ...(data.denied ? { denied: true } : {}), ...(data.connectedSlugs ? { connectedSlugs: data.connectedSlugs } : {}), }; diff --git a/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts b/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts index d99d9209d78..4c2a8d2e415 100644 --- a/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts +++ b/packages/@n8n/instance-ai/src/runtime/run-state-registry.ts @@ -1,4 +1,7 @@ -import type { InstanceAiThreadStatusResponse } from '@n8n/api-types'; +import type { + InstanceAiCredentialDestinationDecision, + InstanceAiThreadStatusResponse, +} from '@n8n/api-types'; import { nanoid } from 'nanoid'; import type { InstanceAiTraceContext, ModelConfig, OrchestrationContext } from '../types'; @@ -84,6 +87,7 @@ export interface ConfirmationData { * persist a thread-level grant so the same action isn't re-asked. */ scope?: 'once' | 'session'; autoSetup?: { credentialType: string; attemptId?: string }; + credentialDestination?: InstanceAiCredentialDestinationDecision; connectedSlugs?: string[]; } diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts index 5633bca980c..4641b9a09d5 100644 --- a/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts +++ b/packages/@n8n/instance-ai/src/stream/__tests__/map-chunk.test.ts @@ -335,6 +335,10 @@ describe('mapAgentChunkToEvent', () => { ], domainAccess: { url: 'https://example.com/api', host: 'example.com' }, credentialFlow: { stage: 'generic' }, + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, setupRequests: [validSetupNode], workflowId: 'wf-1', resourceDecision: { @@ -369,6 +373,10 @@ describe('mapAgentChunkToEvent', () => { inputType: 'plan-review', domainAccess: { url: 'https://example.com/api', host: 'example.com' }, credentialFlow: { stage: 'generic' }, + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, setupRequests: [validSetupNode], workflowId: 'wf-1', questions: [ @@ -432,6 +440,10 @@ describe('mapAgentChunkToEvent', () => { domainAccess: { url: 'https://example.com' }, webSearch: { invalid: true }, credentialFlow: { stage: 'unknown' }, + credentialDestination: { + origin: 'https://api.example.com/path', + nodeNames: ['Fetch account'], + }, setupRequests: [{ invalid: true }], workflowId: 42, }, diff --git a/packages/@n8n/instance-ai/src/stream/map-chunk.ts b/packages/@n8n/instance-ai/src/stream/map-chunk.ts index 0651578da76..8dd2c178408 100644 --- a/packages/@n8n/instance-ai/src/stream/map-chunk.ts +++ b/packages/@n8n/instance-ai/src/stream/map-chunk.ts @@ -8,6 +8,7 @@ import { webSearchMetaSchema, channelConfigSchema, mcpConnectRequestSchema, + credentialDestinationSchema, } from '@n8n/api-types'; import type { InstanceAiEvent } from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; @@ -339,6 +340,10 @@ function mapSuspendedChunk( const domainAccess = parseDomainAccess(suspendPayload.domainAccess); const webSearch = parseSchemaRecord(suspendPayload.webSearch, webSearchMetaSchema); const credentialFlow = parseCredentialFlow(suspendPayload.credentialFlow); + const credentialDestination = parseSchemaRecord( + suspendPayload.credentialDestination, + credentialDestinationSchema, + ); const setupRequests = parseSchemaArray(suspendPayload.setupRequests, workflowSetupNodeSchema); const workflowId = presentString(suspendPayload.workflowId); const resourceDecision = parseSchemaRecord( @@ -384,6 +389,7 @@ function mapSuspendedChunk( ...(domainAccess ? { domainAccess } : {}), ...(webSearch ? { webSearch } : {}), ...(credentialFlow ? { credentialFlow } : {}), + ...(credentialDestination ? { credentialDestination } : {}), ...(setupRequests ? { setupRequests } : {}), ...(workflowId ? { workflowId } : {}), ...(questions ? { questions } : {}), diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts index b818276742c..e8406ce954a 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/credentials.tool.test.ts @@ -943,7 +943,7 @@ describe('credentials tool', () => { ); }); - it('should include setupHint in credentialRequests when provided', async () => { + it('should omit automatic test destinations from standalone setup', async () => { const context = createMockContext(); (context.credentialService.list as Mock).mockResolvedValue([]); @@ -981,12 +981,17 @@ describe('credentials tool', () => { ); expect(suspendFn).toHaveBeenCalledTimes(1); - // The service identity is stamped from the recipe's test endpoint — - // this card has no node context to derive it from. expect(suspendFn.mock.calls[0][0]).toEqual( expect.objectContaining({ credentialRequests: [ - expect.objectContaining({ setupHint: { ...setupHint, serviceHost: 'fal.run' } }), + expect.objectContaining({ + setupHint: { + template: setupHint.template, + placeholders: setupHint.placeholders, + docsUrl: setupHint.docsUrl, + suggestedName: setupHint.suggestedName, + }, + }), ], }), ); diff --git a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts index 842c6aa5454..fffb165a7b1 100644 --- a/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/__tests__/workflows.tool.test.ts @@ -1,4 +1,9 @@ -import type { InstanceAiPermissions } from '@n8n/api-types'; +import { zodToJsonSchema } from '@n8n/agents'; +import { + buildCredentialDestinationGrantKey, + type InstanceAiCredentialSetupHint, + type InstanceAiPermissions, +} from '@n8n/api-types'; import { generateWorkflowCode } from '@n8n/workflow-sdk'; import type { Mock } from 'vitest'; @@ -17,7 +22,7 @@ import { refreshWorkflowSourceFileBindingFromSave, saveWorkflowSourceFileBinding, } from '../workflows/workflow-file-bindings'; -import { createWorkflowsTool, type WorkflowAction } from '../workflows.tool'; +import { createWorkflowsTool, type WorkflowAction, workflowsResumeSchema } from '../workflows.tool'; // Mock the setup-workflow.service module to avoid pulling in heavy dependencies vi.mock('../workflows/setup-workflow.service', () => ({ @@ -40,6 +45,45 @@ vi.mock('@n8n/workflow-sdk', async (importOriginal) => { const emptyList = { workflows: [], total: 0, totalInScope: 0 }; +function templatedSetupFixture( + options: { + nodeUrl?: string; + testUrl?: string; + serviceOrigin?: string | null; + } = {}, +) { + const nodeUrl = options.nodeUrl ?? 'https://api.example.com/v1/account'; + const serviceOrigin = + options.serviceOrigin === undefined ? 'https://api.example.com' : options.serviceOrigin; + const recipe: InstanceAiCredentialSetupHint = { + template: { headers: { Authorization: 'Bearer {{api_key}}' } }, + placeholders: [{ name: 'api_key', title: 'API key' }], + ...(options.testUrl ? { testUrl: options.testUrl } : {}), + }; + const request = { + node: { + name: 'Fetch account', + type: 'n8n-nodes-base.httpRequest', + parameters: { url: nodeUrl }, + }, + credentialType: 'httpTemplatedCustomAuth', + needsAction: true, + setupHint: { + ...recipe, + ...(serviceOrigin ? { serviceHost: new URL(serviceOrigin).hostname, serviceOrigin } : {}), + }, + }; + return { + recipe, + request, + input: { + action: 'setup' as const, + workflowId: 'wf1', + credentialHints: [{ ...recipe, nodeName: request.node.name }], + }, + }; +} + function createMockContext( overrides: Partial> & { permissions?: Partial; @@ -125,6 +169,10 @@ describe('workflows tool', () => { vi.clearAllMocks(); }); + it('exports the resume schema without unsupported URI formats', () => { + expect(JSON.stringify(zodToJsonSchema(workflowsResumeSchema))).not.toContain('"format":"uri"'); + }); + describe('surface filtering', () => { const builderWorkflowActions = [ 'list', @@ -2270,6 +2318,155 @@ describe('workflows tool', () => { expect(suspend).toHaveBeenCalled(); }); + it('should accept a credential test URL on the workflow service origin', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(createMockContext(), 'full'); + await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never); + + expect(suspend).toHaveBeenCalledWith( + expect.objectContaining({ + workflowId: 'wf1', + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, + }), + ); + }); + + it('should remember an approved credential destination and open setup', async () => { + const first = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + const second = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + (analyzeWorkflow as Mock) + .mockResolvedValueOnce([first.request]) + .mockResolvedValueOnce([second.request]); + const grantSessionToolApproval = vi.fn().mockResolvedValue(undefined); + const context = createMockContext({ grantSessionToolApproval }); + const suspend = vi.fn(); + const tool = createWorkflowsTool(context, 'full'); + + await executeTool(tool, first.input, { suspend, resumeData: undefined } as never); + suspend.mockClear(); + await executeTool(tool, first.input, { + suspend, + resumeData: { + approved: true, + credentialDestination: { origin: 'https://api.example.com' }, + }, + } as never); + + expect(grantSessionToolApproval).toHaveBeenCalledWith( + buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'), + ); + expect(suspend).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Configure credentials for your workflow', + setupRequests: [second.request], + }), + ); + }); + + it('should reuse approval only for the same workflow credential destination', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://api.example.com/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const context = createMockContext({ + sessionApprovedToolKeys: new Set([ + buildCredentialDestinationGrantKey('wf1', 'https://api.example.com'), + ]), + }); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(context, 'full'); + await executeTool(tool, fixture.input, { suspend, resumeData: undefined } as never); + + expect(suspend).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Configure credentials for your workflow', + setupRequests: [fixture.request], + }), + ); + }); + + it('should require review again when the credential destination changes', async () => { + const fixture = templatedSetupFixture({ + nodeUrl: 'https://api-v2.example.com/v1/account', + testUrl: 'https://api-v2.example.com/me', + serviceOrigin: 'https://api-v2.example.com', + }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const grantSessionToolApproval = vi.fn(); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(createMockContext({ grantSessionToolApproval }), 'full'); + const result = await executeTool(tool, fixture.input, { + suspend, + resumeData: { + approved: true, + credentialDestination: { origin: 'https://api.example.com' }, + }, + } as never); + + expect(result).toMatchObject({ error: 'credential_destination_changed' }); + expect(grantSessionToolApproval).not.toHaveBeenCalled(); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should stop setup when the credential destination is declined', async () => { + const tool = createWorkflowsTool(createMockContext(), 'full'); + const result = await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, { + resumeData: { + approved: false, + credentialDestination: { origin: 'https://api.example.com' }, + }, + } as never); + + expect(result).toMatchObject({ + success: false, + denied: true, + reason: 'User did not approve credential use with https://api.example.com.', + }); + expect(analyzeWorkflow).not.toHaveBeenCalled(); + }); + + it('should reject a credential test URL on a different service origin', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(createMockContext(), 'full'); + const result = await executeTool(tool, fixture.input, { + suspend, + resumeData: undefined, + } as never); + + expect(result).toMatchObject({ error: 'invalid_credential_hints' }); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('should reject a templated setup when the workflow service origin is unavailable', async () => { + const fixture = templatedSetupFixture({ + nodeUrl: '={{ $json.url }}', + serviceOrigin: null, + }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(createMockContext(), 'full'); + const result = await executeTool(tool, fixture.input, { + suspend, + resumeData: undefined, + } as never); + + expect(result).toMatchObject({ + error: 'invalid_credential_hints', + problems: [expect.stringContaining('no statically derivable HTTP origin')], + }); + expect(suspend).not.toHaveBeenCalled(); + }); + it('should allow a plain generic type when credentials of it already exist', async () => { (analyzeWorkflow as Mock).mockResolvedValue([ { @@ -2744,6 +2941,62 @@ describe('workflows tool', () => { expect(suspend.mock.calls[0][0]).toMatchObject({ setupRequests: [sheetsRequest] }); }); + it('revalidates the credential test destination after a trigger test', async () => { + const fixture = templatedSetupFixture({ testUrl: 'https://status.example.net/me' }); + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request]); + (applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] }); + const context = createGrantAwareContext(); + (context.executionService.run as Mock).mockResolvedValue({ status: 'success' }); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(context, 'full'); + const result = await executeTool(tool, fixture.input, { + suspend, + resumeData: { + approved: true, + action: 'test-trigger', + testTriggerNode: 'Fetch account', + }, + } as never); + + expect(result).toMatchObject({ error: 'invalid_credential_hints' }); + expect(suspend).not.toHaveBeenCalled(); + }); + + it('checks refreshed credential test destinations against skipped node URLs', async () => { + const fixture = templatedSetupFixture({ + testUrl: 'https://api.example.com/v1/action', + }); + const skippedRequest = { + node: { + name: 'Run action', + type: 'n8n-nodes-base.httpRequest', + parameters: { url: fixture.recipe.testUrl }, + }, + credentialType: 'httpHeaderAuth', + needsAction: true, + credentialNeedsAction: true, + }; + (analyzeWorkflow as Mock).mockResolvedValue([fixture.request, skippedRequest]); + (applyNodeChanges as Mock).mockResolvedValue({ applied: [], failed: [] }); + const context = createGrantAwareContext(['workflows:setup-skip:cred:httpHeaderAuth']); + (context.executionService.run as Mock).mockResolvedValue({ status: 'success' }); + const suspend = vi.fn(); + + const tool = createWorkflowsTool(context, 'full'); + const result = await executeTool(tool, fixture.input, { + suspend, + resumeData: { + approved: true, + action: 'test-trigger', + testTriggerNode: 'Fetch account', + }, + } as never); + + expect(result).toMatchObject({ error: 'invalid_credential_hints' }); + expect(suspend).not.toHaveBeenCalled(); + }); + it('keeps another node Slack skip when only a parameter was completed', async () => { // "Alert on Slack" was connected already and only needed a channel. Clearing the // type-wide record here would re-open the card "Post to Slack" was skipped on. diff --git a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts index 49141b39f20..9c44a771e7c 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials.tool.ts @@ -20,7 +20,6 @@ import { } from './nodes/preferred-chat-model'; import { CREDENTIALS_TOOL_ID } from './tool-ids'; import { - extractServiceHost, GENERIC_AUTH_CREDENTIAL_TYPES, N8N_CONNECT_DISPLAY_NAME, } from './workflows/credential-utils'; @@ -136,6 +135,31 @@ function normalizeUrlForComparison(raw: unknown): string | undefined { } } +function extractHttpOrigin(raw: unknown): string | undefined { + if (typeof raw !== 'string') return undefined; + try { + const url = new URL(raw); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.origin : undefined; + } catch { + return undefined; + } +} + +export function findSetupHintTestUrlOriginProblem( + hint: InstanceAiCredentialSetupHint, + serviceOrigin: string, +): string | undefined { + if (hint.testUrl === undefined) return undefined; + const testOrigin = extractHttpOrigin(hint.testUrl); + if (!testOrigin) { + return `testUrl "${hint.testUrl}" is not an absolute HTTP URL — omit testUrl if no documented read-only endpoint is available`; + } + if (testOrigin !== serviceOrigin) { + return `testUrl origin "${testOrigin}" does not match the workflow service origin "${serviceOrigin}" — use a documented read-only endpoint on the workflow service or omit testUrl`; + } + return undefined; +} + /** * Collect recipe problems so the model corrects them instead of the card * silently degrading. `nodeUrls` additionally rejects a testUrl pointing at @@ -345,6 +369,8 @@ const searchTypesAction = z.object({ ), }); +const standaloneSetupHintField = setupHintField.omit({ testUrl: true }); + const setupAction = z.object({ action: z .literal('setup') @@ -372,7 +398,7 @@ const setupAction = z.object({ .describe( 'Set when the user explicitly asked to create a new credential of this type ("create a new Slack credential"), or needs to enter a replacement for one whose secret is invalid or rotated (e.g. pasted a new token in chat, which you cannot store). The card then opens with nothing preselected instead of offering the most recent existing credential — existing ones stay listed in case the user changes their mind.', ), - setupHint: setupHintField.optional(), + setupHint: standaloneSetupHintField.optional(), }), ) .describe('List of credentials to set up'), @@ -766,18 +792,13 @@ async function handleSetup( type: req.credentialType, ...(context.projectId ? { projectId: context.projectId } : {}), }); - // Service identity comes from the recipe's test endpoint here; an - // untagged credential is never offered automatically later. - const serviceHost = req.setupHint ? extractServiceHost(req.setupHint.testUrl) : undefined; return { credentialType: req.credentialType, reason: req.reason ?? `Required for ${req.credentialType}`, existingCredentials: existing.map((c) => ({ id: c.id, name: c.name })), ...(req.suggestedName ? { suggestedName: req.suggestedName } : {}), ...(req.preferNew ? { preferNew: true } : {}), - ...(req.setupHint - ? { setupHint: { ...req.setupHint, ...(serviceHost ? { serviceHost } : {}) } } - : {}), + ...(req.setupHint ? { setupHint: req.setupHint } : {}), }; }, ), diff --git a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts index a50f600f8ea..0c480c80546 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows.tool.ts @@ -4,6 +4,11 @@ * update-version. */ import { Tool } from '@n8n/agents'; +import { + buildCredentialDestinationGrantKey, + credentialDestinationSchema, + TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE, +} from '@n8n/api-types'; import { isRecord } from '@n8n/utils/is-record'; import { dropInvalidWorkflowJsonGroups, type WorkflowJSON } from '@n8n/workflow-sdk'; import { makeGetNodeTypeForGrouping } from 'n8n-workflow'; @@ -15,6 +20,7 @@ import { WorkflowSaveConflictError } from '../errors/workflow-save-conflict.erro import type { InstanceAiContext } from '../types'; import { findSetupHintProblems, + findSetupHintTestUrlOriginProblem, INVALID_SETUP_HINT_MESSAGE, setupHintField, TEMPLATABLE_PLAIN_AUTH_TYPES, @@ -283,7 +289,8 @@ const confirmationSuspendSchema = setupSuspendSchema severity: true, workflowId: true, }) - .partial({ workflowId: true }); + .partial({ workflowId: true }) + .extend({ credentialDestination: credentialDestinationSchema.optional() }); const suspendSchema = z.union([setupSuspendSchema, confirmationSuspendSchema]); @@ -787,6 +794,90 @@ function appliedCredentialIdList( .flatMap(([, byType]) => Object.values(byType)); } +interface RequiredCredentialDestination { + origin: string; + nodeNames: string[]; + grantKey: string; +} + +function inspectCredentialDestinations( + workflowId: string, + requests: readonly SetupRequest[], + requestsForNodeUrls: readonly SetupRequest[] = requests, +): { problems: string[]; destinations: RequiredCredentialDestination[] } { + const problems: string[] = []; + const byGrantKey = new Map(); + const nodeUrls = requestsForNodeUrls.map((request) => request.node.parameters?.url); + for (const request of requests) { + if ( + request.credentialType !== TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE || + request.needsAction === false || + !request.setupHint + ) { + continue; + } + + const origin = request.setupHint.serviceOrigin; + if (!origin) { + problems.push( + `${request.node.name}: credential destination cannot be verified because the workflow node has no statically derivable HTTP origin`, + ); + continue; + } + + const hintProblems = findSetupHintProblems(request.setupHint, { nodeUrls }); + const originProblem = findSetupHintTestUrlOriginProblem(request.setupHint, origin); + problems.push( + ...hintProblems.map((problem) => `${request.node.name}: ${problem}`), + ...(originProblem ? [`${request.node.name}: ${originProblem}`] : []), + ); + + const grantKey = buildCredentialDestinationGrantKey(workflowId, origin); + const existing = byGrantKey.get(grantKey); + if (existing) { + existing.nodeNames.push(request.node.name); + continue; + } + byGrantKey.set(grantKey, { + origin, + nodeNames: [request.node.name], + grantKey, + }); + } + return { problems, destinations: [...byGrantKey.values()] }; +} + +function findUnapprovedCredentialDestination( + context: InstanceAiContext, + destinations: readonly RequiredCredentialDestination[], + justApprovedGrantKey?: string, +): RequiredCredentialDestination | undefined { + return destinations.find( + (destination) => + destination.grantKey !== justApprovedGrantKey && + context.sessionApprovedToolKeys?.has(destination.grantKey) !== true, + ); +} + +async function suspendForCredentialDestination( + ctx: WorkflowToolContext, + state: SetupState, + workflowId: string, + destination: RequiredCredentialDestination, +) { + state.currentRequestId = nanoid(); + return await ctx.suspend({ + requestId: state.currentRequestId, + message: 'Review where this credential will be used', + severity: 'warning' as const, + workflowId, + credentialDestination: { + origin: destination.origin, + nodeNames: destination.nodeNames, + }, + }); +} + /** Setup state 3: persist setup, run the trigger, and re-suspend with the refreshed requests. */ async function handleSetupTestTrigger( context: InstanceAiContext, @@ -834,6 +925,26 @@ async function handleSetupTestTrigger( getSkippedSetupSubjects(context), ); applyCredentialHints(refreshedPending, input.credentialHints); + const destinationInspection = inspectCredentialDestinations( + input.workflowId, + refreshedPending, + refreshedRequests, + ); + if (destinationInspection.problems.length > 0) { + return { + error: 'invalid_credential_hints', + message: INVALID_SETUP_HINT_MESSAGE, + problems: destinationInspection.problems, + }; + } + + const destination = findUnapprovedCredentialDestination( + context, + destinationInspection.destinations, + ); + if (destination) { + return await suspendForCredentialDestination(ctx, state, input.workflowId, destination); + } // Generate a new requestId so the frontend doesn't filter it // as already-resolved from the previous suspend cycle @@ -1061,9 +1172,18 @@ async function handleSetup( } const resumeData = ctx.resumeData; + const destinationDecision = resumeData?.credentialDestination; + + if (destinationDecision && !resumeData.approved) { + return { + success: false, + denied: true, + reason: `User did not approve credential use with ${destinationDecision.origin}.`, + }; + } // State 1: Analyze workflow and suspend for user setup - if (resumeData === undefined || resumeData === null) { + if (resumeData === undefined || resumeData === null || destinationDecision !== undefined) { const allSetupRequests = await analyzeWorkflow( context, input.workflowId, @@ -1110,24 +1230,6 @@ async function handleSetup( await forgetSkippedSetup(context, subjects); } - // Setup after a build covers only the nodes that build changed — - // pre-existing, unrelated nodes must not surface in the setup card. - const scopeNodeNames = input.includeAllNodes - ? undefined - : await resolveSetupScopeNodeNames(context, input.workflowId); - const scopedRequests = scopeNodeNames - ? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name)) - : allSetupRequests; - - // Two reasons a card stays out, applied in order: this build never touched the node, or - // the user declined it. Partitioning the scoped list keeps them apart in the report — - // an out-of-scope card is not something the user passed on. - const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests( - scopedRequests, - input.workflowId, - getSkippedSetupSubjects(context), - ); - // Validated against the workflow's node URLs so a recipe can't set one of // the workflow's own (action) endpoints as its probe testUrl. Checked against every // analyzed node, not just the pending ones — narrowing it to what the card shows would @@ -1146,7 +1248,33 @@ async function handleSetup( }; } - applyCredentialHints(setupRequests, input.credentialHints); + applyCredentialHints(allSetupRequests, input.credentialHints); + const destinationInspection = inspectCredentialDestinations(input.workflowId, allSetupRequests); + if (destinationInspection.problems.length > 0) { + return { + error: 'invalid_credential_hints', + message: INVALID_SETUP_HINT_MESSAGE, + problems: destinationInspection.problems, + }; + } + + // Setup after a build covers only the nodes that build changed — + // pre-existing, unrelated nodes must not surface in the setup card. + const scopeNodeNames = input.includeAllNodes + ? undefined + : await resolveSetupScopeNodeNames(context, input.workflowId); + const scopedRequests = scopeNodeNames + ? allSetupRequests.filter((request) => scopeNodeNames.includes(request.node.name)) + : allSetupRequests; + + // Two reasons a card stays out, applied in order: this build never touched the node, or + // the user declined it. Partitioning the scoped list keeps them apart in the report — + // an out-of-scope card is not something the user passed on. + const { pending: setupRequests, skippedByUser } = partitionSkippedSetupRequests( + scopedRequests, + input.workflowId, + getSkippedSetupSubjects(context), + ); // A provider documenting `Authorization: Bearer ` reliably lures the // model into httpBearerAuth despite the skill guidance, so new plain generic @@ -1172,6 +1300,36 @@ async function handleSetup( } } + const setupNodeNames = new Set(setupRequests.map((request) => request.node.name)); + const credentialDestinations = destinationInspection.destinations.flatMap((destination) => { + const nodeNames = destination.nodeNames.filter((name) => setupNodeNames.has(name)); + return nodeNames.length > 0 ? [{ ...destination, nodeNames }] : []; + }); + let justApprovedGrantKey: string | undefined; + if (destinationDecision) { + const approvedDestination = credentialDestinations.find( + (destination) => destination.origin === destinationDecision.origin, + ); + if (!approvedDestination) { + return { + error: 'credential_destination_changed', + message: + 'The credential destination changed before approval was applied. Call setup again to review the current destination.', + }; + } + await context.grantSessionToolApproval?.(approvedDestination.grantKey); + justApprovedGrantKey = approvedDestination.grantKey; + } + + const destination = findUnapprovedCredentialDestination( + context, + credentialDestinations, + justApprovedGrantKey, + ); + if (destination) { + return await suspendForCredentialDestination(ctx, state, input.workflowId, destination); + } + if (setupRequests.length === 0) { // Two different silences, and the agent has to say different things about them: cards // the user declined, and pre-existing nodes this build never touched. Both can hold at diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/credential-utils.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/credential-utils.test.ts index 81f676b7560..6d6611cd8f4 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/credential-utils.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/credential-utils.test.ts @@ -3,6 +3,7 @@ import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types'; import type { InstanceAiContext } from '../../../types'; import { extractServiceHost, + extractServiceOrigin, resolveCredentialForApply, serviceHostsMatch, } from '../credential-utils'; @@ -95,6 +96,19 @@ describe('extractServiceHost', () => { ); }); +describe('extractServiceOrigin', () => { + it.each([ + ['https://api.pexels.com/v1/search?query=x', 'https://api.pexels.com'], + ['=https://queue.fal.run:8443/jobs/{{ $json.id }}', 'https://queue.fal.run:8443'], + ])('derives the origin of %s', (url, origin) => { + expect(extractServiceOrigin(url)).toBe(origin); + }); + + it.each([['={{ $json.url }}'], ['not a url'], [undefined]])('returns undefined for %s', (url) => { + expect(extractServiceOrigin(url)).toBeUndefined(); + }); +}); + describe('serviceHostsMatch', () => { it.each([ ['api.pexels.com', 'api.pexels.com'], diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts index 65d184277d1..6397d4a41f6 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/setup-workflow.service.test.ts @@ -2159,6 +2159,7 @@ describe('applyCredentialHints', () => { applyCredentialHints(requests, [hint()]); expect(requests[0].setupHint?.serviceHost).toBe('queue.fal.run'); + expect(requests[0].setupHint?.serviceOrigin).toBe('https://queue.fal.run'); }); it('stamps each request with its own node host from a shared type-wide hint', () => { @@ -2175,16 +2176,20 @@ describe('applyCredentialHints', () => { expect(requests[0].setupHint?.serviceHost).toBe('api.pexels.com'); expect(requests[1].setupHint?.serviceHost).toBe('api.apify.com'); expect(shared).not.toHaveProperty('serviceHost'); + expect(requests[0].setupHint?.serviceOrigin).toBe('https://api.pexels.com'); + expect(requests[1].setupHint?.serviceOrigin).toBe('https://api.apify.com'); + expect(shared).not.toHaveProperty('serviceOrigin'); }); - it('omits serviceHost when the node URL is not derivable', () => { + it('omits service identity when the node URL is not derivable', () => { const requests = [request('Call fal.ai', 'httpTemplatedCustomAuth')]; requests[0].node.parameters = { url: '={{ $json.url }}' }; - applyCredentialHints(requests, [hint()]); + applyCredentialHints(requests, [hint({ testUrl: 'https://fal.run/v1/models' })]); expect(requests[0].setupHint).toBeDefined(); expect(requests[0].setupHint).not.toHaveProperty('serviceHost'); + expect(requests[0].setupHint).not.toHaveProperty('serviceOrigin'); }); }); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts new file mode 100644 index 00000000000..f338cfc9469 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/workflows/__tests__/summarize-workflow.test.ts @@ -0,0 +1,77 @@ +import type { WorkflowNode } from '../../../types'; +import { summarizeWorkflowStructure } from '../summarize-workflow'; + +const nodes: WorkflowNode[] = [ + { name: 'A', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [0, 0], parameters: {} }, + { name: 'B', type: 'n8n-nodes-base.noOp', typeVersion: 1, position: [220, 0], parameters: {} }, +]; + +const edge = [[{ node: 'B', type: 'main', index: 0 }]]; + +/** + * Adds `key` as an own enumerable property — the shape `JSON.parse` produces for + * a stored `connections` column, which an object literal cannot express for keys + * like `__proto__`. + */ +function withOwnKey(key: string, value: unknown): Record { + const connections: Record = {}; + Object.defineProperty(connections, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return connections; +} + +describe('summarizeWorkflowStructure', () => { + it('renders nodes and edges of a normal graph', async () => { + const summary = await summarizeWorkflowStructure('wf', nodes, { A: { main: edge } }); + + expect(summary).toContain('A'); + expect(summary).toContain('B'); + }); + + describe('keys that resolve to object internals', () => { + afterEach(() => { + delete (Object.prototype as Record).main; + delete (Object as unknown as Record).main; + }); + + it('skips a source key that would resolve to the shared prototype', async () => { + const connections = withOwnKey('__proto__', { main: edge }); + + await summarizeWorkflowStructure('wf', nodes, connections); + + expect('main' in {}).toBe(false); + expect(Object.getOwnPropertyNames(Object.prototype)).not.toContain('main'); + }); + + it('skips a source key that would resolve to the Object constructor', async () => { + const connections = withOwnKey('constructor', { main: edge }); + + await summarizeWorkflowStructure('wf', nodes, connections); + + expect((Object as unknown as Record).main).toBeUndefined(); + }); + + it('skips a connection-type key that would resolve to object internals', async () => { + const connections = { A: withOwnKey('__proto__', edge) }; + + const summary = await summarizeWorkflowStructure('wf', nodes, connections); + + expect(summary).not.toContain('__proto__'); + }); + + it('keeps the rest of the graph when an unsafe source key is skipped', async () => { + const connections = withOwnKey('__proto__', { main: edge }); + connections.A = { main: edge }; + + const summary = await summarizeWorkflowStructure('wf', nodes, connections); + + expect(summary).toContain('A'); + expect(summary).toContain('B'); + expect(summary).not.toContain('__proto__'); + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts b/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts index dfdcadcc8e6..c6382dfd2a7 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/credential-utils.ts @@ -107,12 +107,21 @@ export function assignCredentialToNode( * anything that doesn't parse as an http(s) URL with a host. */ export function extractServiceHost(raw: unknown): string | undefined { + return extractServiceUrl(raw)?.hostname.toLowerCase(); +} + +/** Exact origin of a node's statically derivable HTTP URL. */ +export function extractServiceOrigin(raw: unknown): string | undefined { + return extractServiceUrl(raw)?.origin; +} + +function extractServiceUrl(raw: unknown): URL | undefined { if (typeof raw !== 'string') return undefined; const plain = (raw.startsWith('=') ? raw.slice(1) : raw).split('{{')[0].trim(); if (!/^https?:\/\//i.test(plain)) return undefined; try { - const host = new URL(plain).hostname.toLowerCase(); - return host || undefined; + const url = new URL(plain); + return url.hostname ? url : undefined; } catch { return undefined; } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.schema.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.schema.ts index bfdbc90923e..06b75a89ba6 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.schema.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.schema.ts @@ -5,6 +5,7 @@ * The node schema is the canonical `workflowSetupNodeSchema` from @n8n/api-types. */ import { + credentialDestinationDecisionSchema, instanceAiConfirmationSeveritySchema, workflowSetupNodeSchema, type InstanceAiWorkflowSetupNode, @@ -24,6 +25,7 @@ export const setupSuspendSchema = z.object({ export const setupResumeSchema = z.object({ approved: z.boolean(), + credentialDestination: credentialDestinationDecisionSchema.optional(), action: z.enum(['apply', 'test-trigger']).optional(), credentials: z.record(z.record(z.string())).optional(), nodeParameters: z.record(z.record(z.unknown())).optional(), diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index a2f24014b5f..a6d756eef43 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -28,6 +28,7 @@ import { N8N_CONNECT_DISPLAY_NAME, assignCredentialToNode, extractServiceHost, + extractServiceOrigin, isAiGatewayManagedCredential, resolveCredentialForApply, serviceHostsMatch, @@ -878,13 +879,15 @@ export function applyCredentialHints( hints.find((h) => h.nodeName === request.node.name) ?? hints.find((h) => !h.nodeName); if (!hint) continue; const { nodeName: _nodeName, ...setupHint } = hint; - // Service identity is derived from the node being set up (falling back - // to the recipe's own test endpoint), never model-supplied. It is - // stamped into the created credential so setup surfaces only offer it - // to same-service nodes later. - const serviceHost = - extractServiceHost(request.node.parameters?.url) ?? extractServiceHost(setupHint.testUrl); - request.setupHint = { ...setupHint, ...(serviceHost ? { serviceHost } : {}) }; + // The workflow node is the authority for service identity. The recipe's + // own destinations cannot establish or replace it. + const serviceHost = extractServiceHost(request.node.parameters?.url); + const serviceOrigin = extractServiceOrigin(request.node.parameters?.url); + request.setupHint = { + ...setupHint, + ...(serviceHost ? { serviceHost } : {}), + ...(serviceOrigin ? { serviceOrigin } : {}), + }; } } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts b/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts index df1a4b52574..9f044358c61 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/summarize-workflow.ts @@ -1,6 +1,6 @@ import { isRecord } from '@n8n/utils/is-record'; import type { IConnection, IConnections } from 'n8n-workflow'; -import { NodeConnectionTypes } from 'n8n-workflow'; +import { isSafeObjectProperty, NodeConnectionTypes } from 'n8n-workflow'; import type { WorkflowNode } from '../../types'; @@ -31,8 +31,12 @@ function toConnections(connections: Record): IConnections { const result: IConnections = {}; for (const [from, byType] of Object.entries(connections)) { if (!isRecord(byType)) continue; + if (!isSafeObjectProperty(from)) continue; + for (const [connectionType, outputs] of Object.entries(byType)) { if (!Array.isArray(outputs)) continue; + if (!isSafeObjectProperty(connectionType)) continue; + (result[from] ??= {})[connectionType] = outputs.map((targets) => Array.isArray(targets) ? targets.filter(isConnection) : null, ); @@ -46,8 +50,12 @@ function summarizeConnections(connections: Record): string[] { const edges: string[] = []; for (const [from, byType] of Object.entries(connections)) { if (!isRecord(byType)) continue; + if (!isSafeObjectProperty(from)) continue; + for (const [connectionType, outputs] of Object.entries(byType)) { if (!Array.isArray(outputs)) continue; + if (!isSafeObjectProperty(connectionType)) continue; + outputs.forEach((targets, outputIndex) => { if (!Array.isArray(targets)) return; for (const target of targets) { diff --git a/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts b/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts index ab7ccf51e78..19f364b648c 100644 --- a/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/ModelSelector/ModelSelector.node.ts @@ -3,6 +3,7 @@ import type { BaseCallbackHandler, CallbackHandlerMethods } from '@langchain/core/callbacks/base'; import type { Callbacks } from '@langchain/core/callbacks/manager'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import { isRecord } from '@n8n/utils/is-record'; import { NodeConnectionTypes, type INodeType, @@ -40,6 +41,20 @@ interface ModeleSelectionRule { }; } +/** + * Read the header names a tracer declares as redacted. Duck-typed on purpose: a model may + * attach any tracer implementation, so match on the declared list, not on a tracer class. + */ +function getDeclaredRedactedHeaders(tracer: unknown): string[] { + if (!isRecord(tracer) || !isRecord(tracer.options)) return []; + + const { redactedHeaders } = tracer.options; + + return Array.isArray(redactedHeaders) + ? redactedHeaders.filter((name) => typeof name === 'string') + : []; +} + function getCallbacksArray( callbacks: Callbacks | undefined, ): Array { @@ -200,7 +215,11 @@ export class ModelSelector implements INodeType { currentCallback.setParentRunIndex(this.getNextRunIndex()); } } - const modelSelectorTracing = new N8nNonEstimatingTracing(this); + + // This node records the same serialized model as the model's own tracer, + // so it masks whatever header names that tracer declares. + const redactedHeaders = originalCallbacks.flatMap(getDeclaredRedactedHeaders); + const modelSelectorTracing = new N8nNonEstimatingTracing(this, { redactedHeaders }); selectedModel.callbacks = [...originalCallbacks, modelSelectorTracing]; return { diff --git a/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.headers.test.ts b/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.headers.test.ts new file mode 100644 index 00000000000..0a60aeeb946 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/ModelSelector/test/ModelSelector.headers.test.ts @@ -0,0 +1,85 @@ +import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { Serialized } from '@langchain/core/load/serializable'; +import { N8nLlmTracing } from '@n8n/ai-utilities'; +import type { ISupplyDataFunctions, INode } from 'n8n-workflow'; +import type { Mocked } from 'vitest'; +import { mock } from 'vitest-mock-extended'; + +import { ModelSelector } from '../ModelSelector.node'; + +const serializedModel: Serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'chat_models', 'openai'], + kwargs: { + configuration: { + defaultHeaders: { + 'User-Agent': 'n8n', + authorization: 'Bearer My_secret_API_key123456789', + 'x-secret-header': 'My_secret_API_key123456789', + }, + }, + }, +}; + +describe('ModelSelector Node header handling', () => { + let selectorContext: Mocked; + + beforeEach(() => { + selectorContext = mock({ + addInputData: vi.fn().mockReturnValue({ index: 0 }), + getNextRunIndex: vi.fn().mockReturnValue(0), + }); + selectorContext.getNode.mockReturnValue({ name: 'Model Selector' } as INode); + selectorContext.getNodeParameter.mockImplementation((parameter) => + parameter === 'rules.rule' ? [{ modelIndex: 1, conditions: {} }] : true, + ); + }); + + /** Runs the selected model through the node and returns what the node persisted. */ + const persistHeadersFor = async (callbacks: unknown[]) => { + const model = { _llmType: () => 'fake-llm', callbacks } as unknown as BaseChatModel; + selectorContext.getInputConnectionData.mockResolvedValue([model]); + + const { response } = await new ModelSelector().supplyData.call(selectorContext, 0); + + const attached = (response as BaseChatModel).callbacks as Array<{ + handleLLMStart: (llm: Serialized, prompts: string[], runId: string) => Promise; + }>; + // the tracer the Model Selector attached on top of the model's own callbacks + await attached[attached.length - 1].handleLLMStart(serializedModel, ['hello'], 'run-123'); + + const inputArg = selectorContext.addInputData.mock.calls[0][1] as Array< + Array<{ json: { options: { configuration: { defaultHeaders: Record } } } }> + >; + return inputArg[0][0].json.options.configuration.defaultHeaders; + }; + + it('should mask the header values declared by the selected model', async () => { + const modelContext = mock({ + addInputData: vi.fn().mockReturnValue({ index: 0 }), + getNextRunIndex: vi.fn().mockReturnValue(0), + }); + + const persistedHeaders = await persistHeadersFor([ + new N8nLlmTracing(modelContext, { redactedHeaders: ['x-secret-header'] }), + ]); + + expect(persistedHeaders['x-secret-header']).toBe('**********'); + expect(persistedHeaders['User-Agent']).toBe('n8n'); + }); + + it('should mask the header values declared by a model that attaches its own tracer type', async () => { + const persistedHeaders = await persistHeadersFor([ + { handleLLMStart: vi.fn(), options: { redactedHeaders: ['x-secret-header'] } }, + ]); + + expect(persistedHeaders['x-secret-header']).toBe('**********'); + }); + + it('should still mask the always-redacted header names when the model declares none', async () => { + const persistedHeaders = await persistHeadersFor([{ handleLLMStart: vi.fn() }]); + + expect(persistedHeaders.authorization).toBe('**********'); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts index 02430fd2322..96031f0ba72 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts @@ -1,7 +1,7 @@ import { AgentExecutor } from '@langchain/classic/agents'; import type { OpenAIToolType } from '@langchain/classic/dist/experimental/openai_assistant/schema'; import { OpenAIAssistantRunnable } from '@langchain/classic/experimental/openai_assistant'; -import { assertCredentialAllowsUrl, NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; +import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import type { IExecuteFunctions, INodeExecutionData, @@ -14,6 +14,7 @@ import { getConnectedTools, mergeCustomHeaders } from '@utils/helpers'; import { getTracingConfig } from '@utils/tracing'; import { formatToOpenAIAssistantTool } from './utils'; +import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials'; import { Container } from '@n8n/di'; import { AiConfig } from '@n8n/config'; @@ -350,13 +351,7 @@ export class OpenAiAssistant implements INodeType { const defaultHeaders = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {}); if (options.baseURL) { - assertCredentialAllowsUrl({ - node: this.getNode(), - credentialData: credentials, - url: options.baseURL, - pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined, - surface: 'OpenAI', - }); + assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL); } const client = new OpenAIClient({ diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts index 523d2c70539..9d309a55f40 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/EmbeddingsOllama.node.ts @@ -1,5 +1,6 @@ import { OllamaEmbeddings } from '@langchain/ollama'; import { + assertCredentialAllowsUrl, NodeConnectionTypes, type INodeType, type INodeTypeDescription, @@ -7,7 +8,7 @@ import { type SupplyData, } from 'n8n-workflow'; -import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; +import { logWrapper, getConnectionHintNoticeField, proxyFetch } from '@n8n/ai-utilities'; import { ollamaDescription, ollamaModel } from '../../llms/LMOllama/description'; @@ -48,16 +49,29 @@ export class EmbeddingsOllama implements INodeType { this.logger.debug('Supply data for embeddings Ollama'); const modelName = this.getNodeParameter('model', itemIndex) as string; const credentials = await this.getCredentials('ollamaApi'); + const baseUrl = credentials.baseUrl as string; + + assertCredentialAllowsUrl({ + node: this.getNode(), + credentialData: credentials, + url: baseUrl, + surface: 'Ollama', + }); + const headers = credentials.apiKey ? { Authorization: `Bearer ${credentials.apiKey as string}`, } : undefined; + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); + const embeddings = new OllamaEmbeddings({ - baseUrl: credentials.baseUrl as string, + baseUrl, model: modelName, headers, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => + await proxyFetch({ input, init, lookup }), }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts new file mode 100644 index 00000000000..3f33b6f90ea --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOllama/test/EmbeddingsOllama.node.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { OllamaEmbeddings } from '@langchain/ollama'; +import { proxyFetch } from '@n8n/ai-utilities'; +import { createResultOk } from '@n8n/utils/result'; +import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow'; +import type { MockedFunction } from 'vitest'; +import { mockDeep } from 'vitest-mock-extended'; + +import { EmbeddingsOllama } from '../EmbeddingsOllama.node'; + +vi.mock('@langchain/ollama'); +vi.mock('@n8n/ai-utilities', async () => { + const actual = await vi.importActual('@n8n/ai-utilities'); + return { + ...(actual as Record), + proxyFetch: vi.fn(), + }; +}); + +const MockedOllamaEmbeddings = vi.mocked(OllamaEmbeddings); +const mockedProxyFetch = proxyFetch as MockedFunction; + +const nodeDef: INode = { + id: '1', + name: 'Embeddings Ollama', + typeVersion: 1, + type: 'n8n-nodes-langchain.embeddingsOllama', + position: [0, 0], + parameters: {}, +}; + +/** + * The lookup may travel to `proxyFetch` as a positional argument or as a field + * of an options object, so accept either shape. + */ +function lookupReachedProxyFetch(lookup: unknown): boolean { + return mockedProxyFetch.mock.calls.some((call) => + call.some( + (arg) => + arg === lookup || + (typeof arg === 'object' && + arg !== null && + (arg as { lookup?: unknown }).lookup === lookup), + ), + ); +} + +describe('EmbeddingsOllama', () => { + let node: EmbeddingsOllama; + + const setupContext = (credentials: Record, egressFilter: NodeEgressFilter) => { + const ctx = mockDeep(); + ctx.getNode.mockReturnValue(nodeDef); + ctx.getCredentials.mockResolvedValue(credentials); + ctx.getNodeParameter.mockImplementation((name: string) => { + if (name === 'model') return 'llama3'; + return undefined; + }); + ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter); + return ctx; + }; + + beforeEach(() => { + node = new EmbeddingsOllama(); + vi.clearAllMocks(); + mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 })); + }); + + it('routes requests through the egress filter lookup', async () => { + const secureLookup = vi.fn(); + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }; + const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); + + await node.supplyData.call(ctx, 0); + + expect(egressFilter.createSecureLookup).toHaveBeenCalled(); + + const embeddingsOptions = MockedOllamaEmbeddings.mock.calls[0][0] as + | { fetch?: typeof fetch } + | undefined; + expect(embeddingsOptions?.fetch).toBeTypeOf('function'); + + await embeddingsOptions?.fetch?.('http://ollama.example.com:11434/api/embed', {}); + + expect(mockedProxyFetch).toHaveBeenCalled(); + expect(lookupReachedProxyFetch(secureLookup)).toBe(true); + }); + + it('rejects a base URL the credential does not allow', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://not-allowed.example.com:11434', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'ollama.example.com', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed'); + expect(MockedOllamaEmbeddings).not.toHaveBeenCalled(); + }); + + it('rejects a credential restricted from use in this node', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://ollama.example.com:11434', + allowedHttpRequestDomains: 'none', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use'); + expect(MockedOllamaEmbeddings).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index 646d86897c9..76fc3a78c74 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -2,7 +2,6 @@ import { OpenAIEmbeddings } from '@langchain/openai'; import { AiConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import { - assertCredentialAllowsUrl, NodeConnectionTypes, type INodeProperties, type INodeType, @@ -13,6 +12,8 @@ import { import type { ClientOptions } from 'openai'; import { mergeCustomHeaders } from '@utils/helpers'; + +import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials'; import { getProxyAgent, logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; const modelParameter: INodeProperties = { @@ -252,13 +253,7 @@ export class EmbeddingsOpenAi implements INodeType { defaultHeaders, }; if (options.baseURL) { - assertCredentialAllowsUrl({ - node: this.getNode(), - credentialData: credentials, - url: options.baseURL, - pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined, - surface: 'OpenAI', - }); + assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL); configuration.baseURL = options.baseURL; } else if (credentials.url) { configuration.baseURL = credentials.url as string; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts index d18add52e2f..0ef635814c2 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/__tests__/searchModels.test.ts @@ -44,10 +44,17 @@ const mockModels = [ describe('searchModels', () => { let mockContext: Mocked; let fetchSpy: ReturnType; + const secureLookup = vi.fn(); beforeEach(() => { mockContext = { getCredentials: vi.fn().mockResolvedValue({ apiKey: 'test-api-key' }), + helpers: { + getSecureEgressFilter: vi.fn().mockReturnValue({ + validateUrl: vi.fn(), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }), + }, } as unknown as Mocked; fetchSpy = vi.fn().mockResolvedValue({ @@ -69,15 +76,16 @@ describe('searchModels', () => { const result = await searchModels.call(mockContext); expect(mockContext.getCredentials).toHaveBeenCalledWith('anthropicApi'); - expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.anthropic.com/v1/models', - expect.objectContaining({ + expect(fetchSpy).toHaveBeenCalledWith({ + input: 'https://api.anthropic.com/v1/models', + init: expect.objectContaining({ headers: expect.objectContaining({ 'x-api-key': 'test-api-key', 'anthropic-version': '2023-06-01', }), }), - ); + lookup: secureLookup, + }); expect(result.results).toHaveLength(5); }); @@ -89,7 +97,9 @@ describe('searchModels', () => { const result = await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith(`${customUrl}/v1/models`, expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: `${customUrl}/v1/models` }), + ); expect(result.results).toHaveLength(5); }); @@ -100,7 +110,9 @@ describe('searchModels', () => { const result = await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith('https://api.anthropic.com/v1/models', expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://api.anthropic.com/v1/models' }), + ); expect(result.results).toHaveLength(5); }); @@ -154,11 +166,13 @@ describe('searchModels', () => { await searchModels.call(mockContext); expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.anthropic.com/v1/models', expect.objectContaining({ - headers: expect.objectContaining({ - 'x-api-key': 'test-api-key', - 'X-Gateway-Auth': 'gateway-value', + input: 'https://api.anthropic.com/v1/models', + init: expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'test-api-key', + 'X-Gateway-Auth': 'gateway-value', + }), }), }), ); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts index 3504432bef7..4bc884f73f1 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatAnthropic/methods/searchModels.ts @@ -9,6 +9,7 @@ export async function searchModels( filter?: string, ): Promise { const credentials = await this.getCredentials('anthropicApi'); + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const baseURL = (credentials.url as string) ?? 'https://api.anthropic.com'; // Shared with the agents model catalog: endpoint, auth, and newest-first @@ -19,7 +20,7 @@ export async function searchModels( apiKey: (credentials.apiKey as string) ?? '', baseURL, headers: mergeCustomHeaders(credentials, {}), - fetch: proxyFetch, + fetch: async (input, init) => await proxyFetch({ input, init, lookup }), }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts index 933992ceedf..df2a2f7e656 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/LmChatOllama.node.ts @@ -7,6 +7,7 @@ import { getConnectionHintNoticeField, } from '@n8n/ai-utilities'; import { + assertCredentialAllowsUrl, NodeConnectionTypes, type INodeType, type INodeTypeDescription, @@ -59,6 +60,14 @@ export class LmChatOllama implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const credentials = await this.getCredentials('ollamaApi'); + const baseUrl = credentials.baseUrl as string; + + assertCredentialAllowsUrl({ + node: this.getNode(), + credentialData: credentials, + url: baseUrl, + surface: 'Ollama', + }); const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as ChatOllamaInput; @@ -68,12 +77,13 @@ export class LmChatOllama implements INodeType { } : undefined; + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => - await proxyFetch(input, init, {}); + await proxyFetch({ input, init, lookup }); const model = new ChatOllama({ ...options, - baseUrl: credentials.baseUrl as string, + baseUrl, model: modelName, format: options.format === 'default' ? undefined : options.format, callbacks: [new N8nLlmTracing(this)], diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts new file mode 100644 index 00000000000..8de2c2d0f34 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOllama/test/LmChatOllama.node.test.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { ChatOllama } from '@langchain/ollama'; +import { proxyFetch } from '@n8n/ai-utilities'; +import { createResultOk } from '@n8n/utils/result'; +import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow'; +import type { MockedFunction } from 'vitest'; +import { mockDeep } from 'vitest-mock-extended'; + +import { LmChatOllama } from '../LmChatOllama.node'; + +vi.mock('@langchain/ollama'); +vi.mock('@n8n/ai-utilities', async () => { + const actual = await vi.importActual('@n8n/ai-utilities'); + return { + ...(actual as Record), + proxyFetch: vi.fn(), + }; +}); + +const MockedChatOllama = vi.mocked(ChatOllama); +const mockedProxyFetch = proxyFetch as MockedFunction; + +const nodeDef: INode = { + id: '1', + name: 'Ollama Chat Model', + typeVersion: 1, + type: 'n8n-nodes-langchain.lmChatOllama', + position: [0, 0], + parameters: {}, +}; + +/** + * The lookup may travel to `proxyFetch` as a positional argument or as a field + * of an options object, so accept either shape. + */ +function lookupReachedProxyFetch(lookup: unknown): boolean { + return mockedProxyFetch.mock.calls.some((call) => + call.some( + (arg) => + arg === lookup || + (typeof arg === 'object' && + arg !== null && + (arg as { lookup?: unknown }).lookup === lookup), + ), + ); +} + +describe('LmChatOllama', () => { + let node: LmChatOllama; + + const setupContext = (credentials: Record, egressFilter: NodeEgressFilter) => { + const ctx = mockDeep(); + ctx.getNode.mockReturnValue(nodeDef); + ctx.getCredentials.mockResolvedValue(credentials); + ctx.getNodeParameter.mockImplementation((name: string) => { + if (name === 'model') return 'llama3'; + if (name === 'options') return {}; + return undefined; + }); + ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter); + return ctx; + }; + + beforeEach(() => { + node = new LmChatOllama(); + vi.clearAllMocks(); + mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 })); + }); + + it('routes requests through the egress filter lookup', async () => { + const secureLookup = vi.fn(); + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }; + const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); + + await node.supplyData.call(ctx, 0); + + expect(egressFilter.createSecureLookup).toHaveBeenCalled(); + + const [modelOptions] = MockedChatOllama.mock.calls[0]; + expect(modelOptions?.fetch).toBeTypeOf('function'); + + await modelOptions?.fetch?.('http://ollama.example.com:11434/api/chat', {}); + + expect(mockedProxyFetch).toHaveBeenCalled(); + expect(lookupReachedProxyFetch(secureLookup)).toBe(true); + }); + + it('rejects a base URL the credential does not allow', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://not-allowed.example.com:11434', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'ollama.example.com', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed'); + expect(MockedChatOllama).not.toHaveBeenCalled(); + }); + + it('rejects a credential restricted from use in this node', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://ollama.example.com:11434', + allowedHttpRequestDomains: 'none', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use'); + expect(MockedChatOllama).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index 5509ce328eb..443b4ea865c 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -2,7 +2,6 @@ import { ChatOpenAI, type ChatOpenAIFields, type ClientOptions } from '@langchai import isPlainObject from 'lodash/isPlainObject'; import pick from 'lodash/pick'; import { - assertCredentialAllowsUrl, jsonParse, NodeConnectionTypes, NodeOperationError, @@ -17,6 +16,7 @@ import { import { wrapChatModelMessageInput } from '@utils/chatModelMessageWrapper'; import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers'; +import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials'; import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; import { makeN8nLlmFailedAttemptHandler, @@ -771,13 +771,7 @@ export class LmChatOpenAi implements INodeType { }; if (options.baseURL) { - assertCredentialAllowsUrl({ - node: this.getNode(), - credentialData: credentials, - url: options.baseURL, - pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined, - surface: 'OpenAI', - }); + assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL); configuration.baseURL = options.baseURL; } else if (credentials.url) { configuration.baseURL = credentials.url as string; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts index e7b2782b1d4..c87a3fd26f4 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts @@ -1,5 +1,6 @@ import { proxyFetch } from '@n8n/ai-utilities'; -import type { ILoadOptionsFunctions } from 'n8n-workflow'; +import type { ILoadOptionsFunctions, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; import type { Mocked } from 'vitest'; import { searchModels } from '../loadModels'; @@ -33,6 +34,7 @@ const OFFICIAL_API_RESULTS = [ describe('searchModels', () => { let mockContext: Mocked; let fetchSpy: ReturnType; + const secureLookup = vi.fn(); beforeEach(() => { mockContext = { @@ -40,6 +42,20 @@ describe('searchModels', () => { apiKey: 'test-api-key', }), getNodeParameter: vi.fn().mockReturnValue(''), + getNode: vi.fn().mockReturnValue({ + id: '1', + name: 'Test Node', + type: 'test', + typeVersion: 1, + position: [0, 0], + parameters: {}, + } as INode), + helpers: { + getSecureEgressFilter: vi.fn().mockReturnValue({ + validateUrl: vi.fn(), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }), + }, } as unknown as Mocked; fetchSpy = vi.fn().mockResolvedValue({ @@ -61,12 +77,13 @@ describe('searchModels', () => { it('should return filtered models if custom API endpoint is not provided', async () => { const result = await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.openai.com/v1/models', - expect.objectContaining({ + expect(fetchSpy).toHaveBeenCalledWith({ + input: 'https://api.openai.com/v1/models', + init: expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test-api-key' }), }), - ); + lookup: secureLookup, + }); expect(result.results).toEqual(OFFICIAL_API_RESULTS); }); @@ -78,7 +95,9 @@ describe('searchModels', () => { await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith('https://test-url.com/models', expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://test-url.com/models' }), + ); }); it('should use default OpenAI URL if no custom URL provided', async () => { @@ -88,7 +107,9 @@ describe('searchModels', () => { await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith('https://api.openai.com/v1/models', expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://api.openai.com/v1/models' }), + ); }); it('should include all models for custom API endpoints', async () => { @@ -96,7 +117,9 @@ describe('searchModels', () => { const result = await searchModels.call(mockContext); - expect(fetchSpy).toHaveBeenCalledWith('https://custom-api.com/models', expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://custom-api.com/models' }), + ); expect(result.results).toEqual([ { name: 'computer-use-preview', value: 'computer-use-preview' }, { name: 'davinci-instruct-beta', value: 'davinci-instruct-beta' }, @@ -144,6 +167,75 @@ describe('searchModels', () => { ]); }); + it('should reject a base URL override that the credential does not allow', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + apiKey: 'test-api-key', + url: 'https://api.openai.com/v1', + allowedHttpRequestDomains: 'none', + }); + mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1'); + + await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should allow a base URL override matching the credential URL', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + apiKey: 'test-api-key', + url: 'https://api.openai.com/v1', + allowedHttpRequestDomains: 'none', + }); + mockContext.getNodeParameter = vi.fn().mockReturnValue('https://api.openai.com/v1'); + + await searchModels.call(mockContext); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://api.openai.com/v1/models' }), + ); + }); + + it('should reject a base URL override outside the allowed domains list', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + apiKey: 'test-api-key', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'example.com', + }); + mockContext.getNodeParameter = vi.fn().mockReturnValue('https://custom-api.com/v1'); + + await expect(searchModels.call(mockContext)).rejects.toThrow(NodeOperationError); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should allow a base URL override within the allowed domains list', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + apiKey: 'test-api-key', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'example.com', + }); + mockContext.getNodeParameter = vi.fn().mockReturnValue('https://example.com/v1'); + + await searchModels.call(mockContext); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://example.com/v1/models' }), + ); + }); + + it('should not restrict the credential URL when no override is set', async () => { + mockContext.getCredentials.mockResolvedValueOnce({ + apiKey: 'test-api-key', + url: 'https://my-proxy.internal/v1', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'example.com', + }); + + await searchModels.call(mockContext); + + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: 'https://my-proxy.internal/v1/models' }), + ); + }); + it('should include custom credential headers in the request', async () => { mockContext.getCredentials.mockResolvedValueOnce({ apiKey: 'test-api-key', @@ -155,9 +247,11 @@ describe('searchModels', () => { await searchModels.call(mockContext); expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.openai.com/v1/models', expect.objectContaining({ - headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }), + input: 'https://api.openai.com/v1/models', + init: expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom-Auth': 'custom-value' }), + }), }), ); }); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts index 2fd07949d6d..d7abc384963 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts @@ -5,17 +5,20 @@ import { Container } from '@n8n/di'; import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; import { mergeCustomHeaders } from '../../../../utils/helpers'; +import { assertOpenAiCredentialAllowsUrl } from '../../../vendors/OpenAi/helpers/credentials'; export async function searchModels( this: ILoadOptionsFunctions, filter?: string, ): Promise { const credentials = await this.getCredentials('openAiApi'); - const baseURL = - (this.getNodeParameter('options.baseURL', '') as string) || - (credentials.url as string) || - 'https://api.openai.com/v1'; + const baseUrlOverride = this.getNodeParameter('options.baseURL', '') as string; + if (baseUrlOverride) { + assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, baseUrlOverride); + } + const baseURL = baseUrlOverride || (credentials.url as string) || 'https://api.openai.com/v1'; const { openAiDefaultHeaders } = Container.get(AiConfig); + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const headers = mergeCustomHeaders(credentials, openAiDefaultHeaders ?? {}); // Shared with the agents model catalog: endpoint, auth, chat-model filtering @@ -24,7 +27,7 @@ export async function searchModels( apiKey: credentials.apiKey as string, baseURL, headers, - fetch: proxyFetch, + fetch: async (input, init) => await proxyFetch({ input, init, lookup }), }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts index 6e908c44e15..4b68b3ef991 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/LmOllama.node.ts @@ -1,5 +1,6 @@ import { Ollama, type OllamaInput } from '@langchain/ollama'; import { + assertCredentialAllowsUrl, NodeConnectionTypes, type INodeType, type INodeTypeDescription, @@ -11,6 +12,7 @@ import { ollamaDescription, ollamaModel, ollamaOptions } from './description'; import { makeN8nLlmFailedAttemptHandler, N8nLlmTracing, + proxyFetch, getConnectionHintNoticeField, } from '@n8n/ai-utilities'; @@ -55,6 +57,14 @@ export class LmOllama implements INodeType { async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { const credentials = await this.getCredentials('ollamaApi'); + const baseUrl = credentials.baseUrl as string; + + assertCredentialAllowsUrl({ + node: this.getNode(), + credentialData: credentials, + url: baseUrl, + surface: 'Ollama', + }); const modelName = this.getNodeParameter('model', itemIndex) as string; const options = this.getNodeParameter('options', itemIndex, {}) as OllamaInput; @@ -64,13 +74,17 @@ export class LmOllama implements INodeType { } : undefined; + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); + const model = new Ollama({ - baseUrl: credentials.baseUrl as string, + baseUrl, model: modelName, ...options, callbacks: [new N8nLlmTracing(this)], onFailedAttempt: makeN8nLlmFailedAttemptHandler(this), headers, + fetch: async (input: RequestInfo | URL, init?: RequestInit) => + await proxyFetch({ input, init, lookup }), }); return { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts new file mode 100644 index 00000000000..7895674a457 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOllama/test/LmOllama.node.test.ts @@ -0,0 +1,125 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { Ollama } from '@langchain/ollama'; +import { proxyFetch } from '@n8n/ai-utilities'; +import { createResultOk } from '@n8n/utils/result'; +import type { INode, ISupplyDataFunctions, NodeEgressFilter } from 'n8n-workflow'; +import type { MockedFunction } from 'vitest'; +import { mockDeep } from 'vitest-mock-extended'; + +import { LmOllama } from '../LmOllama.node'; + +vi.mock('@langchain/ollama'); +vi.mock('@n8n/ai-utilities', async () => { + const actual = await vi.importActual('@n8n/ai-utilities'); + return { + ...(actual as Record), + proxyFetch: vi.fn(), + }; +}); + +const MockedOllama = vi.mocked(Ollama); +const mockedProxyFetch = proxyFetch as MockedFunction; + +const nodeDef: INode = { + id: '1', + name: 'Ollama Model', + typeVersion: 1, + type: 'n8n-nodes-langchain.lmOllama', + position: [0, 0], + parameters: {}, +}; + +/** + * The lookup may travel to `proxyFetch` as a positional argument or as a field + * of an options object, so accept either shape. + */ +function lookupReachedProxyFetch(lookup: unknown): boolean { + return mockedProxyFetch.mock.calls.some((call) => + call.some( + (arg) => + arg === lookup || + (typeof arg === 'object' && + arg !== null && + (arg as { lookup?: unknown }).lookup === lookup), + ), + ); +} + +describe('LmOllama', () => { + let node: LmOllama; + + const setupContext = (credentials: Record, egressFilter: NodeEgressFilter) => { + const ctx = mockDeep(); + ctx.getNode.mockReturnValue(nodeDef); + ctx.getCredentials.mockResolvedValue(credentials); + ctx.getNodeParameter.mockImplementation((name: string) => { + if (name === 'model') return 'llama3'; + if (name === 'options') return {}; + return undefined; + }); + ctx.helpers.getSecureEgressFilter.mockReturnValue(egressFilter); + return ctx; + }; + + beforeEach(() => { + node = new LmOllama(); + vi.clearAllMocks(); + mockedProxyFetch.mockResolvedValue(new Response('ok', { status: 200 })); + }); + + it('routes requests through the egress filter lookup', async () => { + const secureLookup = vi.fn(); + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }; + const ctx = setupContext({ baseUrl: 'http://ollama.example.com:11434' }, egressFilter); + + await node.supplyData.call(ctx, 0); + + expect(egressFilter.createSecureLookup).toHaveBeenCalled(); + + const modelOptions = MockedOllama.mock.calls[0][0] as { fetch?: typeof fetch } | undefined; + expect(modelOptions?.fetch).toBeTypeOf('function'); + + await modelOptions?.fetch?.('http://ollama.example.com:11434/api/generate', {}); + + expect(mockedProxyFetch).toHaveBeenCalled(); + expect(lookupReachedProxyFetch(secureLookup)).toBe(true); + }); + + it('rejects a base URL the credential does not allow', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://not-allowed.example.com:11434', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'ollama.example.com', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('Domain not allowed'); + expect(MockedOllama).not.toHaveBeenCalled(); + }); + + it('rejects a credential restricted from use in this node', async () => { + const egressFilter: NodeEgressFilter = { + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), + }; + const ctx = setupContext( + { + baseUrl: 'http://ollama.example.com:11434', + allowedHttpRequestDomains: 'none', + }, + egressFilter, + ); + + await expect(node.supplyData.call(ctx, 0)).rejects.toThrow('configured to prevent use'); + expect(MockedOllama).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts index 160033c90a3..3d9436b16c3 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMOpenAi/LmOpenAi.node.ts @@ -1,6 +1,6 @@ import { OpenAI, type ClientOptions } from '@langchain/openai'; import { getProxyAgent, makeN8nLlmFailedAttemptHandler, N8nLlmTracing } from '@n8n/ai-utilities'; -import { assertCredentialAllowsUrl, NodeConnectionTypes } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; import type { INodeType, INodeTypeDescription, @@ -13,6 +13,8 @@ import { Container } from '@n8n/di'; import { AiConfig } from '@n8n/config'; import { getCustomCredentialHeader, mergeCustomHeaders } from '@utils/helpers'; +import { assertOpenAiCredentialAllowsUrl } from '../../vendors/OpenAi/helpers/credentials'; + type LmOpenAiOptions = { baseURL?: string; frequencyPenalty?: number; @@ -211,13 +213,11 @@ export class LmOpenAi implements INodeType { if (options.baseURL) { const credentials = await this.getCredentials('openAiApi'); - allowedDomains = assertCredentialAllowsUrl({ - node: this.getNode(), - credentialData: credentials, - url: options.baseURL, - pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined, - surface: 'OpenAI', - }); + allowedDomains = assertOpenAiCredentialAllowsUrl( + this.getNode(), + credentials, + options.baseURL, + ); uri = `${options.baseURL}/models`; } @@ -274,13 +274,7 @@ export class LmOpenAi implements INodeType { }; if (options.baseURL) { - assertCredentialAllowsUrl({ - node: this.getNode(), - credentialData: credentials, - url: options.baseURL, - pinnedUrl: typeof credentials.url === 'string' ? credentials.url : undefined, - surface: 'OpenAI', - }); + assertOpenAiCredentialAllowsUrl(this.getNode(), credentials, options.baseURL); configuration.baseURL = options.baseURL; } diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts index d91648c3b4e..d797bbdf8ab 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.ts @@ -194,8 +194,9 @@ export class LmChatMistralCloud implements INodeType { randomSeed: undefined, }) as Partial; + const lookup = this.helpers.getSecureEgressFilter().createSecureLookup(); const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => - await proxyFetch(input, init, {}); + await proxyFetch({ input, init, timeoutOptions: {}, lookup }); const httpClient = new HTTPClient({ fetcher: fetchWithTimeout }); const model = new ChatMistralAI({ diff --git a/packages/@n8n/nodes-langchain/nodes/llms/N8nNonEstimatingTracing.ts b/packages/@n8n/nodes-langchain/nodes/llms/N8nNonEstimatingTracing.ts index 61c541618af..27fbee76028 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/N8nNonEstimatingTracing.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/N8nNonEstimatingTracing.ts @@ -7,7 +7,7 @@ import type { } from '@langchain/core/load/serializable'; import type { BaseMessage } from '@langchain/core/messages'; import type { LLMResult } from '@langchain/core/outputs'; -import { logAiEvent } from '@n8n/ai-utilities'; +import { logAiEvent, redactHeaderValues } from '@n8n/ai-utilities'; import pick from 'lodash/pick'; import type { IDataObject, ISupplyDataFunctions, JsonObject } from 'n8n-workflow'; import { NodeConnectionTypes, NodeError, NodeOperationError } from 'n8n-workflow'; @@ -37,7 +37,10 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler { */ runsMap: Record = {}; - options = { + options: { + errorDescriptionMapper: (error: NodeError) => string | null | undefined; + redactedHeaders?: string[]; + } = { // Default(OpenAI format) parser errorDescriptionMapper: (error: NodeError) => error.description, }; @@ -46,6 +49,7 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler { private executionFunctions: ISupplyDataFunctions, options?: { errorDescriptionMapper?: (error: NodeError) => string; + redactedHeaders?: string[]; }, ) { super(); @@ -110,7 +114,10 @@ export class N8nNonEstimatingTracing extends BaseCallbackHandler { ? this.#parentRunIndex + this.executionFunctions.getNextRunIndex() : undefined; - const options = llm.type === 'constructor' ? llm.kwargs : llm; + const options = redactHeaderValues( + llm.type === 'constructor' ? llm.kwargs : llm, + this.options.redactedHeaders ?? [], + ); const { index } = this.executionFunctions.addInputData( this.connectionType, [ diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts index a39edfce749..80f5ce913f3 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/LmChatAnthropic.test.ts @@ -426,6 +426,7 @@ describe('LmChatAnthropic', () => { let mockLoadContext: ILoadOptionsFunctions; let mockGetCredentials: Mock; let fetchSpy: Mock; + const secureLookup = vi.fn(); beforeEach(() => { mockGetCredentials = vi.fn(); @@ -436,6 +437,11 @@ describe('LmChatAnthropic', () => { mockLoadContext = { getCredentials: mockGetCredentials, + helpers: { + getSecureEgressFilter: vi.fn().mockReturnValue({ + createSecureLookup: vi.fn().mockReturnValue(secureLookup), + }), + }, } as unknown as ILoadOptionsFunctions; }); @@ -481,12 +487,15 @@ describe('LmChatAnthropic', () => { const result = await searchModels.call(mockLoadContext); expect(fetchSpy).toHaveBeenCalledWith( - 'https://api.anthropic.com/v1/models', expect.objectContaining({ - headers: expect.objectContaining({ - 'x-api-key': 'test-api-key', - 'anthropic-version': '2023-06-01', + input: 'https://api.anthropic.com/v1/models', + init: expect.objectContaining({ + headers: expect.objectContaining({ + 'x-api-key': 'test-api-key', + 'anthropic-version': '2023-06-01', + }), }), + lookup: secureLookup, }), ); @@ -562,7 +571,9 @@ describe('LmChatAnthropic', () => { const { searchModels } = lmChatAnthropic.methods.listSearch; await searchModels.call(mockLoadContext); - expect(fetchSpy).toHaveBeenCalledWith(`${customURL}/v1/models`, expect.anything()); + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ input: `${customURL}/v1/models` }), + ); }); it('should handle empty model list', async () => { diff --git a/packages/@n8n/nodes-langchain/nodes/llms/test/N8nNonEstimatingTracing.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nNonEstimatingTracing.test.ts new file mode 100644 index 00000000000..9a60582a51a --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/test/N8nNonEstimatingTracing.test.ts @@ -0,0 +1,89 @@ +import type { Serialized } from '@langchain/core/load/serializable'; +import type { ISupplyDataFunctions } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import { N8nNonEstimatingTracing } from '../N8nNonEstimatingTracing'; + +describe('N8nNonEstimatingTracing', () => { + const executionFunctions = mock({ + addInputData: vi.fn().mockReturnValue({ index: 0 }), + addOutputData: vi.fn(), + getNode: vi.fn().mockReturnValue({ name: 'TestNode' }), + getNextRunIndex: vi.fn().mockReturnValue(0), + }); + + beforeEach(() => { + vi.clearAllMocks(); + executionFunctions.addInputData.mockReturnValue({ index: 0 }); + }); + + describe('handleLLMStart', () => { + const serializedModel: Serialized = { + lc: 1, + type: 'constructor', + id: ['langchain', 'chat_models', 'openai'], + kwargs: { + model: 'gpt-4', + configuration: { + baseURL: 'https://api.openai.com/v1', + defaultHeaders: { + 'User-Agent': 'n8n', + authorization: 'Bearer My_secret_API_key123456789', + 'x-secret-header': 'My_secret_API_key123456789', + }, + }, + }, + }; + + const getPersistedHeaders = () => { + const inputArg = executionFunctions.addInputData.mock.calls[0][1] as Array< + Array<{ json: { options: { configuration: { defaultHeaders: Record } } } }> + >; + return inputArg[0][0].json.options.configuration.defaultHeaders; + }; + + it('should mask declared header values in persisted input data', async () => { + const tracer = new N8nNonEstimatingTracing(executionFunctions, { + redactedHeaders: ['x-secret-header'], + }); + + await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123'); + + const persistedHeaders = getPersistedHeaders(); + expect(persistedHeaders['x-secret-header']).toBe('**********'); + // non-declared header is untouched + expect(persistedHeaders['User-Agent']).toBe('n8n'); + + // stored run details are masked the same way + const storedOptions = tracer.runsMap['run-123'].options as { + configuration: { defaultHeaders: Record }; + }; + expect(storedOptions.configuration.defaultHeaders['x-secret-header']).toBe('**********'); + + // the original serialized object is not mutated + expect( + (serializedModel.kwargs.configuration as { defaultHeaders: Record }) + .defaultHeaders['x-secret-header'], + ).toBe('My_secret_API_key123456789'); + }); + + it('should mask the always-redacted header names without being asked to', async () => { + const tracer = new N8nNonEstimatingTracing(executionFunctions); + + await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123'); + + // the floor every model gets: ALWAYS_REDACTED_HEADERS in redact-headers.ts, which + // covers the nodes that declare no header names of their own + expect(getPersistedHeaders().authorization).toBe('**********'); + }); + + it('should keep header values that are neither declared nor always-redacted', async () => { + const tracer = new N8nNonEstimatingTracing(executionFunctions); + + await tracer.handleLLMStart(serializedModel, ['hello'], 'run-123'); + + // masking is opt-in per header name, so a name the model never declared is kept + expect(getPersistedHeaders()['x-secret-header']).toBe('My_secret_API_key123456789'); + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts index 14a3cd9c7bc..6198a3001b8 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/McpClientTool/__test__/McpClientTool.node.test.ts @@ -3,6 +3,7 @@ import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { McpError, ErrorCode, CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { proxyFetch } from '@n8n/ai-utilities'; import { Container } from '@n8n/di'; +import { createResultOk } from '@n8n/utils/result'; import { StructuredToolkit } from 'n8n-core'; import { type IExecuteFunctions, @@ -11,6 +12,7 @@ import { type ILoadOptionsFunctions, type INode, type ISupplyDataFunctions, + type NodeEgressFilter, jsonParse, } from 'n8n-workflow'; import type { MockedFunction } from 'vitest'; @@ -33,6 +35,16 @@ vi.mock('@n8n/ai-utilities', async () => { const mockedProxyFetch = proxyFetch as MockedFunction; +const createTestEgressFilter = (): NodeEgressFilter => ({ + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn().mockReturnValue(vi.fn()), +}); + +const egressHelpers = (): Partial => + ({ + helpers: { getSecureEgressFilter: vi.fn(() => createTestEgressFilter()) }, + }) as Partial; + describe('McpClientTool', () => { beforeEach(() => { vi.resetAllMocks(); @@ -54,6 +66,7 @@ describe('McpClientTool', () => { const result = await getTools.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1 })), getNodeParameter: vi.fn((key: string) => { const parameters: Record = { @@ -78,6 +91,7 @@ describe('McpClientTool', () => { vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('Fail!')); const node = mock({ typeVersion: 1 }); const mockLoadOptionsFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => node), getNodeParameter: vi.fn((key: string) => { const parameters: Record = { @@ -111,6 +125,7 @@ describe('McpClientTool', () => { await getTools.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1 })), getNodeParameter: vi.fn((key: string) => { const parameters: Record = { @@ -148,6 +163,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -190,6 +206,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -237,6 +254,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -281,6 +299,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), getNodeParameter: vi.fn((key, _index) => { const parameters: Record = { @@ -312,7 +331,7 @@ describe('McpClientTool', () => { const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; await customFetch?.(url, {} as any); expect(mockedProxyFetch).toHaveBeenCalledOnce(); - const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0]; + const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0]; expect(requestedUrl).toBe(url); expect(requestInit?.redirect).toBe('manual'); const sentHeaders = new Headers(requestInit?.headers); @@ -336,6 +355,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), getNodeParameter: vi.fn((key, _index) => { const parameters: Record = { @@ -367,7 +387,7 @@ describe('McpClientTool', () => { const customFetch = vi.mocked(SSEClientTransport).mock.calls[0][1]?.eventSourceInit?.fetch; await customFetch?.(url, {} as any); expect(mockedProxyFetch).toHaveBeenCalledOnce(); - const [requestedUrl, requestInit] = mockedProxyFetch.mock.calls[0]; + const [{ input: requestedUrl, init: requestInit }] = mockedProxyFetch.mock.calls[0]; expect(requestedUrl).toBe(url); expect(requestInit?.redirect).toBe('manual'); const sentHeaders = new Headers(requestInit?.headers); @@ -393,6 +413,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -437,6 +458,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -474,6 +496,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -511,6 +534,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -547,6 +571,7 @@ describe('McpClientTool', () => { }); const supplyDataFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -596,6 +621,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -638,6 +664,7 @@ describe('McpClientTool', () => { await expect( new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -669,6 +696,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -712,6 +740,7 @@ describe('McpClientTool', () => { const errorLogger = vi.fn(); const supplyDataFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: errorLogger }, addInputData: vi.fn(() => ({ index: 0 })), @@ -747,6 +776,7 @@ describe('McpClientTool', () => { await expect( new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -780,6 +810,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'McpClientTool' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -819,6 +850,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, name: 'MCP Client' }); const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getNodeParameter: vi.fn((key, _index) => { const parameters: Record = { @@ -867,6 +899,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -935,6 +968,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1000,6 +1034,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1053,6 +1088,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1.2, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1106,6 +1142,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1152,6 +1189,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1.3, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1197,6 +1235,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1230,6 +1269,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1278,6 +1318,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1350,6 +1391,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1402,6 +1444,7 @@ describe('McpClientTool', () => { name: 'GitHub MCP', }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1457,6 +1500,7 @@ describe('McpClientTool', () => { const mockExecuteFunctions = mockDeep(); mockExecuteFunctions.getNode.mockReturnValue(mockNode); mockExecuteFunctions.getExecutionCancelSignal.mockReturnValue(abortController.signal); + mockExecuteFunctions.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter()); mockExecuteFunctions.getInputData.mockReturnValue([ { json: { @@ -1505,6 +1549,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [{ json: { tool: 'get_weather', location: 'Berlin' } }]), getNodeParameter: vi.fn((key) => { @@ -1546,6 +1591,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool', name: 'MCP Client' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1600,6 +1646,7 @@ describe('McpClientTool', () => { }); const mockSupplyDataFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -1641,6 +1688,7 @@ describe('McpClientTool', () => { }); const mockSupplyDataFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP Client' })), logger: { debug: vi.fn(), error: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), @@ -1676,6 +1724,7 @@ describe('McpClientTool', () => { name: 'McpClientTool', }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1719,6 +1768,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [ { @@ -1753,6 +1803,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]), getNodeParameter: vi.fn((key) => { @@ -1790,6 +1841,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [{ json: { location: 'Berlin' } }]), getNodeParameter: vi.fn((key) => { @@ -1819,6 +1871,7 @@ describe('McpClientTool', () => { const mockNode = mock({ typeVersion: 1, type: 'mcpClientTool' }); const mockExecuteFunctions = mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), getInputData: vi.fn(() => [{ json: { tool: 'get_weather' } }]), getNodeParameter: vi.fn((key) => { @@ -1857,6 +1910,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -1896,6 +1950,7 @@ describe('McpClientTool', () => { const supplyDataResult = await new McpClientTool().supplyData.call( mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -1936,6 +1991,7 @@ describe('McpClientTool', () => { credentials: Record; }) => mock({ + ...egressHelpers(), getNode: vi.fn(() => mock({ typeVersion: 1, @@ -2058,6 +2114,7 @@ describe('McpClientTool', () => { const node = mock({ typeVersion: 1 }); const ctx = mock({ + ...egressHelpers(), getNode: vi.fn(() => node), getNodeParameter: vi.fn((key) => { const params: Record = { @@ -2083,6 +2140,7 @@ describe('McpClientTool', () => { function createCtx(typeVersion: number) { const mockNode = mock({ typeVersion, type: 'mcpClientTool', name: 'MCP Client' }); return mock({ + ...egressHelpers(), getNode: vi.fn(() => mockNode), logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, getExecutionId: vi.fn(() => 'exec-gating'), diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts index d129c6fbfff..bdb59257a85 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/__test__/utils.test.ts @@ -35,6 +35,11 @@ vi.mock('@n8n/ai-utilities', async () => { const MockedClient = Client as MockedClass; +const createTestEgressFilter = (): NodeEgressFilter => ({ + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn(), +}); + describe('utils', () => { afterEach(() => { vi.restoreAllMocks(); @@ -368,6 +373,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -391,6 +397,7 @@ describe('utils', () => { await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer token' }, name: 'test-client', @@ -408,6 +415,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -424,6 +432,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -446,6 +455,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -474,6 +484,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -497,6 +508,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer token' }, name: 'test-client', @@ -514,6 +526,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -532,6 +545,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -552,6 +566,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -573,6 +588,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -595,6 +611,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -614,6 +631,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', name: 'test-client', version: 1, @@ -631,6 +649,7 @@ describe('utils', () => { await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer my-token' }, name: 'test-client', @@ -644,10 +663,10 @@ describe('utils', () => { expect(mockedProxyFetch).toHaveBeenCalled(); - const call = mockedProxyFetch.mock.calls[0]; + const [options] = mockedProxyFetch.mock.calls[0]; - expect(call[0]).toBe('https://example.com/mcp'); - expect(new Headers(call[1]?.headers).get('authorization')).toBe('Bearer my-token'); + expect(options.input).toBe('https://example.com/mcp'); + expect(new Headers(options.init?.headers).get('authorization')).toBe('Bearer my-token'); }); it('should preserve SDK headers passed as Headers instance', async () => { @@ -662,6 +681,7 @@ describe('utils', () => { await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer my-token' }, name: 'test-client', @@ -673,7 +693,7 @@ describe('utils', () => { const [, opts] = (TransportClass as Mock).mock.calls[0]; await opts.fetch('https://example.com', { headers: sdkHeaders }); - const [, callOpts] = mockedProxyFetch.mock.calls[0]; + const [{ init: callOpts }] = mockedProxyFetch.mock.calls[0]; expect(new Headers(callOpts?.headers).get('accept')).toBe('text/event-stream'); }); @@ -691,6 +711,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer old-token' }, name: 'test-client', @@ -717,6 +738,7 @@ describe('utils', () => { await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer old-token' }, name: 'test-client', @@ -739,6 +761,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer token' }, name: 'test-client', @@ -761,6 +784,7 @@ describe('utils', () => { const result = await connectMcpClient({ serverTransport: transport, + secureEgressFilter: createTestEgressFilter(), endpointUrl: 'https://example.com', headers: { Authorization: 'Bearer token' }, name: 'test-client', @@ -788,6 +812,7 @@ describe('utils', () => { ctx.helpers.refreshOAuth2Token.mockResolvedValue({ access_token: 'refreshed-token', }); + ctx.helpers.getSecureEgressFilter.mockReturnValue(createTestEgressFilter()); const connection = { nodeTypeName: '@n8n/mcp-registry.test', credentialType: 'testMcpOAuth2Api' as const, diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts index 670b3aa434b..672b218fd8b 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/runtime.test.ts @@ -1,5 +1,6 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { Container } from '@n8n/di'; +import { createResultOk } from '@n8n/utils/result'; import { mock } from 'vitest-mock-extended'; import { StructuredToolkit } from 'n8n-core'; import { @@ -9,6 +10,7 @@ import { type ILoadOptionsFunctions, type INode, type ISupplyDataFunctions, + type NodeEgressFilter, } from 'n8n-workflow'; import { McpClientsManager } from './McpClientsManager'; @@ -44,6 +46,16 @@ const baseConnectionConfig: McpConnectionConfig = { timeout: 60000, }; +const createTestEgressFilter = (): NodeEgressFilter => ({ + validateUrl: vi.fn().mockResolvedValue(createResultOk(undefined)), + createSecureLookup: vi.fn(), +}); + +const egressHelpers = (): Partial => + ({ + helpers: { getSecureEgressFilter: vi.fn(() => createTestEgressFilter()) }, + }) as Partial; + const sampleTool = { name: 'search', description: 'Search the workspace', @@ -59,6 +71,7 @@ function createSupplyDataCtx(overrides: Record = {}) { logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, addInputData: vi.fn(() => ({ index: 0 })), addOutputData: vi.fn(), + ...egressHelpers(), ...overrides, } as Partial); } @@ -71,6 +84,7 @@ function createExecuteCtx( getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP', type: 'mcp' })), logger: { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() }, getInputData: vi.fn(() => inputItems), + ...egressHelpers(), ...overrides, } as Partial); } @@ -428,6 +442,7 @@ describe('runtime', () => { getExecutionId: vi.fn(() => executionId), getExecutionCancelSignal: vi.fn(() => undefined), onExecutionCancellation: vi.fn(), + ...egressHelpers(), ...overrides, } as Partial); } @@ -538,6 +553,7 @@ describe('runtime', () => { vi.spyOn(Client.prototype, 'close').mockResolvedValue(); const ctx = mock({ getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP' })), + ...egressHelpers(), }); const result = await loadMcpToolOptions(ctx, baseConnectionConfig); @@ -556,6 +572,7 @@ describe('runtime', () => { vi.spyOn(Client.prototype, 'connect').mockRejectedValue(new Error('boom')); const ctx = mock({ getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP' })), + ...egressHelpers(), }); await expect(loadMcpToolOptions(ctx, baseConnectionConfig)).rejects.toThrow( @@ -569,6 +586,7 @@ describe('runtime', () => { const closeSpy = vi.spyOn(Client.prototype, 'close').mockResolvedValue(); const ctx = mock({ getNode: vi.fn(() => mock({ typeVersion: 1, name: 'MCP' })), + ...egressHelpers(), }); await expect(loadMcpToolOptions(ctx, baseConnectionConfig)).rejects.toThrow('list-failed'); diff --git a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts index 730d16740eb..dc1e5146daf 100644 --- a/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/mcp/shared/utils.ts @@ -168,11 +168,11 @@ export async function connectMcpClient({ */ allowedDomains?: string; /** - * Instance egress filter. When set, every request (including redirect hops) - * is validated against the configured egress policy, and the connection is + * Instance egress filter. Every request (including redirect hops) is + * validated against the configured egress policy, and the connection is * pinned to the validated address. */ - secureEgressFilter?: NodeEgressFilter; + secureEgressFilter: NodeEgressFilter; }): Promise> { const endpoint = normalizeAndValidateUrl(endpointUrl); @@ -180,7 +180,7 @@ export async function connectMcpClient({ return createResultError({ type: 'invalid_url', error: endpoint.error }); } - const authFetch = createAuthFetch(headers, onUnauthorized, allowedDomains, secureEgressFilter); + const authFetch = createAuthFetch(headers, secureEgressFilter, onUnauthorized, allowedDomains); const client = new Client({ name, version: version.toString() }, { capabilities: {} }); let onAbort: (() => void) | undefined; @@ -301,13 +301,13 @@ function headersToRecord(headers: HeadersInit | undefined): Record | undefined, + secureEgressFilter: NodeEgressFilter, onUnauthorized?: OnUnauthorizedHandler, allowedDomains?: string, - secureEgressFilter?: NodeEgressFilter, ): typeof fetch { - const secureLookup = secureEgressFilter?.createSecureLookup(); + const secureLookup = secureEgressFilter.createSecureLookup(); return createRefreshingAuthFetch({ - baseFetch: async (input, init) => await proxyFetch(input, init, undefined, secureLookup), + baseFetch: async (input, init) => await proxyFetch({ input, init, lookup: secureLookup }), initialHeaders, ...(onUnauthorized ? { @@ -317,10 +317,8 @@ function createAuthFetch( : {}), assertAllowedUrl: async (hopUrl) => { assertUrlAllowed({ url: hopUrl, allowedDomains }); - if (secureEgressFilter) { - const result = await secureEgressFilter.validateUrl(hopUrl); - if (!result.ok) throw result.error; - } + const result = await secureEgressFilter.validateUrl(hopUrl); + if (!result.ok) throw result.error; }, }); } @@ -482,7 +480,7 @@ export async function connectMcpClientForCredential( endpointUrl, headers: authHeaders, allowedDomains, - secureEgressFilter: ctx.helpers.getSecureEgressFilter?.(), + secureEgressFilter: ctx.helpers.getSecureEgressFilter(), name: node.type, version: node.typeVersion, onUnauthorized: async (h) => await tryRefreshOAuth2Token(ctx, config.authentication, h), diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/__tests__/microsoft-utils.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/__tests__/microsoft-utils.test.ts index 5b0bcc4bcba..096e8ae6a83 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/__tests__/microsoft-utils.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/__tests__/microsoft-utils.test.ts @@ -1,4 +1,4 @@ -import type { IWebhookFunctions } from 'n8n-workflow'; +import type { IWebhookFunctions, NodeEgressFilter } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -126,6 +126,8 @@ import { import { AgenticAuthenticationService } from '@microsoft/agents-a365-runtime'; import { proxyFetch } from '@n8n/ai-utilities'; +const testEgressFilter = mock(); + describe('microsoft-utils', () => { beforeAll(async () => { const actualMcpUtils = await vi.hoisted( @@ -211,6 +213,9 @@ describe('microsoft-utils', () => { nodeContext = mock({ getNodeParameter: vi.fn(), getNode: vi.fn().mockReturnValue({ name: 'Test Node' }), + helpers: { + getSecureEgressFilter: () => testEgressFilter, + } as unknown as IWebhookFunctions['helpers'], }); agent = { @@ -644,6 +649,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); expect(result).toBeUndefined(); @@ -680,6 +686,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', selectedTools, + testEgressFilter, ); expect(result).toBeDefined(); @@ -699,7 +706,13 @@ describe('microsoft-utils', () => { (getAllTools as Mock).mockResolvedValue([]); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); expect(connectMcpClient).toHaveBeenCalledWith({ serverTransport: 'httpStreamable', @@ -710,6 +723,7 @@ describe('microsoft-utils', () => { }, name: 'Microsoft-Agent-365', version: 1, + secureEgressFilter: testEgressFilter, }); }); @@ -739,7 +753,13 @@ describe('microsoft-utils', () => { (getAllTools as Mock).mockResolvedValue([]); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); expect(connectMcpClient).toHaveBeenCalledWith( expect.objectContaining({ @@ -783,13 +803,19 @@ describe('microsoft-utils', () => { }); (getAllTools as Mock).mockResolvedValue([]); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', [ - 'mcp_CalendarTools', - ]); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + ['mcp_CalendarTools'], + testEgressFilter, + ); expect(proxyFetch).toHaveBeenCalledWith( - 'https://agent365.svc.cloud.microsoft/agents/v2/agent-identity/mcpServers', - expect.any(Object), + expect.objectContaining({ + input: 'https://agent365.svc.cloud.microsoft/agents/v2/agent-identity/mcpServers', + init: expect.any(Object), + }), ); // mcp_CalendarTools has no audience → V1 server → uses shared token directly, // no OBO exchange needed @@ -821,7 +847,13 @@ describe('microsoft-utils', () => { try { await expect( - getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined), + getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ), ).rejects.toThrow( 'Failed to read MCP servers from endpoint: response is not a server list', ); @@ -861,7 +893,13 @@ describe('microsoft-utils', () => { const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); expect(consoleSpy).toHaveBeenCalledWith( 'Skipping MCP server mcp_CalendarTools: failed to connect', @@ -898,6 +936,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); expect(result).toBeDefined(); @@ -934,6 +973,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); expect(result).toBeUndefined(); @@ -967,6 +1007,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); await result?.client.close(); @@ -1000,6 +1041,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); expect(connectMcpClient).toHaveBeenCalledWith( @@ -1040,6 +1082,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); expect(result?.toolkits).toHaveLength(2); @@ -1069,6 +1112,7 @@ describe('microsoft-utils', () => { mockAuthorization, 'test-token', undefined, + testEgressFilter, ); // mcpToolToDynamicTool gets server-prefixed names, avoiding collision @@ -1102,7 +1146,13 @@ describe('microsoft-utils', () => { name: 'mcp_Calendar_Tools__v2__create_event', }); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); // Special chars (dash, dot, space, parens) all replaced with underscores expect(mcpToolToDynamicTool).toHaveBeenCalledWith( @@ -1125,7 +1175,13 @@ describe('microsoft-utils', () => { (createCallTool as Mock).mockReturnValue(mockCallTool); (mcpToolToDynamicTool as Mock).mockReturnValue({ name: 'trimmed' }); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); const calledWith = (mcpToolToDynamicTool as Mock).mock.calls[0][0]; // Tool name is always preserved; only the prefix is trimmed @@ -1145,7 +1201,13 @@ describe('microsoft-utils', () => { (mcpToolToDynamicTool as Mock).mockReturnValue({ name: toolName }); - await getMicrosoftMcpTools(mockTurnContext, mockAuthorization, 'test-token', undefined); + await getMicrosoftMcpTools( + mockTurnContext, + mockAuthorization, + 'test-token', + undefined, + testEgressFilter, + ); expect(mcpToolToDynamicTool).toHaveBeenCalledWith( expect.objectContaining({ name: toolName }), @@ -1200,6 +1262,7 @@ describe('microsoft-utils', () => { mockAuthorizationLogging, 'test-token', undefined, + testEgressFilter, ); expect(result).toBeDefined(); @@ -1225,6 +1288,7 @@ describe('microsoft-utils', () => { mockAuthorizationLogging, 'test-token', undefined, + testEgressFilter, ); await capturedToolFunc!({ maxResults: 5 }); @@ -1263,6 +1327,7 @@ describe('microsoft-utils', () => { mockAuthorizationLogging, 'test-token', undefined, + testEgressFilter, ); await capturedToolFunc!({ maxResults: 5 }); @@ -1289,6 +1354,7 @@ describe('microsoft-utils', () => { mockAuthorizationLogging, 'test-token', undefined, + testEgressFilter, ); // createCallTool is not called at setup — only when the tool is actually invoked @@ -1324,6 +1390,7 @@ describe('microsoft-utils', () => { mockAuthorizationLogging, 'test-token', undefined, + testEgressFilter, ); await capturedFuncs[0]({ query: 'today' }); @@ -1349,6 +1416,9 @@ describe('microsoft-utils', () => { nodeContext = mock({ getNodeParameter: vi.fn(), getNode: vi.fn().mockReturnValue({ name: 'Test Node' }), + helpers: { + getSecureEgressFilter: () => testEgressFilter, + } as unknown as IWebhookFunctions['helpers'], }); credentials = { diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/microsoft-utils.ts b/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/microsoft-utils.ts index a3799a59692..27c4a8846c4 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/microsoft-utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/Microsoft/microsoft-utils.ts @@ -13,6 +13,7 @@ import { type IDataObject, type IWebhookFunctions, type INodePropertyOptions, + type NodeEgressFilter, } from 'n8n-workflow'; import { @@ -223,17 +224,22 @@ function normalizeMcpServerConfig(rawServer: unknown): MCPServerConfig | undefin async function getMcpServerConfigsWithoutAudienceTokens( turnContext: TurnContext, mcpAuthToken: string, + secureEgressFilter: NodeEgressFilter, ) { MicrosoftToolingUtility.ValidateAuthToken(mcpAuthToken); const agenticAppId = MicrosoftRuntimeUtility.ResolveAgentIdentity(turnContext, mcpAuthToken); const endpoint = getToolingGatewayUrl(agenticAppId); - const response = await proxyFetch(endpoint, { - headers: MicrosoftToolingUtility.GetToolRequestHeaders( - mcpAuthToken, - turnContext, - MICROSOFT_TOOL_OPTIONS, - ), + const response = await proxyFetch({ + input: endpoint, + init: { + headers: MicrosoftToolingUtility.GetToolRequestHeaders( + mcpAuthToken, + turnContext, + MICROSOFT_TOOL_OPTIONS, + ), + }, + lookup: secureEgressFilter.createSecureLookup(), }); if (!response.ok) { @@ -361,6 +367,7 @@ export async function getMicrosoftMcpTools( authorization: Authorization, mcpAuthToken: string, selectedTools: string[] | undefined, + secureEgressFilter: NodeEgressFilter, ) { const configService: McpToolServerConfigurationService = new McpToolServerConfigurationService(); @@ -376,7 +383,11 @@ export async function getMicrosoftMcpTools( ); } catch (error) { console.warn('Microsoft SDK listToolServers failed, falling back to direct discovery'); - servers = await getMcpServerConfigsWithoutAudienceTokens(turnContext, mcpAuthToken); + servers = await getMcpServerConfigsWithoutAudienceTokens( + turnContext, + mcpAuthToken, + secureEgressFilter, + ); shouldAttachServerAuthorization = true; } @@ -418,6 +429,7 @@ export async function getMicrosoftMcpTools( headers, name: 'Microsoft-Agent-365', version: 1, + secureEgressFilter, }); if (!clientResult.ok) { @@ -558,6 +570,7 @@ export const configureActivityCallback = ( authorization, mcpTokenRef.token, selectedTools, + nodeContext.helpers.getSecureEgressFilter(), ); mcpClient = result?.client; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts new file mode 100644 index 00000000000..6673c13d931 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.test.ts @@ -0,0 +1,95 @@ +import type { ICredentialDataDecryptedObject, INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +import { assertOpenAiCredentialAllowsUrl } from './credentials'; + +describe('assertOpenAiCredentialAllowsUrl', () => { + const node = { + id: '1', + name: 'Test Node', + type: 'test', + typeVersion: 1, + position: [0, 0], + parameters: {}, + } as INode; + + it('should return undefined when the credential has no restriction mode', () => { + const result = assertOpenAiCredentialAllowsUrl(node, { apiKey: 'key' }, 'https://any.host/v1'); + + expect(result).toBeUndefined(); + }); + + it('should allow the credential URL in "none" mode', () => { + const credentials = { + apiKey: 'key', + url: 'https://api.openai.com/v1', + allowedHttpRequestDomains: 'none', + }; + + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://api.openai.com/v1'), + ).not.toThrow(); + }); + + it('should reject other URLs in "none" mode when the credential has a URL', () => { + const credentials = { + apiKey: 'key', + url: 'https://api.openai.com/v1', + allowedHttpRequestDomains: 'none', + }; + + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://other.host/v1'), + ).toThrow(NodeOperationError); + }); + + it.each([undefined, '', null])( + 'should fall back to the default URL in "none" mode when the credential URL is %p', + (url) => { + const credentials = { + apiKey: 'key', + url, + allowedHttpRequestDomains: 'none', + } as unknown as ICredentialDataDecryptedObject; + + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://api.openai.com/v1'), + ).not.toThrow(); + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://any.host/v1'), + ).toThrow(NodeOperationError); + }, + ); + + it('should allow a listed domain in "domains" mode and return the list', () => { + const credentials = { + apiKey: 'key', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'example.com, *.example.org', + }; + + const result = assertOpenAiCredentialAllowsUrl(node, credentials, 'https://example.com/v1'); + + expect(result).toBe('example.com, *.example.org'); + }); + + it('should reject an unlisted domain in "domains" mode', () => { + const credentials = { + apiKey: 'key', + allowedHttpRequestDomains: 'domains', + allowedDomains: 'example.com', + }; + + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://other.host/v1'), + ).toThrow(NodeOperationError); + }); + + it('should throw in "domains" mode when no domains are listed', () => { + const credentials = { apiKey: 'key', allowedHttpRequestDomains: 'domains' }; + + expect(() => + assertOpenAiCredentialAllowsUrl(node, credentials, 'https://example.com/v1'), + ).toThrow(NodeOperationError); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts new file mode 100644 index 00000000000..82db48f32f7 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/helpers/credentials.ts @@ -0,0 +1,30 @@ +import type { ICredentialDataDecryptedObject, INode } from 'n8n-workflow'; +import { assertCredentialAllowsUrl } from 'n8n-workflow'; + +/** Mirrors the fallback every OpenAI call site applies when the credential has no URL. */ +const DEFAULT_BASE_URL = 'https://api.openai.com/v1'; + +/** + * Enforces the credential's domain restrictions against a caller-supplied + * OpenAI-compatible base URL, treating the credential's effective endpoint — + * its own `url`, or the OpenAI default when that is unset — as the pinned URL + * for `'none'` mode. + * + * @returns {string | undefined} the allowed-domains list for per-redirect-hop checks; `undefined` means allow-all + * @throws {NodeOperationError} when the URL is outside the credential's allowed domains + */ +export function assertOpenAiCredentialAllowsUrl( + node: INode, + credentials: ICredentialDataDecryptedObject, + url: string, +): string | undefined { + const credentialUrl = typeof credentials.url === 'string' ? credentials.url : ''; + + return assertCredentialAllowsUrl({ + node, + credentialData: credentials, + url, + pinnedUrl: credentialUrl || DEFAULT_BASE_URL, + surface: 'OpenAI', + }); +} diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts index 96eea6c2b18..588276e8fd8 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/v1/actions/assistant/message.operation.ts @@ -11,7 +11,6 @@ import type { INodeProperties, } from 'n8n-workflow'; import { - assertCredentialAllowsUrl, BaseError, NodeConnectionTypes, NodeOperationError, @@ -23,6 +22,7 @@ import { promptTypeOptionsDeprecated } from '@utils/descriptions'; import { getConnectedTools, getPromptInputByType, mergeCustomHeaders } from '@utils/helpers'; import { getTracingConfig } from '@utils/tracing'; +import { assertOpenAiCredentialAllowsUrl } from '../../../helpers/credentials'; import { formatToOpenAIAssistantTool, getChatMessages } from '../../../helpers/utils'; import { assistantRLC } from '../descriptions'; import { getProxyAgent } from '@n8n/ai-utilities'; @@ -182,13 +182,7 @@ export async function execute(this: IExecuteFunctions, i: number): Promise { b.binaryExpression('===', v, b.literal(false)), ), v, - b.literal(''), + rawStringLiteral(''), ), ), ]); @@ -179,20 +179,16 @@ export const getExpressionCode = ( b.variableDeclaration('var', [b.variableDeclarator(globalIdentifier, b.objectExpression([]))]), ]); - // This is what's used to access that's passed in. For compatibility we us - // `this` unless the expression contains a function. If it contains an - // expression we instead assign a different variable to hold onto the contents - // of `this` (default: `___n8n_data`) since functions aren't compatibility - // anyway. - let dataNode: DataNode = b.thisExpression(); + // Hold the passed-in `this` in a lexically-bound variable (default: + // `___n8n_data`) and route all data access and the sanitizer through it. + // A lexical binding cannot be reached by member shadowing the way `this` + // can, and the reserved-name guards already block rebinding it. + const dataNode: DataNode = b.identifier(dataNodeName); + newProg.body.push( + b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]), + ); + // Reported back to callers, no longer used to shape the emitted code. const hasFn = chunks.filter((c) => c.type === 'code').some((c) => hasFunction(c.parsed)); - if (hasFn) { - dataNode = b.identifier(dataNodeName); - newProg.body.push( - b.variableDeclaration('var', [b.variableDeclarator(dataNode, b.thisExpression())]), - ); - } - const hasTempString = chunks .filter((c) => c.type === 'code') .some((c) => hasTemplateString(c.parsed)); @@ -211,7 +207,7 @@ export const getExpressionCode = ( for (const chunk of chunks) { // This is just a text chunks, push it up as a literal. if (chunk.type === 'text') { - parts.push(b.literal(chunk.text)); + parts.push(rawStringLiteral(chunk.text)); // This is a code chunk so do some magic } else { const fixed = fixStringNewLines(chunk.parsed); @@ -264,7 +260,7 @@ export const getExpressionCode = ( newProg.body.push( b.returnStatement( b.callExpression(b.memberExpression(b.arrayExpression(parts), b.identifier('join')), [ - b.literal(''), + rawStringLiteral(''), ]), ), ); diff --git a/packages/@n8n/tournament/src/VariablePolyfill.ts b/packages/@n8n/tournament/src/VariablePolyfill.ts index e716ec9e446..31adc56c49e 100644 --- a/packages/@n8n/tournament/src/VariablePolyfill.ts +++ b/packages/@n8n/tournament/src/VariablePolyfill.ts @@ -13,6 +13,26 @@ function assertNever(_value: never): _value is never { return true; } +// Captured at module load so printing stays stable even if the global is later replaced. +const safeStringify = JSON.stringify; + +// A string literal carrying its own printed form. recast's printer emits `extra.raw` +// verbatim (when it matches the value) instead of stringifying at print time. +export const rawStringLiteral = (value: string) => { + const literal = b.literal(value); + // JSON.stringify leaves U+2028/U+2029 unescaped; since the raw form is printed + // verbatim, escape them so the emitted literal stays single-line and valid. + const raw = safeStringify(value) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + // Attach `extra` in place so the ast-types node identity is preserved. + (literal as namedTypes.Literal & { extra?: { raw: string; rawValue: string } }).extra = { + raw, + rawValue: value, + }; + return literal; +}; + export const globalIdentifier = b.identifier( // @ts-expect-error window not in lib target typeof window !== 'object' ? 'global' : 'window', @@ -21,7 +41,7 @@ export const globalIdentifier = b.identifier( const buildGlobalSwitch = (node: types.namedTypes.Identifier, dataNode: DataNode) => { return b.memberExpression( b.conditionalExpression( - b.binaryExpression('in', b.literal(node.name), dataNode), + b.binaryExpression('in', rawStringLiteral(node.name), dataNode), dataNode, globalIdentifier, ), diff --git a/packages/@n8n/tournament/test/VariablePolyfill.test.ts b/packages/@n8n/tournament/test/VariablePolyfill.test.ts index c1367c7a2db..8fdeb343149 100644 --- a/packages/@n8n/tournament/test/VariablePolyfill.test.ts +++ b/packages/@n8n/tournament/test/VariablePolyfill.test.ts @@ -105,3 +105,52 @@ describe('jsVariablePolyfill', () => { }); }); }); + +describe('generated code', () => { + it('generates identifier code independent of the global JSON.stringify', () => { + const [pristine] = evaluator.getExpressionCode('{{ someFreeName }}'); + expect(pristine).toContain('"someFreeName"'); + + const bogus = 'BOGUS_STRINGIFY_OUTPUT'; + const originalStringify = JSON.stringify; + let generated: string; + try { + JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify; + [generated] = evaluator.getExpressionCode('{{ anotherFreeName }}'); + } finally { + JSON.stringify = originalStringify; + } + + expect(generated).not.toContain(bogus); + expect(generated).toBe(pristine.replace(/someFreeName/g, 'anotherFreeName')); + }); + + it('renders text chunks and the join separator independent of the global JSON.stringify', () => { + const [pristine] = evaluator.getExpressionCode('prefix {{ value }}'); + expect(pristine).toContain('["prefix "'); + expect(pristine).toContain('].join("")'); + + const bogus = 'BOGUS_STRINGIFY_OUTPUT'; + const originalStringify = JSON.stringify; + let generated: string; + try { + JSON.stringify = (() => bogus) as unknown as typeof JSON.stringify; + [generated] = evaluator.getExpressionCode('prefix {{ value }}'); + } finally { + JSON.stringify = originalStringify; + } + + // The text chunk and join separator render verbatim; neither is routed + // through the global. + expect(generated).toContain('["prefix "'); + expect(generated).toContain('].join("")'); + }); + + it.each([ + ['a line separator', 'a\u2028b'], + ['a paragraph separator', 'a\u2029b'], + ['quotes, a backslash and a newline', 'he said "hi"\\\n\ttab'], + ])('round-trips text chunks containing %s', (_, text) => { + expect(evaluator.execute(`${text} {{ 1 }}`, {})).toBe(`${text} 1`); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/errors.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/errors.ts index 38f3a4b581d..0b5402ae867 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/errors.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/errors.ts @@ -101,3 +101,18 @@ export class UnknownIdentifierError extends InterpreterError { this.identifier = identifier; } } + +/** + * Error thrown when interpretation exceeds a bound on statement count, + * total produced data, or expression nesting depth. + */ +export class ResourceLimitError extends InterpreterError { + readonly limitType: string; + + /** Pass `detail` for sentence-style messages to avoid double-wrapping `limitType`. */ + constructor(limitType: string, location?: SourceLocation, sourceCode?: string, detail?: string) { + super(detail ?? `Resource limit exceeded: '${limitType}'`, location, sourceCode); + this.name = 'ResourceLimitError'; + this.limitType = limitType; + } +} diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts index 01778c6b9b1..e757d5ba3a4 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/index.ts @@ -28,6 +28,7 @@ export { UnsupportedNodeError, SecurityError, UnknownIdentifierError, + ResourceLimitError, } from './errors'; export { parseSDKCode } from './parser'; export { diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts index f8e12bf2cfe..440349526fe 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.test.ts @@ -8,6 +8,7 @@ import { SecurityError, UnsupportedNodeError, UnknownIdentifierError, + ResourceLimitError, } from './errors'; import { expr } from '../expression'; import type { SDKFunctions } from './interpreter'; @@ -1066,6 +1067,288 @@ describe('AST Interpreter', () => { }); }); + describe('Resource limits', () => { + let sdkFunctions: SDKFunctions; + + beforeEach(() => { + sdkFunctions = createMockSDKFunctions(); + }); + + it('should reject exponential array-spread doubling before completing', () => { + const doublings = 25; // 2^25 elements if unbounded — must abort far earlier + const lines = ['const a0 = [1];']; + for (let i = 1; i <= doublings; i++) { + lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`); + } + lines.push(`export default a${doublings};`); + const code = lines.join('\n'); + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject cumulative object-spread growth across many statements', () => { + // Object spread merges by key (duplicate keys overwrite rather than + // duplicate), so it can't blow up exponentially the way array spread + // does — but copying a large object's keys over and over still adds + // up, so the shared budget must catch it across statements. + const keyCount = 2000; + const props = Array.from({ length: keyCount }, (_, i) => `k${i}: ${i}`).join(', '); + // Each merge copies keyCount slots but adds only ~23 characters of + // source, so the cost outgrows the source-derived budget. + const mergeCount = 500; + const lines = [`const big = { ${props} };`]; + + for (let i = 0; i < mergeCount; i++) { + lines.push(`const m${i} = { ...big };`); + } + + lines.push('export default m0;'); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject a huge String.repeat count', () => { + const code = 'export default "a".repeat(100000000);'; + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject a huge String.repeat count passed as a numeric string', () => { + // Native String.prototype.repeat coerces its argument, so a string-typed + // count must be charged the same as a numeric one. + const code = 'export default "a".repeat("100000000");'; + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject exponential string growth from repeated + concatenation', () => { + const doublings = 25; // 2^25 characters if unbounded — must abort far earlier + const lines = ['const s0 = "a";']; + for (let i = 1; i <= doublings; i++) { + lines.push(`const s${i} = s${i - 1} + s${i - 1};`); + } + + lines.push(`export default s${doublings};`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject a reference DAG (Directed Acyclic Graph) that doubles without ever spreading', () => { + // [a, a] is two references, no copy — must still cost what `a` holds. + const doublings = 25; + const lines = ['const a0 = [1];']; + for (let i = 1; i <= doublings; i++) { + lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`); + } + + lines.push(`export default a${doublings};`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should count empty containers when sizing repeated references', () => { + const doublings = 18; + const lines = ['const a0 = [];']; + for (let i = 1; i <= doublings; i++) { + lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`); + } + + lines.push(`export default a${doublings};`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject chained JSON.stringify that doubles its own output each round', () => { + // Each round wraps the previous string in a 2-element array and + // re-serializes it. Serializing writes both references out in full, + // so the output doubles while the structure stays tiny. + const rounds = 15; + const lines = ['const s0 = "x".repeat(1000);']; + for (let i = 1; i <= rounds; i++) { + lines.push(`const a${i} = [s${i - 1}, s${i - 1}];`, `const s${i} = JSON.stringify(a${i});`); + } + + lines.push(`export default s${rounds};`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject a DAG whose members were filled in by property assignment', () => { + // Nothing is serialized here, so only the recorded size of `holder` can + // catch the doubling: left at the size it had when it was built, the + // whole chain measures empty. + const code = [ + 'const big = "x".repeat(50000);', + 'const holder = {};', + 'holder.a = big;', + 'const a0 = [holder, holder];', + 'const a1 = [a0, a0];', + 'const a2 = [a1, a1];', + 'export default a2;', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject a DAG that repeats a long property name', () => { + // The name is written out once per occurrence, so it counts towards the + // output even though it is not part of any value. Sized like the case + // above, so a late limit would surface as a RangeError instead. + const doublings = 14; + const lines = [`const k = { "${'K'.repeat(20_000)}": 1 };`, 'const a0 = [k, k];']; + for (let i = 1; i <= doublings; i++) { + lines.push(`const a${i} = [a${i - 1}, a${i - 1}];`); + } + + lines.push(`export default JSON.stringify(a${doublings});`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should still allow ordinary property assignment', () => { + const code = [ + 'const cfg = {};', + 'cfg.name = "hello";', + 'cfg.items = [1, 2, 3];', + 'export default JSON.stringify(cfg);', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow(); + }); + + it('should reject deep nesting serialized with a wide indent', () => { + // Indentation grows with depth, so it has to be counted per node: left + // out, this reaches the engine's string ceiling and raises a RangeError + // while building instead. + const depth = 4900; + const lines = ['const d0 = [1];']; + for (let i = 1; i <= depth; i++) { + lines.push(`const d${i} = [d${i - 1}];`); + } + + lines.push(`export default JSON.stringify(d${depth}, null, 10);`); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject JSON.stringify traversing an externally produced oversized structure', () => { + // Untracked value (no tracked size) must still trip the JSON.stringify guard. + const bigArray = Array.from({ length: 200_001 }, (_, i) => i); // above the floor budget + const funcs: SDKFunctions = { + ...sdkFunctions, + getBig: vi.fn(() => bigArray), + }; + const code = 'export default JSON.stringify(getBig());'; + + expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError); + }); + + it('should accumulate JSON.stringify traversal across separate calls', () => { + // Each call stays under the limit on its own; together they must not. + const bigArray = Array.from({ length: 50_000 }, (_, i) => i); + const funcs: SDKFunctions = { + ...sdkFunctions, + getBig: vi.fn(() => bigArray), + }; + const code = [ + 'const s1 = JSON.stringify(getBig());', + 'const s2 = JSON.stringify(getBig());', + 'const s3 = JSON.stringify(getBig());', + 'const s4 = JSON.stringify(getBig());', + 'const s5 = JSON.stringify(getBig());', + 'export default s5;', + ].join('\n'); + + expect(() => interpretSDKCode(code, funcs)).toThrow(ResourceLimitError); + }); + + it('should reject a JSON.stringify replacer, which would displace the size guard', () => { + const code = 'export default JSON.stringify({ a: 1, b: 2 }, ["a"]);'; + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(UnsupportedNodeError); + }); + + it('should still allow JSON.stringify on a reasonably large legitimate structure', () => { + const code = [ + 'const items = [1, 2, 3, 4, 5];', + 'export default JSON.stringify({ items, name: "test" });', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow(); + }); + + it('should allow a large embedded literal to be serialized into a parameter', () => { + // The documented way to embed a page in a Code node. Passing the same + // string along must not be charged again for every hop it makes. + const html = 'y'.repeat(150_000); + const code = [ + `const html = "${html}";`, + 'const jsCode = JSON.stringify(html);', + 'export default { parameters: { jsCode } };', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow(); + }); + + it('should allow a multi-part concatenation around a large embedded literal', () => { + // Charging each step its whole result would make this cost O(n^2). + const html = 'y'.repeat(120_000); + const code = [ + `const html = "${html}";`, + 'const jsCode = "var h=" + JSON.stringify(html) + "; return h;";', + 'export default { parameters: { jsCode } };', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow(); + }); + + it('should reject unbounded template-literal growth from repeated big values', () => { + const code = [ + 'const big = "x".repeat(150000);', + 'const t1 = `${big}${big}`;', + 'const t2 = `${t1}${t1}`;', + 'export default t2;', + ].join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should reject programs with too many top-level statements', () => { + const lines: string[] = []; + for (let i = 0; i < 6000; i++) { + lines.push(`const v${i} = ${i};`); + } + + lines.push('export default v0;'); + const code = lines.join('\n'); + + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + + it('should still allow reasonably large legitimate literals and spreads', () => { + const items = Array.from({ length: 500 }, (_, i) => i).join(','); + const code = `const items = [${items}]; export default { ...{ items } };`; + + expect(() => interpretSDKCode(code, sdkFunctions)).not.toThrow(); + }); + + it('should cap unbounded recursion reached via template-literal runtime-variable detection', () => { + // Builds a deeply nested $-prefixed member chain inside a template literal, + // which is resolved via isN8nRuntimeVariable/expressionToString, not evaluate(). + let expression = '$json'; + for (let i = 0; i < 600; i++) { + expression += '.a'; + } + + const code = `export default \`\${${expression}}\`;`; + expect(() => interpretSDKCode(code, sdkFunctions)).toThrow(ResourceLimitError); + }); + }); + describe('expr(placeholder(...)) round-trip', () => { it('prepends = to the placeholder marker so it parses as an n8n expression', () => { const funcs: SDKFunctions = { diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.ts index 265f2d327d2..97ee2ec16c0 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/interpreter.ts @@ -9,6 +9,7 @@ import { UnsupportedNodeError, UnknownIdentifierError, SecurityError, + ResourceLimitError, } from './errors'; import { parseSDKCode } from './parser'; import { @@ -37,17 +38,32 @@ export type SDKFunctions = Record unknown>; */ class SDKInterpreter { private static readonly MAX_EVAL_DEPTH = 500; + private static readonly MAX_STATEMENT_COUNT = 5_000; + /** Smallest data budget any program gets, however short it is. */ + private static readonly MIN_ALLOCATED_UNITS = 200_000; + /** Extra data budget per character of source, on top of that MIN_ALLOCATED_UNITS minimum. */ + private static readonly ALLOCATION_AMPLIFICATION = 20; private sdkFunctions: Map unknown>; private variables: Map; private renamedVariables: Map = new Map(); private sourceCode: string; private evalDepth = 0; + /** Ceiling on the data this program may produce, derived in constructor from the input program length. */ + private readonly maxAllocatedUnits: number; + /** How much of that ceiling has been used so far. */ + private allocatedUnits = 0; + /** What each array/object holds, so referencing one can be sized without walking it again. */ + private valueWeights = new WeakMap(); constructor(sdkFunctions: SDKFunctions, sourceCode: string) { this.sdkFunctions = new Map(Object.entries(sdkFunctions)); this.variables = new Map(); this.sourceCode = sourceCode; + this.maxAllocatedUnits = Math.max( + SDKInterpreter.MIN_ALLOCATED_UNITS, + sourceCode.length * SDKInterpreter.ALLOCATION_AMPLIFICATION, + ); } private generateSafeName(name: string): string { @@ -65,6 +81,8 @@ class SDKInterpreter { * Interpret the AST program and return the result. */ interpret(ast: ESTree.Program): unknown { + this.assertAstBodyLengthWithinLimits(ast); + let result: unknown; for (const stmt of ast.body) { @@ -87,6 +105,24 @@ class SDKInterpreter { return result; } + /** + * Assert that the AST body length is within the allowed limits. + * Throws a ResourceLimitError if the limit is exceeded. + * @param ast + */ + private assertAstBodyLengthWithinLimits(ast: ESTree.Program): void { + if (ast.body.length <= SDKInterpreter.MAX_STATEMENT_COUNT) { + return; + } + + throw new ResourceLimitError( + 'statement-count', + ast.loc ?? undefined, + this.sourceCode, + `Program has ${ast.body.length} top-level statements; the limit is ${SDKInterpreter.MAX_STATEMENT_COUNT}.`, + ); + } + /** * Process a variable declaration. */ @@ -140,16 +176,12 @@ class SDKInterpreter { * Evaluate an expression and return its value. */ private evaluate(node: ESTree.Expression | ESTree.SpreadElement | null): unknown { - if (node === null) return undefined; - - if (this.evalDepth >= SDKInterpreter.MAX_EVAL_DEPTH) { - throw new InterpreterError( - 'Expression nesting too deep (possible cycle in method chain)', - node.loc ?? undefined, - this.sourceCode, - ); + if (node === null) { + return undefined; } + this.assertDepthWithinLimits(node); + this.evalDepth++; try { return this.evaluateNode(node); @@ -158,6 +190,142 @@ class SDKInterpreter { } } + /** + * Run a recursive helper under the same nesting-depth guard as `evaluate()`, + * for helpers that walk the AST directly instead of calling `evaluate()`. + */ + private withDepthGuard(node: { loc?: ESTree.SourceLocation | null }, fn: () => T): T { + // Assert the depth on every node visit, not just on the initial evaluate() call, to catch cycles in method chains. + this.assertDepthWithinLimits(node); + + this.evalDepth++; + try { + return fn(); + } finally { + this.evalDepth--; + } + } + + /** + * Assert that the current evaluation depth is within the allowed limits. + * Throws a ResourceLimitError if the limit is exceeded. + * @param node + */ + private assertDepthWithinLimits(node: { loc?: ESTree.SourceLocation | null }): void { + if (this.evalDepth < SDKInterpreter.MAX_EVAL_DEPTH) { + return; + } + + throw new ResourceLimitError( + 'nesting-depth', + node.loc ?? undefined, + this.sourceCode, + 'Expression nesting too deep (possible cycle in method chain)', + ); + } + + private chargeBudget(units: number, location?: ESTree.SourceLocation): void { + if (units <= 0) { + return; + } + + if (this.allocatedUnits + units <= this.maxAllocatedUnits) { + this.allocatedUnits += units; + return; + } + + throw new ResourceLimitError( + 'allocation-budget', + location, + this.sourceCode, + 'SDK code produced or traversed too much data. Limit is ' + + `${this.maxAllocatedUnits} total elements/characters.`, + ); + } + + /** How much data a value holds: a string's length, an array/object's tracked total, otherwise 1. */ + private sizeOf(value: unknown): number { + if (value !== null && typeof value === 'object') { + return Math.max(this.valueWeights.get(value) ?? 1, 1); + } + + if (typeof value === 'string') { + return Math.max(value.length, 1); + } + + return 1; + } + + private assertValueSizeWithinLimits(size: number, location?: ESTree.SourceLocation): void { + if (size <= this.maxAllocatedUnits) { + return; + } + + throw new ResourceLimitError( + 'allocation-budget', + location, + this.sourceCode, + `A single value grew to ${size} elements/characters; the limit is ${this.maxAllocatedUnits}.`, + ); + } + + /** + * Spaces JSON.stringify adds per level of nesting, which it never passes to + * the replacer. It caps both forms at ten, so charging has to cap them too. + */ + private calculateIndentationWidth(space: unknown): number { + const maxCap = 10; + if (typeof space === 'number') { + return Math.max(Math.min(Math.trunc(space), maxCap), 0); + } + + if (typeof space === 'string') { + return Math.min(space.length, maxCap); + } + + return 0; + } + + /** + * Serializing a POJO into JSON writes every repeated reference out in full, so the output can + * be much bigger than its input. + */ + private safeJSONStringify( + value: unknown, + space: unknown, + location?: ESTree.SourceLocation, + ): string | undefined { + this.assertValueSizeWithinLimits(this.sizeOf(value), location); + + const indentWidth = this.calculateIndentationWidth(space); + const depths = new WeakMap(); + const charge = (units: number) => this.chargeBudget(units, location); + + /** + * The replacer is the only hook into the walk, so it is where an oversized value + * can be stopped before the whole string exists. + */ + const replacer = function (this: unknown, key: string, val: unknown) { + // `this` is the array or object holding this node, and a holder is always + // reached before its members, so its depth is already recorded. + const depth = key === '' ? 0 : (depths.get(this as object) ?? 0) + 1; + if (val !== null && typeof val === 'object') { + depths.set(val, depth); + } + + charge(key.length + depth * indentWidth + (typeof val === 'string' ? val.length : 1)); + return val; + }; + + const result = JSON.stringify(value, replacer, space as string | number | undefined); + + if (typeof result === 'string') { + this.chargeBudget(result.length, location); + } + + return result; + } + private evaluateNode(node: ESTree.Expression | ESTree.SpreadElement): unknown { validateNodeType(node, this.sourceCode); @@ -255,6 +423,17 @@ class SDKInterpreter { const safeMethod = getSafeJSONMethod(memberExpr.object.name, methodName); if (safeMethod) { const args = node.arguments.map((arg) => this.evaluate(arg)); + if (methodName === 'stringify') { + // The replacer slot is reserved for the size guard below. + if (args[1] !== undefined && args[1] !== null) { + throw new UnsupportedNodeError( + 'JSON.stringify replacer', + node.loc ?? undefined, + this.sourceCode, + ); + } + return this.safeJSONStringify(args[0], args[2], node.loc ?? undefined); + } return safeMethod(...args); } } @@ -265,6 +444,13 @@ class SDKInterpreter { const safeStringMethod = getSafeStringMethod(thisArg, methodName); if (safeStringMethod) { const args = node.arguments.map((arg) => this.evaluate(arg)); + if (methodName === 'repeat' && typeof thisArg === 'string') { + // Coerce like native String.prototype.repeat does, so a numeric + // string (e.g. "1e9") can't dodge the charge below. + const coercedCount = Number(args[0]); + const count = Number.isFinite(coercedCount) && coercedCount > 0 ? coercedCount : 0; + this.chargeBudget(thisArg.length * count, node.loc ?? undefined); + } return safeStringMethod(...args); } @@ -354,13 +540,20 @@ class SDKInterpreter { */ private visitObjectExpression(node: ESTree.ObjectExpression): Record { const result: Record = {}; + let weight = 0; for (const prop of node.properties) { if (prop.type === 'SpreadElement') { // Handle spread: { ...obj } const spreadValue = this.evaluate(prop.argument); if (spreadValue && typeof spreadValue === 'object') { - Object.assign(result, spreadValue); + for (const key of Object.keys(spreadValue)) { + const value = (spreadValue as Record)[key]; + this.chargeBudget(1, prop.loc ?? undefined); + result[key] = value; + + weight += this.sizeOf(value); + } } } else if (prop.type === 'Property') { // Get key @@ -381,10 +574,14 @@ class SDKInterpreter { // Get value (handle shorthand: { name } === { name: name }) const value = this.evaluate(prop.value as ESTree.Expression); + this.chargeBudget(1, prop.loc ?? undefined); result[key] = value; + weight += this.sizeOf(value); } } + this.assertValueSizeWithinLimits(weight, node.loc ?? undefined); + this.valueWeights.set(result, weight); return result; } @@ -393,22 +590,31 @@ class SDKInterpreter { */ private visitArrayExpression(node: ESTree.ArrayExpression): unknown[] { const result: unknown[] = []; + let weight = 0; for (const element of node.elements) { if (element === null) { result.push(undefined); + weight += 1; } else if (element.type === 'SpreadElement') { const spreadValue = this.evaluate(element.argument); if (Array.isArray(spreadValue)) { for (const item of spreadValue) { + this.chargeBudget(1, element.loc ?? undefined); result.push(item); + weight += this.sizeOf(item); } } } else { - result.push(this.evaluate(element)); + const value = this.evaluate(element); + this.chargeBudget(1, element.loc ?? undefined); + result.push(value); + weight += this.sizeOf(value); } } + this.assertValueSizeWithinLimits(weight, node.loc ?? undefined); + this.valueWeights.set(result, weight); return result; } @@ -461,6 +667,8 @@ class SDKInterpreter { if (i < node.expressions.length) { const expr = node.expressions[i]; const value = this.evaluateTemplateExpression(expr); + // charge 1 unit to the budget for each character in the evaluated expression to avoid huge template literal output + this.chargeBudget(value.length, expr.loc ?? undefined); result += value; } } @@ -490,21 +698,27 @@ class SDKInterpreter { * These start with $ like $json, $today, $input, etc. */ private isN8nRuntimeVariable(expr: ESTree.Expression): boolean { - if (expr.type === 'Identifier') { - return expr.name.startsWith('$'); - } - if (expr.type === 'MemberExpression') { - return this.isN8nRuntimeVariable(expr.object as ESTree.Expression); - } - if (expr.type === 'CallExpression') { - if (expr.callee.type === 'Identifier') { - return expr.callee.name.startsWith('$'); + return this.withDepthGuard(expr, () => { + if (expr.type === 'Identifier') { + return expr.name.startsWith('$'); } - if (expr.callee.type === 'MemberExpression') { - return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression); + + if (expr.type === 'MemberExpression') { + return this.isN8nRuntimeVariable(expr.object as ESTree.Expression); } - } - return false; + + if (expr.type === 'CallExpression') { + if (expr.callee.type === 'Identifier') { + return expr.callee.name.startsWith('$'); + } + + if (expr.callee.type === 'MemberExpression') { + return this.isN8nRuntimeVariable(expr.callee.object as ESTree.Expression); + } + } + + return false; + }); } /** @@ -512,67 +726,81 @@ class SDKInterpreter { * Used for preserving n8n runtime variables in template literals. */ private expressionToString(expr: ESTree.Expression): string { - if (expr.type === 'Identifier') { - return expr.name; - } - if (expr.type === 'MemberExpression') { - const obj = this.expressionToString(expr.object as ESTree.Expression); - if (expr.computed) { - if (expr.property.type === 'Literal') { - return `${obj}[${JSON.stringify(expr.property.value)}]`; - } - return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`; + return this.withDepthGuard(expr, () => { + if (expr.type === 'Identifier') { + return expr.name; } - const prop = expr.property.type === 'Identifier' ? expr.property.name : ''; - return `${obj}.${prop}`; - } - if (expr.type === 'CallExpression') { - const callee = this.expressionToString(expr.callee as ESTree.Expression); - const args = expr.arguments - .map((arg) => { - if (arg.type === 'SpreadElement') { - return '...' + this.expressionToString(arg.argument); + + if (expr.type === 'MemberExpression') { + const obj = this.expressionToString(expr.object as ESTree.Expression); + + if (expr.computed) { + if (expr.property.type === 'Literal') { + return `${obj}[${JSON.stringify(expr.property.value)}]`; } - return this.expressionToString(arg); - }) - .join(', '); - return `${callee}(${args})`; - } - if (expr.type === 'Literal') { - if (typeof expr.value === 'string') { - return JSON.stringify(expr.value); + return `${obj}[${this.expressionToString(expr.property as ESTree.Expression)}]`; + } + + const prop = expr.property.type === 'Identifier' ? expr.property.name : ''; + return `${obj}.${prop}`; } - return String(expr.value); - } - if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') { - const left = this.expressionToString(expr.left as ESTree.Expression); - const right = this.expressionToString(expr.right); - return `${left} ${expr.operator} ${right}`; - } - if (expr.type === 'ConditionalExpression') { - const test = this.expressionToString(expr.test); - const consequent = this.expressionToString(expr.consequent); - const alternate = this.expressionToString(expr.alternate); - return `${test} ? ${consequent} : ${alternate}`; - } - if (expr.type === 'UnaryExpression') { - const unary = expr; - const arg = this.expressionToString(unary.argument); - return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`; - } - // Fallback: extract from source code if we have location info - if (expr.loc && this.sourceCode) { - const lines = this.sourceCode.split('\n'); - const startLine = expr.loc.start.line - 1; - const endLine = expr.loc.end.line - 1; + if (expr.type === 'CallExpression') { + const callee = this.expressionToString(expr.callee as ESTree.Expression); + const args = expr.arguments + .map((arg) => { + if (arg.type === 'SpreadElement') { + return '...' + this.expressionToString(arg.argument); + } + return this.expressionToString(arg); + }) + .join(', '); - if (startLine === endLine) { - return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column); + return `${callee}(${args})`; } - } - return ''; + if (expr.type === 'Literal') { + if (typeof expr.value === 'string') { + return JSON.stringify(expr.value); + } + return String(expr.value); + } + + if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') { + const left = this.expressionToString(expr.left as ESTree.Expression); + const right = this.expressionToString(expr.right); + + return `${left} ${expr.operator} ${right}`; + } + + if (expr.type === 'ConditionalExpression') { + const test = this.expressionToString(expr.test); + const consequent = this.expressionToString(expr.consequent); + const alternate = this.expressionToString(expr.alternate); + + return `${test} ? ${consequent} : ${alternate}`; + } + + if (expr.type === 'UnaryExpression') { + const unary = expr; + const arg = this.expressionToString(unary.argument); + + return unary.prefix ? `${unary.operator}${arg}` : `${arg}${unary.operator}`; + } + + // Fallback: extract from source code if we have location info + if (expr.loc && this.sourceCode) { + const lines = this.sourceCode.split('\n'); + const startLine = expr.loc.start.line - 1; + const endLine = expr.loc.end.line - 1; + + if (startLine === endLine) { + return lines[startLine].slice(expr.loc.start.column, expr.loc.end.column); + } + } + + return ''; + }); } /** @@ -618,6 +846,9 @@ class SDKInterpreter { switch (node.operator) { case '+': if (typeof left === 'string' || typeof right === 'string') { + // Only the appended part is new; charging the whole result would + // make an n-piece chain cost O(n^2) for linear output. + this.chargeBudget(String(right).length, node.loc ?? undefined); return String(left) + String(right); } return (left as number) + (right as number); @@ -744,7 +975,16 @@ class SDKInterpreter { } const value = this.evaluate(node.right); + this.chargeBudget(1, node.loc ?? undefined); (obj as Record)[propName] = value; + + // Assigning grows the target, so its recorded size has to grow with it or + // every later check reads the size it had when it was built. An overwrite + // is counted as an addition, which overstates it in the safe direction. + const grown = this.sizeOf(obj) + this.sizeOf(value); + this.assertValueSizeWithinLimits(grown, node.loc ?? undefined); + this.valueWeights.set(obj as object, grown); + return value; } diff --git a/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts b/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts index 17a4eee36d5..98112d8393d 100644 --- a/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts +++ b/packages/@n8n/workflow-sdk/src/ast-interpreter/validators.ts @@ -421,4 +421,5 @@ export const SDK_INLINE_CONSTRAINTS: readonly string[] = [ 'No destructuring in declarations — declare each value with its own `const`.', 'No variable reassignment — only property assignment (`obj.prop = value`) is allowed.', "No computed/dynamic member access (`obj[expr]`); use static names. Literal keys like `obj['a']` or `arr[0]` are fine.", + '`JSON.stringify(value)` takes no replacer argument — pass `null` if you need the indentation argument.', ]; diff --git a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts index 074800c7441..d69f415d95b 100644 --- a/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts +++ b/packages/@n8n/workflow-sdk/src/codegen/parse-workflow-code.test.ts @@ -176,4 +176,24 @@ describe('parseWorkflowCodeToBuilder', () => { ); }); }); + + describe('resource limits', () => { + it('should surface a resource-limit violation as a SyntaxError', () => { + const doublings = 25; + const lines = ['const a0 = [1];']; + + for (let i = 1; i <= doublings; i++) { + lines.push(`const a${i} = [...a${i - 1}, ...a${i - 1}];`); + } + + lines.push(`export default a${doublings};`); + const code = lines.join('\n'); + + // Match the specific message, not just SyntaxError: exporting a bare + // array is also invalid on its own (wrong export type), so asserting + // only the error class would pass even if the resource limit were + // never enforced. + expect(() => parseWorkflowCodeToBuilder(code)).toThrow(/too much data/); + }); + }); }); diff --git a/packages/cli/src/__tests__/active-workflow-manager.test.ts b/packages/cli/src/__tests__/active-workflow-manager.test.ts index 21d30836680..efcd2c76bde 100644 --- a/packages/cli/src/__tests__/active-workflow-manager.test.ts +++ b/packages/cli/src/__tests__/active-workflow-manager.test.ts @@ -5,6 +5,7 @@ import type { GlobalConfig, WorkflowsConfig } from '@n8n/config'; import type { Project, WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db'; import type { UpdateResult } from '@n8n/typeorm'; import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise'; +import { sleep } from '@n8n/utils/sleep'; import type { ErrorReporter, InstanceSettings } from 'n8n-core'; import { ActiveWorkflowTriggers, @@ -26,7 +27,6 @@ import type { WorkflowActivateMode, WorkflowExecuteMode, } from 'n8n-workflow'; -import { sleep } from '@n8n/utils/sleep'; import { Workflow, WorkflowActivationError } from 'n8n-workflow'; import { mock, type MockProxy } from 'vitest-mock-extended'; @@ -49,6 +49,8 @@ import type { OwnershipService } from '@/services/ownership.service'; import type { PollCursorService } from '@/workflows/triggers/poll-cursor.service'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; import type { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; +import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import type { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; describe('ActiveWorkflowManager', () => { @@ -85,11 +87,11 @@ describe('ActiveWorkflowManager', () => { instanceSettings, mock(), workflowsConfig, - mock(), mock(), mock(), mock(), // scheduleTriggerJobRegistrar mock(), // pollTriggerJobRegistrar + mock(), // workflowPushNotifier policyEnforcementService, ownershipService, ); @@ -223,9 +225,9 @@ describe('ActiveWorkflowManager', () => { instanceSettings, mock(), policyWorkflowsConfig, - mock(), mock(), mock(), + mock(), mock(), // scheduleTriggerJobRegistrar mock(), // pollTriggerJobRegistrar policyEnforcementService, @@ -414,8 +416,13 @@ describe('ActiveWorkflowManager', () => { describe('handleAddWebhooksAndNonWebhookTriggers', () => { const push = mock(); const publisher = mock(); + const workflowSharingService = mock(); + const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService); + const sharedUserIds = ['user-1', 'user-2']; beforeEach(() => { + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds); + publisher.publishCommand.mockResolvedValue(undefined); activeWorkflowManager = new ActiveWorkflowManager( mockLogger(), mock(), @@ -430,11 +437,11 @@ describe('ActiveWorkflowManager', () => { instanceSettings, publisher, mock(), - push, mock(), mock(), mock(), // scheduleTriggerJobRegistrar mock(), // pollTriggerJobRegistrar + workflowPushNotifier, policyEnforcementService, ownershipService, ); @@ -460,8 +467,8 @@ describe('ActiveWorkflowManager', () => { expect(clearWebhooksSpy).not.toHaveBeenCalled(); expect(removeTriggersSpy).not.toHaveBeenCalled(); // The user is still told why it did not start. - expect(push.broadcast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'workflowFailedToActivate' }), + expect(push.sendToUsers.mock.calls.map(([msg]) => msg.type)).toContain( + 'workflowFailedToActivate', ); }); @@ -479,14 +486,20 @@ describe('ActiveWorkflowManager', () => { activationMode: 'activate', }); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowFailedToActivate', - data: { - workflowId: 'wf-1', - errorMessage: 'Invalid role: admin required', - nodeId: 'node-123', + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + 'wf-1', + ); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowFailedToActivate', + data: { + workflowId: 'wf-1', + errorMessage: 'Invalid role: admin required', + nodeId: 'node-123', + }, }, - }); + sharedUserIds, + ); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-activation-error', @@ -507,10 +520,13 @@ describe('ActiveWorkflowManager', () => { activationMode: 'activate', }); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowFailedToActivate', - data: { workflowId: 'wf-1', errorMessage: 'Some error' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowFailedToActivate', + data: { workflowId: 'wf-1', errorMessage: 'Some error' }, + }, + sharedUserIds, + ); }); test('should tear down partial registrations before deactivating when activation fails', async () => { @@ -580,6 +596,180 @@ describe('ActiveWorkflowManager', () => { activeVersionId: null, }); }); + + test('should push workflowActivated to accessible users and relay it on successful activation', async () => { + vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({ + webhooks: true, + triggersAndPollers: true, + }); + + await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({ + workflowId: 'wf-1', + activeVersionId: 'v1', + activationMode: 'activate', + }); + + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + 'wf-1', + ); + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } }, + sharedUserIds, + ); + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'display-workflow-activation', + payload: { workflowId: 'wf-1', activeVersionId: 'v1' }, + }); + expect(workflowRepository.update).not.toHaveBeenCalled(); + }); + + test('should not roll back a successfully activated workflow when nobody can be resolved to notify', async () => { + vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({ + webhooks: true, + triggersAndPollers: true, + }); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValueOnce([]); + + await activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({ + workflowId: 'wf-1', + activeVersionId: 'v1', + activationMode: 'activate', + }); + + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v1' } }, + [], + ); + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'display-workflow-activation', + payload: { workflowId: 'wf-1', activeVersionId: 'v1' }, + }); + expect(workflowRepository.update).not.toHaveBeenCalled(); + }); + + test('should not roll back a successfully activated workflow even if the recipient lookup itself throws', async () => { + vi.spyOn(activeWorkflowManager, 'add').mockResolvedValue({ + webhooks: true, + triggersAndPollers: true, + }); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockRejectedValueOnce( + new Error('db unavailable'), + ); + + await expect( + activeWorkflowManager.handleAddWebhooksAndNonWebhookTriggers({ + workflowId: 'wf-1', + activeVersionId: 'v1', + activationMode: 'activate', + }), + ).rejects.toThrow('db unavailable'); + + expect(workflowRepository.update).not.toHaveBeenCalled(); + expect(push.sendToUsers).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'workflowFailedToActivate' }), + expect.anything(), + ); + }); + }); + + describe('display and removal pubsub handlers', () => { + const push = mock(); + const publisher = mock(); + const workflowSharingService = mock(); + const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService); + const sharedUserIds = ['user-1', 'user-2']; + + beforeEach(() => { + vi.clearAllMocks(); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(sharedUserIds); + activeWorkflowManager = new ActiveWorkflowManager( + mockLogger(), + mock(), + mock(), + mock(), + nodeTypes, + mock(), + workflowRepository, + mock(), + mock(), + mock(), + instanceSettings, + publisher, + mock(), + mock(), + mock(), + mock(), // scheduleTriggerJobRegistrar + mock(), // pollTriggerJobRegistrar + workflowPushNotifier, + policyEnforcementService, + ownershipService, + ); + }); + + test.each([ + { + name: 'handleDisplayWorkflowActivation', + call: async () => + await activeWorkflowManager.handleDisplayWorkflowActivation({ + workflowId: 'wf-1', + activeVersionId: 'v1', + }), + expectedMsg: { + type: 'workflowActivated', + data: { workflowId: 'wf-1', activeVersionId: 'v1' }, + }, + }, + { + name: 'handleDisplayWorkflowDeactivation', + call: async () => + await activeWorkflowManager.handleDisplayWorkflowDeactivation({ workflowId: 'wf-1' }), + expectedMsg: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, + }, + { + name: 'handleDisplayWorkflowActivationError', + call: async () => + await activeWorkflowManager.handleDisplayWorkflowActivationError({ + workflowId: 'wf-1', + errorMessage: 'boom', + nodeId: 'node-1', + }), + expectedMsg: { + type: 'workflowFailedToActivate', + data: { + workflowId: 'wf-1', + errorMessage: 'boom', + errorDescription: undefined, + nodeId: 'node-1', + }, + }, + }, + ])('$name sends its push message only to accessible users', async ({ call, expectedMsg }) => { + await call(); + + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + 'wf-1', + ); + expect(push.sendToUsers).toHaveBeenCalledWith(expectedMsg, sharedUserIds); + }); + + test('handleRemoveNonWebhookTriggers sends workflowDeactivated only to accessible users and relays it', async () => { + vi.spyOn(activeWorkflowManager, 'removeActivationError').mockResolvedValue(); + vi.spyOn(activeWorkflowManager, 'removeNonWebhookTriggers').mockResolvedValue(); + + await activeWorkflowManager.handleRemoveNonWebhookTriggers({ workflowId: 'wf-1' }); + + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + 'wf-1', + ); + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, + sharedUserIds, + ); + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'display-workflow-deactivation', + payload: { workflowId: 'wf-1' }, + }); + }); }); describe('activateWorkflow', () => { @@ -715,11 +905,11 @@ describe('ActiveWorkflowManager', () => { instanceSettings, mock(), // publisher workflowsConfig, - mock(), // push factory, mock(), // eventBus mock(), // scheduleTriggerJobRegistrar mock(), // pollTriggerJobRegistrar + mock(), // workflowPushNotifier policyEnforcementService, ownershipService, ); @@ -1168,11 +1358,11 @@ describe('ActiveWorkflowManager', () => { instanceSettings, mock(), workflowsConfig, - mock(), mock(), mock(), scheduleTriggerJobRegistrar, pollTriggerJobRegistrar, + mock(), // workflowPushNotifier policyEnforcementService, ownershipService, ); @@ -1356,11 +1546,11 @@ describe('ActiveWorkflowManager', () => { mock({ isMultiMain: true }), publisher, workflowsConfig, - mock(), mock(), mock(), scheduleTriggerJobRegistrar, pollTriggerJobRegistrar, + mock(), // workflowPushNotifier policyEnforcementService, ownershipService, ); @@ -1435,11 +1625,11 @@ describe('ActiveWorkflowManager', () => { instanceSettings, mock(), workflowsConfig, - mock(), mock(), mock(), scheduleTriggerJobRegistrar, mock(), // pollTriggerJobRegistrar + mock(), // workflowPushNotifier policyEnforcementService, ownershipService, ); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index b01541c8e36..523bb05500d 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -1,16 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { - STARTING_NODES, - TRIGGER_COUNT_EXCLUDED_NODES, - WORKFLOW_REACTIVATE_INITIAL_TIMEOUT, - WORKFLOW_REACTIVATE_MAX_TIMEOUT, -} from '@/constants'; import { Logger } from '@n8n/backend-common'; import { WorkflowsConfig } from '@n8n/config'; import type { WorkflowEntity, IWorkflowDb } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db'; import { OnLeaderStepdown, OnLeaderTakeover, OnPubSubEvent, OnShutdown } from '@n8n/decorators'; import { Service } from '@n8n/di'; +import { ensureError } from '@n8n/utils/errors/ensure-error'; import chunk from 'lodash/chunk'; import { ActiveWorkflowTriggers, @@ -29,7 +24,6 @@ import type { INodeType, WorkflowId, } from 'n8n-workflow'; -import { ensureError } from '@n8n/utils/errors/ensure-error'; import { Workflow, WorkflowActivationError, @@ -41,25 +35,31 @@ import { import { strict } from 'node:assert'; import { ActivationErrorsService } from '@/activation-errors.service'; +import { + STARTING_NODES, + TRIGGER_COUNT_EXCLUDED_NODES, + WORKFLOW_REACTIVATE_INITIAL_TIMEOUT, + WORKFLOW_REACTIVATE_MAX_TIMEOUT, +} from '@/constants'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { ExternalHooks } from '@/external-hooks'; import { NodeTypes } from '@/node-types'; import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import { isPolicyRefusal } from '@/policy/policy-violation.error'; -import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map'; -import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar'; import { PollTriggerJobRegistrar } from '@/scheduling/poll-trigger-node/poll-trigger-job-registrar'; +import type { ScheduleTriggerCollectionSession } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar'; import { ScheduleTriggerJobRegistrar } from '@/scheduling/schedule-trigger-node/schedule-trigger-job-registrar'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import { OwnershipService } from '@/services/ownership.service'; import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import { WebhookService } from '@/webhooks/webhook.service'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; -import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { TriggerExecutionContextFactory } from '@/workflows/triggers/trigger-execution-context.factory'; import { getErrorDescription, getErrorNodeId } from '@/workflows/utils'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; +import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { formatWorkflow } from '@/workflows/workflow.formatter'; interface QueuedActivation { @@ -87,11 +87,11 @@ export class ActiveWorkflowManager { private readonly instanceSettings: InstanceSettings, private readonly publisher: Publisher, private readonly workflowsConfig: WorkflowsConfig, - private readonly push: Push, private readonly triggerExecutionContextFactory: TriggerExecutionContextFactory, private readonly eventBus: MessageEventBus, private readonly scheduleTriggerJobRegistrar: ScheduleTriggerJobRegistrar, private readonly pollTriggerJobRegistrar: PollTriggerJobRegistrar, + private readonly workflowPushNotifier: WorkflowPushNotifier, private readonly policyEnforcementService: PolicyEnforcementService, private readonly ownershipService: OwnershipService, ) { @@ -699,20 +699,26 @@ export class ActiveWorkflowManager { } @OnPubSubEvent('display-workflow-activation', { instanceType: 'main' }) - handleDisplayWorkflowActivation({ + async handleDisplayWorkflowActivation({ workflowId, activeVersionId, }: PubSubCommandMap['display-workflow-activation']) { - this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } }); + await this.workflowPushNotifier.notify(workflowId, { + type: 'workflowActivated', + data: { workflowId, activeVersionId }, + }); } @OnPubSubEvent('display-workflow-deactivation', { instanceType: 'main' }) - handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) { - this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }); + async handleDisplayWorkflowDeactivation({ workflowId }: { workflowId: string }) { + await this.workflowPushNotifier.notify(workflowId, { + type: 'workflowDeactivated', + data: { workflowId }, + }); } @OnPubSubEvent('display-workflow-activation-error', { instanceType: 'main' }) - handleDisplayWorkflowActivationError({ + async handleDisplayWorkflowActivationError({ workflowId, errorMessage, errorDescription, @@ -723,7 +729,7 @@ export class ActiveWorkflowManager { errorDescription?: string; nodeId?: string; }) { - this.push.broadcast({ + await this.workflowPushNotifier.notify(workflowId, { type: 'workflowFailedToActivate', data: { workflowId, errorMessage, errorDescription, nodeId }, }); @@ -742,13 +748,6 @@ export class ActiveWorkflowManager { await this.add(workflowId, activationMode, undefined, { shouldPublish: false, // prevent leader from re-publishing message }); - - this.push.broadcast({ type: 'workflowActivated', data: { workflowId, activeVersionId } }); - - await this.publisher.publishCommand({ - command: 'display-workflow-activation', - payload: { workflowId, activeVersionId }, - }); // instruct followers to show activation in UI } catch (e) { const error = ensureError(e); const { message } = error; @@ -811,7 +810,7 @@ export class ActiveWorkflowManager { } } - this.push.broadcast({ + await this.workflowPushNotifier.notify(workflowId, { type: 'workflowFailedToActivate', data: { workflowId, errorMessage: message, nodeId, errorDescription }, }); @@ -820,7 +819,24 @@ export class ActiveWorkflowManager { command: 'display-workflow-activation-error', payload: { workflowId, errorMessage: message, nodeId, errorDescription }, }); // instruct followers to show activation error in UI + + return; } + + // Activation already succeeded above; notifying about it happens + // outside the try/catch so it can't be mistaken for an activation failure. + await this.workflowPushNotifier.notify(workflowId, { + type: 'workflowActivated', + data: { workflowId, activeVersionId }, + }); + + // Not awaited: a relay failure must not fail this pubsub handler. + void this.publisher + .publishCommand({ + command: 'display-workflow-activation', + payload: { workflowId, activeVersionId }, + }) // instruct followers to show activation in UI + .catch((error) => this.errorReporter.error(ensureError(error), { shouldBeLogged: true })); } /** @@ -1010,7 +1026,10 @@ export class ActiveWorkflowManager { await this.removeActivationError(workflowId); await this.removeNonWebhookTriggers(workflowId); - this.push.broadcast({ type: 'workflowDeactivated', data: { workflowId } }); + await this.workflowPushNotifier.notify(workflowId, { + type: 'workflowDeactivated', + data: { workflowId }, + }); // instruct followers to show workflow deactivation in UI await this.publisher.publishCommand({ diff --git a/packages/cli/src/chat/__tests__/chat-execution-manager.test.ts b/packages/cli/src/chat/__tests__/chat-execution-manager.test.ts index 6ef26ba01dc..8a5e1ad8be7 100644 --- a/packages/cli/src/chat/__tests__/chat-execution-manager.test.ts +++ b/packages/cli/src/chat/__tests__/chat-execution-manager.test.ts @@ -44,6 +44,7 @@ describe('ChatExecutionManager', () => { const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse; const message = { sessionId: '123', action: 'sendMessage', chatInput: 'input' } as ChatMessage; + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true); vi.spyOn(chatExecutionManager as any, 'getRunData').mockRejectedValue(new Error('Test error')); await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow( @@ -61,6 +62,7 @@ describe('ChatExecutionManager', () => { } as ChatMessage; const runData = { executionMode: 'manual', executionData: {}, workflowData: {} } as any; + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true); vi.spyOn(chatExecutionManager as any, 'getRunData').mockResolvedValue(runData); await chatExecutionManager.runWorkflow(execution, message); @@ -70,9 +72,34 @@ describe('ChatExecutionManager', () => { expectedStatus: 'waiting', }); }); + + it('refuses to run when the suspended node is not chat-driven', async () => { + const execution = { id: '1', workflowData: {}, data: {} } as IExecutionResponse; + const message = { + sessionId: '123', + action: 'sendMessage', + chatInput: 'input', + } as ChatMessage; + + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false); + const getRunDataSpy = vi.spyOn(chatExecutionManager as any, 'getRunData'); + + await expect(chatExecutionManager.runWorkflow(execution, message)).rejects.toThrow( + 'Refusing to resume a non-chat node over chat', + ); + expect(getRunDataSpy).not.toHaveBeenCalled(); + expect(workflowRunner.run).not.toHaveBeenCalled(); + }); }); describe('cancelExecution', () => { + beforeEach(() => { + // The status-transition tests below exercise the cancel logic itself; the + // chat-ownership gate is covered by its own tests. Treat the execution as + // chat-driven so these focus on status handling. + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(true); + }); + it('should update execution status to canceled if it is running', async () => { const executionId = '1'; const execution = { id: executionId, status: 'running' } as any; @@ -125,6 +152,44 @@ describe('ChatExecutionManager', () => { expect(executionRepository.update).not.toHaveBeenCalled(); }); + + it('should not cancel a waiting execution the chat socket does not drive', async () => { + // A socket attached to a non-chat execution (e.g. a Send-and-Wait gate via + // a leaked token) must not cancel it by going idle. + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockReturnValue(false); + const executionId = '9'; + const execution = { id: executionId, status: 'waiting' } as any; + + executionPersistence.findSingleExecution.mockResolvedValue(execution); + + await chatExecutionManager.cancelExecution(executionId); + + expect(executionRepository.update).not.toHaveBeenCalled(); + }); + + it('does not throw or cancel when the parked node type is unresolvable', async () => { + // Use the real isChatDrivenExecution (the describe's beforeEach stubs it) so + // the resolution failure is exercised: it must be swallowed, so cancelExecution + // returns and the heartbeat loop can still clean up the session. + vi.spyOn(chatExecutionManager as any, 'isChatDrivenExecution').mockRestore(); + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => { + throw new Error('Unrecognized node type: some.uninstalled.node'); + }); + const execution = { + id: '9', + status: 'waiting', + workflowData: { id: 'wf', nodes: [] }, + data: { + executionData: { + nodeExecutionStack: [{ node: { name: 'X', type: 'x' }, data: { main: [[]] } }], + }, + }, + } as any; + executionPersistence.findSingleExecution.mockResolvedValue(execution); + + await expect(chatExecutionManager.cancelExecution('9')).resolves.toBeUndefined(); + expect(executionRepository.update).not.toHaveBeenCalled(); + }); }); describe('findExecution', () => { @@ -424,7 +489,9 @@ describe('ChatExecutionManager', () => { workflowData: { id: 'workflowId' }, data: { resultData: { lastNodeExecuted: 'nodeId' }, - executionData: { nodeExecutionStack: [{ data: { main: [[]] } }] }, + executionData: { + nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[]] } }], + }, }, mode: 'manual', } as any; @@ -474,7 +541,9 @@ describe('ChatExecutionManager', () => { workflowData: { id: 'workflowId' }, data: { resultData: { lastNodeExecuted: 'nodeId' }, - executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] }, + executionData: { + nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }], + }, }, mode: 'manual', } as any; @@ -510,7 +579,9 @@ describe('ChatExecutionManager', () => { workflowData: { id: 'workflowId' }, data: { resultData: { lastNodeExecuted: 'nodeId' }, - executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] }, + executionData: { + nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }], + }, }, mode: 'manual', } as any; @@ -551,7 +622,9 @@ describe('ChatExecutionManager', () => { workflowData: { id: 'workflowId' }, data: { resultData: { lastNodeExecuted: 'nodeId' }, - executionData: { nodeExecutionStack: [{ data: { main: [[{}]] } }] }, + executionData: { + nodeExecutionStack: [{ node: { name: 'nodeId' }, data: { main: [[{}]] } }], + }, }, mode: 'manual', } as any; @@ -703,4 +776,204 @@ describe('ChatExecutionManager', () => { }); }); }); + + describe('canResumeOverChat', () => { + // A chat message may only resume a node whose resolved type implements the + // onMessage hook. Everything else (Send-and-Wait gates, non-chat HITL tools, + // plain Wait) must be refused regardless of the token presented. + function makeExecution( + nodeType: string, + parameters: Record = {}, + nodeName = 'parkedNode', + ): IExecutionResponse { + const node = { name: nodeName, type: nodeType, typeVersion: 1, parameters }; + return { + id: '1', + workflowData: { id: 'wf', nodes: [node] }, + data: { + resultData: { lastNodeExecuted: nodeName, runData: {} }, + executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] }, + }, + } as unknown as IExecutionResponse; + } + + function mockWorkflow(execution: IExecutionResponse, hasOnMessage: boolean) { + const node = execution.data.executionData!.nodeExecutionStack[0].node; + const workflow = { + id: 'wf', + getNode: (name: string) => (name === node.name ? node : null), + nodeTypes: { + getByNameAndVersion: () => (hasOnMessage ? { onMessage: vi.fn() } : {}), + }, + }; + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow); + } + + it('allows a chat node that implements onMessage', () => { + const execution = makeExecution(CHAT_NODE_TYPE); + mockWorkflow(execution, true); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true); + }); + + it('returns false (does not throw) when node type resolution fails', () => { + const execution = makeExecution(CHAT_NODE_TYPE); + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => { + throw new Error('Unrecognized node type: some.uninstalled.node'); + }); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('allows RespondToWebhook (implements onMessage)', () => { + const execution = makeExecution(RESPOND_TO_WEBHOOK_NODE_TYPE); + mockWorkflow(execution, true); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true); + }); + + it('allows a chat-based HITL tool (inherits onMessage)', () => { + const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool'); + mockWorkflow(execution, true); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(true); + }); + + it('refuses a chat node that opted out of user input', () => { + const execution = makeExecution(CHAT_NODE_TYPE, { blockUserInput: true }); + mockWorkflow(execution, true); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('applies the blockUserInput carve-out to a chat-based HITL tool', () => { + // chatHitlTool inherits onMessage and retains blockUserInput; the carve-out + // must reach it even though its type is not literally CHAT_(TOOL_)NODE_TYPE. + const execution = makeExecution('@n8n/n8n-nodes-langchain.chatHitlTool', { + blockUserInput: true, + }); + mockWorkflow(execution, true); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('refuses a Send-and-Wait node (no onMessage)', () => { + const execution = makeExecution('n8n-nodes-base.telegram', { operation: 'sendAndWait' }); + mockWorkflow(execution, false); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('refuses a non-chat HITL tool (no onMessage)', () => { + const execution = makeExecution('n8n-nodes-base.telegramHitlTool'); + mockWorkflow(execution, false); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('refuses a plain Wait node (no onMessage)', () => { + const execution = makeExecution('n8n-nodes-base.wait', { resume: 'timeInterval' }); + mockWorkflow(execution, false); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('keys off the parked stack node, not lastNodeExecuted', () => { + // Confused-deputy guard: lastNodeExecuted names a chat decoy, but the node + // the engine would resume (nodeExecutionStack[0]) is a Telegram gate. The + // decision must follow the parked stack node and refuse. + const decoy = { name: 'Decoy', type: CHAT_NODE_TYPE, typeVersion: 1, parameters: {} }; + const gate = { + name: 'Manager approval', + type: 'n8n-nodes-base.telegram', + typeVersion: 1, + parameters: { operation: 'sendAndWait' }, + }; + const execution = { + id: '1', + workflowData: { id: 'wf', nodes: [decoy, gate] }, + data: { + resultData: { lastNodeExecuted: 'Decoy', runData: {} }, + executionData: { nodeExecutionStack: [{ node: gate, data: { main: [[]] } }] }, + }, + } as unknown as IExecutionResponse; + const workflow = { + getNode: (name: string) => (name === 'Decoy' ? decoy : name === gate.name ? gate : null), + nodeTypes: { + getByNameAndVersion: (type: string) => + type === CHAT_NODE_TYPE ? { onMessage: vi.fn() } : {}, + }, + }; + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + + it('refuses when there is no parked execution data', () => { + const execution = { + id: '1', + workflowData: { id: 'wf', nodes: [] }, + data: { resultData: { lastNodeExecuted: 'x', runData: {} }, executionData: undefined }, + } as unknown as IExecutionResponse; + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue({ + getNode: () => null, + nodeTypes: { getByNameAndVersion: () => ({ onMessage: vi.fn() }) }, + }); + + expect(chatExecutionManager.canResumeOverChat(execution)).toBe(false); + }); + }); + + describe('resolveResumeNodeType', () => { + it('reports the wrapped tool type for a tool-executor entry, not the executor', () => { + const toolNode = { + name: 'My Tool', + type: 'n8n-nodes-base.telegramHitlTool', + typeVersion: 1, + parameters: {}, + }; + const execution = { + id: '1', + workflowData: { id: 'wf', nodes: [toolNode] }, + data: { + resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} }, + executionData: { + nodeExecutionStack: [ + { + node: { + name: TOOL_EXECUTOR_NODE_NAME, + type: '@n8n/n8n-nodes-langchain.toolExecutor', + parameters: { node: 'My Tool' }, + }, + data: { main: [[]] }, + }, + ], + }, + }, + } as unknown as IExecutionResponse; + const workflow = { + getNode: (name: string) => (name === 'My Tool' ? toolNode : null), + nodeTypes: { getByNameAndVersion: () => ({}) }, + }; + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockReturnValue(workflow); + + expect(chatExecutionManager.resolveResumeNodeType(execution)).toBe( + 'n8n-nodes-base.telegramHitlTool', + ); + }); + + it('returns undefined when the node type cannot be resolved', () => { + const execution = { + id: '1', + data: { + executionData: { nodeExecutionStack: [{ node: { name: 'x', type: 'x' }, data: {} }] }, + }, + } as unknown as IExecutionResponse; + vi.spyOn(chatExecutionManager as any, 'getWorkflow').mockImplementation(() => { + throw new Error('Unrecognized node type'); + }); + + expect(chatExecutionManager.resolveResumeNodeType(execution)).toBeUndefined(); + }); + }); }); diff --git a/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts b/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts new file mode 100644 index 00000000000..4f0943e8a01 --- /dev/null +++ b/packages/cli/src/chat/__tests__/chat-resume-node-types.integration.test.ts @@ -0,0 +1,216 @@ +import type { ExecutionRepository, IExecutionResponse } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; +import path from 'node:path'; +import { readFileSync } from 'node:fs'; +import type { INodeType, IVersionedNodeType, LoadedClass, NodeLoadingDetails } from 'n8n-workflow'; +import { CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE, RESPOND_TO_WEBHOOK_NODE_TYPE } from 'n8n-workflow'; + +import type { ExecutionPersistence } from '@/executions/execution-persistence'; + +import { NodeTypes } from '../../node-types'; +import { ChatExecutionManager } from '../chat-execution-manager'; +import type { ChatMessage } from '../chat-service.types'; + +/** + * Guards the security-critical fact the `/chat` resume allowlist depends on: + * *which real node types implement the `onMessage` hook*. The unit tests stub + * `getByNameAndVersion`, so they can't catch a registry change (e.g. a non-chat + * node gaining `onMessage`, or the chat tool suffix chain breaking). This test + * resolves the actual node classes from dist through the real `NodeTypes` + * registry — including the synthetic `*HitlTool` variants — and asserts both the + * `onMessage` inventory and the resulting `canResumeOverChat` decision. + */ + +const TELEGRAM = 'n8n-nodes-base.telegram'; +const WAIT = 'n8n-nodes-base.wait'; +const TELEGRAM_HITL = 'n8n-nodes-base.telegramHitlTool'; +const CHAT_HITL = '@n8n/n8n-nodes-langchain.chatHitlTool'; + +/** Loads a real node class from a built package's dist using its known-nodes manifest. */ +function loadRealNode(fullType: string): LoadedClass { + const [pkg, shortName] = fullType.startsWith('@n8n/') + ? ['@n8n/n8n-nodes-langchain', fullType.replace('@n8n/n8n-nodes-langchain.', '')] + : ['n8n-nodes-base', fullType.replace('n8n-nodes-base.', '')]; + + const pkgRoot = path.dirname(require.resolve(`${pkg}/package.json`)); + const known = JSON.parse( + readFileSync(path.join(pkgRoot, 'dist/known/nodes.json'), 'utf-8'), + ) as Record; + + const info = known[shortName]; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require(path.join(pkgRoot, info.sourcePath)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const type = new mod[info.className](); + return { type, sourcePath: '' }; +} + +/** Real NodeTypes backed by a thin loader over dist-loaded base nodes, so + * `getByNameAndVersion` runs its actual tool-suffix resolution + synthesis. */ +function makeRealNodeTypes(): NodeTypes { + const baseTypes: Record> = { + [CHAT_NODE_TYPE]: loadRealNode(CHAT_NODE_TYPE), + [RESPOND_TO_WEBHOOK_NODE_TYPE]: loadRealNode(RESPOND_TO_WEBHOOK_NODE_TYPE), + [TELEGRAM]: loadRealNode(TELEGRAM), + [WAIT]: loadRealNode(WAIT), + }; + + const loader = { + recognizesNode: (name: string) => name in baseTypes, + getNode: (name: string) => baseTypes[name], + loadedNodes: {} as Record>, + }; + + return new NodeTypes(mock(), loader as never); +} + +function makeExecution(nodeType: string, parameters: Record = {}) { + const node = { name: 'parked', type: nodeType, typeVersion: 1, parameters }; + return { + id: '1', + workflowData: { id: 'wf', nodes: [node], connections: {} }, + data: { + resultData: { lastNodeExecuted: 'parked', runData: {} }, + executionData: { nodeExecutionStack: [{ node, data: { main: [[]] } }] }, + }, + } as unknown as IExecutionResponse; +} + +describe('chat resume — real node-type onMessage mapping', () => { + let nodeTypes: NodeTypes; + let manager: ChatExecutionManager; + let executionRepository: ReturnType>; + let executionPersistence: ReturnType>; + + beforeAll(() => { + nodeTypes = makeRealNodeTypes(); + executionRepository = mock(); + executionPersistence = mock(); + manager = new ChatExecutionManager( + executionRepository, + executionPersistence, + mock(), + mock(), + nodeTypes, + ); + }); + + describe('onMessage inventory (resolved through the real registry)', () => { + it.each([ + [CHAT_NODE_TYPE, true], + [RESPOND_TO_WEBHOOK_NODE_TYPE, true], + [CHAT_HITL, true], // synthetic tool inherits onMessage from the base Chat node + [TELEGRAM, false], + [WAIT, false], + [TELEGRAM_HITL, false], // synthetic tool of a base node that has no onMessage + ])('%s implements onMessage: %s', (type, expected) => { + const resolved = nodeTypes.getByNameAndVersion(type); + expect(typeof resolved.onMessage === 'function').toBe(expected); + }); + }); + + describe('canResumeOverChat against real node types', () => { + it('allows the Chat node', () => { + expect(manager.canResumeOverChat(makeExecution(CHAT_NODE_TYPE))).toBe(true); + }); + + it('allows a chat-based HITL tool', () => { + expect(manager.canResumeOverChat(makeExecution(CHAT_HITL))).toBe(true); + }); + + it('refuses a Telegram Send-and-Wait gate', () => { + expect(manager.canResumeOverChat(makeExecution(TELEGRAM, { operation: 'sendAndWait' }))).toBe( + false, + ); + }); + + it('refuses a non-chat HITL tool (telegramHitlTool)', () => { + expect(manager.canResumeOverChat(makeExecution(TELEGRAM_HITL))).toBe(false); + }); + + it('refuses a plain Wait node', () => { + expect(manager.canResumeOverChat(makeExecution(WAIT, { resume: 'timeInterval' }))).toBe( + false, + ); + }); + + it('refuses a chat node that opted out of user input', () => { + // blockUserInput only applies (and survives Workflow parameter resolution) + // when responseType is 'approval' — mirror a real approval chat node. + expect( + manager.canResumeOverChat( + makeExecution(CHAT_NODE_TYPE, { responseType: 'approval', blockUserInput: true }), + ), + ).toBe(false); + }); + + it('refuses a chat-based HITL tool that opted out of user input', () => { + expect( + manager.canResumeOverChat( + makeExecution(CHAT_HITL, { responseType: 'approval', blockUserInput: true }), + ), + ).toBe(false); + }); + }); + + describe('cancelExecution heartbeat gate against real node types', () => { + function waitingExecution(nodeType: string, parameters: Record = {}) { + const execution = makeExecution(nodeType, parameters); + (execution as unknown as { status: string }).status = 'waiting'; + return execution; + } + + beforeEach(() => { + executionRepository.update.mockClear(); + }); + + it('cancels an abandoned chat execution', async () => { + executionPersistence.findSingleExecution.mockResolvedValue(waitingExecution(CHAT_NODE_TYPE)); + + await manager.cancelExecution('1'); + + expect(executionRepository.update).toHaveBeenCalledWith({ id: '1' }, { status: 'canceled' }); + }); + + it('does not cancel a Telegram Send-and-Wait execution the socket does not drive', async () => { + executionPersistence.findSingleExecution.mockResolvedValue( + waitingExecution(TELEGRAM, { operation: 'sendAndWait' }), + ); + + await manager.cancelExecution('1'); + + expect(executionRepository.update).not.toHaveBeenCalled(); + }); + + it('does not cancel a plain Wait execution the socket does not drive', async () => { + executionPersistence.findSingleExecution.mockResolvedValue( + waitingExecution(WAIT, { resume: 'timeInterval' }), + ); + + await manager.cancelExecution('1'); + + expect(executionRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe('runWorkflow sink guard against real node types', () => { + const message: ChatMessage = { sessionId: 's', action: 'sendMessage', chatInput: 'x' }; + + it('refuses to resume a Telegram Send-and-Wait gate', async () => { + await expect( + manager.runWorkflow(makeExecution(TELEGRAM, { operation: 'sendAndWait' }), message), + ).rejects.toThrow('Refusing to resume a non-chat node over chat'); + }); + + it('refuses to resume a plain Wait node', async () => { + await expect( + manager.runWorkflow(makeExecution(WAIT, { resume: 'timeInterval' }), message), + ).rejects.toThrow('Refusing to resume a non-chat node over chat'); + }); + }); + + it('keeps CHAT_TOOL_NODE_TYPE resolvable to an onMessage-bearing type', () => { + // chatTool resolves to the base Chat node, so it must also carry onMessage. + expect(typeof nodeTypes.getByNameAndVersion(CHAT_TOOL_NODE_TYPE).onMessage).toBe('function'); + }); +}); diff --git a/packages/cli/src/chat/__tests__/chat-service.test.ts b/packages/cli/src/chat/__tests__/chat-service.test.ts index cc92520feea..cedd9ff1823 100644 --- a/packages/cli/src/chat/__tests__/chat-service.test.ts +++ b/packages/cli/src/chat/__tests__/chat-service.test.ts @@ -25,6 +25,9 @@ describe('ChatService', () => { beforeEach(() => { mockExecutionManager = mock(); + // Default: the parked node is chat-resumable. Node-type gating is covered in + // chat-execution-manager.test.ts; individual tests override to false. + mockExecutionManager.canResumeOverChat.mockReturnValue(true); mockLogger = mock(); mockErrorReporter = mock(); chatService = new ChatService(mockExecutionManager, mockLogger, mockErrorReporter); @@ -375,7 +378,7 @@ describe('ChatService', () => { expect(mockLogger.error).toHaveBeenCalled(); }); - it('should not resume execution if blockUserInput is true', async () => { + it('should not resume execution when the parked node is not chat-resumable', async () => { const sessionKey = 'abc|123|public'; const session = { executionId: '123', @@ -391,12 +394,17 @@ describe('ChatService', () => { nodes: [{ name: 'node1', type: CHAT_NODE_TYPE, parameters: { blockUserInput: true } }], }, } as any); + // The guard refuses this node type, so the message must not resume it and + // the node must stay marked as waiting. + mockExecutionManager.canResumeOverChat.mockReturnValue(false); const incomingMessageHandler = (chatService as any).incomingMessageHandler(sessionKey); await incomingMessageHandler(data); expect(session.nodeWaitingForChatResponse).toBe('node1'); expect(mockExecutionManager.runWorkflow).not.toHaveBeenCalled(); + // The refusal is logged rather than silent. + expect(mockLogger.warn).toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/chat/__tests__/utils.test.ts b/packages/cli/src/chat/__tests__/utils.test.ts index 3e1f5ef903f..ac9e4fbc03f 100644 --- a/packages/cli/src/chat/__tests__/utils.test.ts +++ b/packages/cli/src/chat/__tests__/utils.test.ts @@ -12,6 +12,7 @@ import { import { getMessage, getLastNodeExecuted, + findResumeNode, redirectIfToolExecutor, getLastNodeMessage, shouldResumeImmediately, @@ -511,23 +512,23 @@ describe('redirectIfToolExecutor', () => { disabled: false, }; - function makeExecution(lastNodeExecuted: string, startData?: object): IExecutionResponse { + function makeExecution(startData?: object): IExecutionResponse { return { id: 'exec-1', data: { - resultData: { lastNodeExecuted, runData: {} }, + resultData: { lastNodeExecuted: TOOL_EXECUTOR_NODE_NAME, runData: {} }, startData: startData ?? { runNodeFilter: ['SomeNode'], destinationNode: 'SomeNode' }, }, workflowData: { nodes: [] }, } as unknown as IExecutionResponse; } - function makeExecutionData(toolNodeName: string): IExecuteData { + function executorData(toolNodeName?: string): IExecuteData { return { node: { name: TOOL_EXECUTOR_NODE_NAME, type: '@n8n/n8n-nodes-langchain.toolExecutor', - parameters: { node: toolNodeName }, + parameters: toolNodeName ? { node: toolNodeName } : {}, typeVersion: 1, position: [0, 0], id: '', @@ -538,9 +539,17 @@ describe('redirectIfToolExecutor', () => { } as unknown as IExecuteData; } - it('returns null when lastNodeExecuted is not TOOL_EXECUTOR_NODE_NAME', () => { - const execution = makeExecution('SomeOtherNode'); - const executionData = makeExecutionData('My Tool'); + function plainData(name: string): IExecuteData { + return { + node: { name, type: 'n8n-nodes-base.noOp', parameters: {}, typeVersion: 1 }, + data: { main: [[]] }, + source: null, + } as unknown as IExecuteData; + } + + it('returns null when the parked node is not the executor', () => { + const execution = makeExecution(); + const executionData = plainData('SomeOtherNode'); const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; const result = redirectIfToolExecutor(execution, executionData, workflow); @@ -550,32 +559,24 @@ describe('redirectIfToolExecutor', () => { }); it('returns null when the referenced tool node is not found in the workflow', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = makeExecutionData('My Tool'); + const execution = makeExecution(); + const executionData = executorData('My Tool'); const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow; - const result = redirectIfToolExecutor(execution, executionData, workflow); - - expect(result).toBeNull(); + expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull(); }); it('returns null when executionData.node.parameters.node is missing', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = { - node: { name: TOOL_EXECUTOR_NODE_NAME, parameters: {} }, - data: { main: [[]] }, - source: null, - } as unknown as IExecuteData; + const execution = makeExecution(); + const executionData = executorData(); const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow; - const result = redirectIfToolExecutor(execution, executionData, workflow); - - expect(result).toBeNull(); + expect(redirectIfToolExecutor(execution, executionData, workflow)).toBeNull(); }); it('redirects executionData.node to the actual tool node', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = makeExecutionData('My Tool'); + const execution = makeExecution(); + const executionData = executorData('My Tool'); const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; redirectIfToolExecutor(execution, executionData, workflow); @@ -584,8 +585,8 @@ describe('redirectIfToolExecutor', () => { }); it('updates lastNodeExecuted to the tool node name', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = makeExecutionData('My Tool'); + const execution = makeExecution(); + const executionData = executorData('My Tool'); const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; redirectIfToolExecutor(execution, executionData, workflow); @@ -594,8 +595,8 @@ describe('redirectIfToolExecutor', () => { }); it('resets runIndex to 0', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = makeExecutionData('My Tool'); + const execution = makeExecution(); + const executionData = executorData('My Tool'); (executionData as any).runIndex = 5; const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; @@ -605,11 +606,11 @@ describe('redirectIfToolExecutor', () => { }); it('clears runNodeFilter and destinationNode from startData', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME, { + const execution = makeExecution({ runNodeFilter: ['SomeNode'], destinationNode: 'SomeNode', }); - const executionData = makeExecutionData('My Tool'); + const executionData = executorData('My Tool'); const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; redirectIfToolExecutor(execution, executionData, workflow); @@ -619,13 +620,114 @@ describe('redirectIfToolExecutor', () => { }); it('returns the tool node', () => { - const execution = makeExecution(TOOL_EXECUTOR_NODE_NAME); - const executionData = makeExecutionData('My Tool'); + const execution = makeExecution(); + const executionData = executorData('My Tool'); const workflow = { getNode: vi.fn().mockReturnValue(toolNode) } as unknown as Workflow; - const result = redirectIfToolExecutor(execution, executionData, workflow); + expect(redirectIfToolExecutor(execution, executionData, workflow)).toBe(toolNode); + }); - expect(result).toBe(toolNode); + it('leaves the execution unmodified when the parked node is not the executor', () => { + const execution = makeExecution(); + execution.data.resultData.lastNodeExecuted = 'SomeOtherNode'; + const executionData = plainData('SomeOtherNode'); + const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow; + + redirectIfToolExecutor(execution, executionData, workflow); + + // The mutating redirect must not fire for a non-executor parked node. + expect(execution.data.resultData.lastNodeExecuted).toBe('SomeOtherNode'); + expect(executionData.node.name).toBe('SomeOtherNode'); + }); +}); + +describe('findResumeNode', () => { + const toolNode: INode = { + name: 'My Tool', + type: 'n8n-nodes-base.myTool', + typeVersion: 1, + position: [0, 0], + parameters: {}, + id: 'tool-id', + disabled: false, + }; + + function executorData(toolNodeName?: string): IExecuteData { + return { + node: { + name: TOOL_EXECUTOR_NODE_NAME, + type: '@n8n/n8n-nodes-langchain.toolExecutor', + parameters: toolNodeName ? { node: toolNodeName } : {}, + typeVersion: 1, + position: [0, 0], + id: '', + disabled: false, + }, + data: { main: [[]] }, + source: null, + } as unknown as IExecuteData; + } + + function parkedData(node: Partial): IExecuteData { + return { node, data: { main: [[]] }, source: null } as unknown as IExecuteData; + } + + it('resolves the parked stack node from the workflow', () => { + const parkedNode = { ...toolNode, name: 'Gate' }; + const executionData = parkedData({ name: 'Gate' }); + const workflow = { getNode: vi.fn().mockReturnValue(parkedNode) } as unknown as Workflow; + + const result = findResumeNode(executionData, workflow); + + expect(result).toBe(parkedNode); + expect(workflow.getNode).toHaveBeenCalledWith('Gate'); + }); + + it('resolves the wrapped tool node when the parked node is the virtual executor', () => { + const executionData = executorData('My Tool'); + const getNode = vi + .fn() + .mockImplementation((name: string) => (name === 'My Tool' ? toolNode : null)); + const workflow = { getNode } as unknown as Workflow; + + expect(findResumeNode(executionData, workflow)).toBe(toolNode); + }); + + it('does not mutate the execution data while resolving the executor target', () => { + const executionData = executorData('My Tool'); + const workflow = { + getNode: vi.fn().mockImplementation((n: string) => (n === 'My Tool' ? toolNode : null)), + } as unknown as Workflow; + + findResumeNode(executionData, workflow); + + expect(executionData.node.name).toBe(TOOL_EXECUTOR_NODE_NAME); + }); + + it('returns null when the executor references an unknown tool node', () => { + const executionData = executorData('Missing'); + const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow; + + expect(findResumeNode(executionData, workflow)).toBeNull(); + }); + + it('returns null when the parked node is not in the workflow', () => { + const executionData = parkedData({ name: 'Ghost' }); + const workflow = { getNode: vi.fn().mockReturnValue(null) } as unknown as Workflow; + + expect(findResumeNode(executionData, workflow)).toBeNull(); + }); + + it('follows the parked stack node, ignoring a divergent lastNodeExecuted', () => { + // The engine resumes nodeExecutionStack[0]; if resultData.lastNodeExecuted + // named a different node, findResumeNode must still follow the stack entry. + const gate = { name: 'Gate', type: 'n8n-nodes-base.telegram', typeVersion: 1, parameters: {} }; + const executionData = parkedData(gate); + const getNode = vi.fn().mockImplementation((name: string) => (name === 'Gate' ? gate : null)); + const workflow = { getNode } as unknown as Workflow; + + expect(findResumeNode(executionData, workflow)).toBe(gate); + expect(workflow.getNode).toHaveBeenCalledWith('Gate'); }); }); diff --git a/packages/cli/src/chat/chat-execution-manager.ts b/packages/cli/src/chat/chat-execution-manager.ts index d80fd4d8776..7bbf1ffd256 100644 --- a/packages/cli/src/chat/chat-execution-manager.ts +++ b/packages/cli/src/chat/chat-execution-manager.ts @@ -11,6 +11,7 @@ import { Workflow, BINARY_ENCODING, UnexpectedError, + CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE, NodeConnectionTypes, isHitlToolType, @@ -22,9 +23,10 @@ import * as WorkflowExecuteAdditionalData from '../workflow-execute-additional-d import { preserveInputOverride } from '../workflow-helpers'; import { WorkflowRunner } from '../workflow-runner'; import type { ChatMessage } from './chat-service.types'; -import { redirectIfToolExecutor } from './utils'; +import { findResumeNode, redirectIfToolExecutor } from './utils'; import { NodeTypes } from '../node-types'; import { OwnershipService } from '../services/ownership.service'; +import { stripToolSuffix } from '../utils'; @Service() export class ChatExecutionManager { @@ -37,6 +39,20 @@ export class ChatExecutionManager { ) {} async runWorkflow(execution: IExecutionResponse, message: ChatMessage) { + // Sink guard shared by every chat resume caller (chat websocket, Chat Hub, + // the auto-resume watcher): only a node a chat mechanism drives — one that + // implements onMessage — may be resumed here. This makes the allowlist + // un-skippable so no caller can resume, e.g., a Send-and-Wait gate. Callers + // are expected to refuse earlier with a user-facing message; reaching this + // throw means a caller skipped its check. blockUserInput is a per-message + // policy enforced by the resume-path callers, not here, so auto-resume of + // chat nodes keeps working. + if (!this.isChatDrivenExecution(execution)) { + throw new UnexpectedError('Refusing to resume a non-chat node over chat', { + extra: { executionId: execution.id }, + }); + } + await this.workflowRunner.run(await this.getRunData(execution, message), true, true, { executionId: execution.id, expectedStatus: 'waiting', @@ -51,6 +67,8 @@ export class ChatExecutionManager { if (!execution) return; + if (!this.isChatDrivenExecution(execution)) return; + if (['running', 'waiting', 'unknown'].includes(execution.status)) { await this.executionRepository.update({ id: executionId }, { status: 'canceled' }); } @@ -91,26 +109,101 @@ export class ChatExecutionManager { return binary; } - private async runNode(execution: IExecutionResponse, message: ChatMessage) { - const workflow = this.getWorkflow(execution); - const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; - let node = workflow.getNode(lastNodeExecuted); - const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id }); + /** + * Resolves the node a chat message would resume and its (version-resolved) + * node type, without mutating the execution. Shared by the resume guard and + * `runNode` so the authorization decision can never drift from what runs. + */ + private resolveResumeTarget(execution: IExecutionResponse, workflow: Workflow) { const executionData = execution.data.executionData?.nodeExecutionStack[0]; - if (!executionData) return null; - // PartialExecutionToolExecutor is a virtual node not present in the workflow — - // redirect to the real tool and wraps so onMessage() if present runs on the right node. - if (!node) { - node = redirectIfToolExecutor(execution, executionData, workflow); - } - + const node = findResumeNode(executionData, workflow); if (!node) return null; + const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); + return { node, nodeType, executionData }; + } + + /** + * `resolveResumeTarget` wrapped to fail closed. Building the workflow / resolving + * the parked node's type throws if a node type is unavailable (e.g. an + * uninstalled community node). The guards below must never throw — a resume + * decision that blows up would, on the heartbeat path, abort the whole + * `checkHeartbeats` loop and leave the session uncleaned — so an unresolvable + * node is simply treated as not chat-resumable. + */ + private tryResolveResumeTarget(execution: IExecutionResponse) { + try { + return this.resolveResumeTarget(execution, this.getWorkflow(execution)); + } catch { + return null; + } + } + + /** + * A chat message may only resume a node that implements the `onMessage` hook: + * the Chat node and the tools that inherit it (chatTool, chatHitlTool), plus + * RespondToWebhook. Send-and-Wait gates, non-chat HITL tools and Wait nodes + * have no `onMessage` and must not be resumable over the socket, whatever + * token is presented. + */ + canResumeOverChat(execution: IExecutionResponse): boolean { + const target = this.tryResolveResumeTarget(execution); + if (!target) return false; + + const { node, nodeType } = target; + if (typeof nodeType.onMessage !== 'function') return false; + + // A chat node is chat-resumable by type but still refuses input when the + // builder opted out via blockUserInput. This covers the Chat node and its + // tool variants (chatTool, chatHitlTool) — all resolve to the base chat type + // and carry the same parameter. + const isChatBasedNode = stripToolSuffix(node.type) === CHAT_NODE_TYPE; + if (isChatBasedNode && node.parameters?.blockUserInput === true) return false; + + return true; + } + + /** + * The resolved type of the node a chat message would resume, for diagnostics. + * Uses the same resolution as canResumeOverChat — so a PartialExecutionToolExecutor + * entry reports the wrapped tool node, not the virtual executor — and fails closed + * (undefined) when the node type can't be resolved. + */ + resolveResumeNodeType(execution: IExecutionResponse): string | undefined { + return this.tryResolveResumeTarget(execution)?.node.type; + } + + /** + * Whether the parked node is one a chat socket legitimately drives. + * Unlike canResumeOverChat this ignores blockUserInput: a chat node that + * opted out of input is still a chat execution owned by its socket, so an + * abandoned one should still be cleaned up. Used to decide whether losing the + * socket should cancel the execution. + */ + private isChatDrivenExecution(execution: IExecutionResponse): boolean { + const target = this.tryResolveResumeTarget(execution); + return !!target && typeof target.nodeType.onMessage === 'function'; + } + + private async runNode(execution: IExecutionResponse, message: ChatMessage) { + const workflow = this.getWorkflow(execution); + const target = this.resolveResumeTarget(execution, workflow); + + if (!target) return null; + + const { node, nodeType, executionData } = target; + const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id }); + + // PartialExecutionToolExecutor is a virtual node not present in the workflow. + // `node`/`nodeType` already point at the real tool via findResumeNode; this + // applies the execution-state redirect so onMessage() runs on it (no-op for + // an ordinary parked node). + redirectIfToolExecutor(execution, executionData, workflow); + const inputData = executionData.data; const connectionInputData = executionData.data.main[0]; - const nodeType = workflow.nodeTypes.getByNameAndVersion(node.type, node.typeVersion); const context = new ExecuteContext( workflow, node, @@ -160,10 +253,14 @@ export class ChatExecutionManager { // The chat-based HITL tool resumes here too, but carries the generated // `chatHitlTool` type rather than `chatTool`. Without this its output stays on // the `main` channel instead of `ai_tool`, so the agent never sees the approval - // and the gated tool is never executed. The webhook resume path keys off the - // same `HitlTool` suffix. + // and the gated tool is never executed. Scope this to chat-based tools only: + // non-chat HITL tools (e.g. telegramHitlTool) are not chat-resumable and must + // not receive the approval fix-up, so the suffix check is paired with a base + // type check. const resumingNode = runExecutionData.executionData!.nodeExecutionStack[0].node; - if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isHitlToolType(resumingNode.type)) { + const isChatBasedHitlTool = + isHitlToolType(resumingNode.type) && stripToolSuffix(resumingNode.type) === CHAT_NODE_TYPE; + if (resumingNode.type === CHAT_TOOL_NODE_TYPE || isChatBasedHitlTool) { runExecutionData.waitTill = undefined; resumingNode.disabled = true; resumingNode.rewireOutputLogTo = NodeConnectionTypes.AiTool; diff --git a/packages/cli/src/chat/chat-service.ts b/packages/cli/src/chat/chat-service.ts index cf00e45136f..1f66f570ea7 100644 --- a/packages/cli/src/chat/chat-service.ts +++ b/packages/cli/src/chat/chat-service.ts @@ -5,7 +5,7 @@ import { Service } from '@n8n/di'; import { timingSafeEqual } from 'crypto'; import { ErrorReporter } from 'n8n-core'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import { jsonParse, UnexpectedError, CHAT_NODE_TYPE, CHAT_TOOL_NODE_TYPE } from 'n8n-workflow'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; import { type RawData, WebSocket } from 'ws'; import { z } from 'zod'; @@ -166,8 +166,8 @@ export class ChatService { chatInput: getLastNodeMessage(execution, lastNode), sessionId: session.sessionId, }; - await this.resumeExecution(session.executionId, data, sessionKey); - session.nodeWaitingForChatResponse = undefined; + const resumed = await this.resumeExecution(session.executionId, data, sessionKey); + if (resumed) session.nodeWaitingForChatResponse = undefined; } else { session.nodeWaitingForChatResponse = lastNode?.name; } @@ -249,10 +249,12 @@ export class ChatService { } const executionId = session.executionId; - if (await this.shouldResumeOnMessage(executionId)) { - await this.resumeExecution(executionId, this.parseChatMessage(message), sessionKey); - session.nodeWaitingForChatResponse = undefined; - } + const resumed = await this.resumeExecution( + executionId, + this.parseChatMessage(message), + sessionKey, + ); + if (resumed) session.nodeWaitingForChatResponse = undefined; } catch (e) { const error = ensureError(e); this.errorReporter.error(error); @@ -263,10 +265,30 @@ export class ChatService { }; } - private async resumeExecution(executionId: string, message: ChatMessage, sessionKey: string) { + private async resumeExecution( + executionId: string, + message: ChatMessage, + sessionKey: string, + ): Promise { const execution = await this.getExecutionOrCleanupSession(executionId, sessionKey); - if (!execution || execution.status !== 'waiting') return; + if (!execution || execution.status !== 'waiting') return false; + + // A chat message may only resume nodes designed to accept one (see + // ChatExecutionManager.canResumeOverChat); refuse everything else, + // whatever token is presented. Log the refusal so the denial is visible + // (probing / "why won't my chat resume") rather than silent. + if (!this.executionManager.canResumeOverChat(execution)) { + // Resolve the same node the gate checked (a tool-executor entry redirects to + // the wrapped tool) so the diagnostic names the node that was refused. + const nodeType = this.executionManager.resolveResumeNodeType(execution); + this.logger.warn( + `Refused chat resume for execution ${executionId}: suspended node (type: ${nodeType ?? 'unknown'}) is not resumable over chat`, + ); + return false; + } + await this.executionManager.runWorkflow(execution, message); + return true; } private async getExecutionOrCleanupSession(executionId: string, sessionKey: string) { @@ -360,21 +382,6 @@ export class ChatService { } } - private async shouldResumeOnMessage(executionId: string) { - const execution = await this.executionManager.findExecution(executionId); - if (!execution) { - return true; - } - - const lastNode = getLastNodeExecuted(execution); - const isChatNode = lastNode?.type === CHAT_NODE_TYPE || lastNode?.type === CHAT_TOOL_NODE_TYPE; - if (isChatNode && lastNode?.parameters?.blockUserInput === true) { - return false; - } - - return true; - } - @OnShutdown() shutdown() { for (const [key, session] of this.sessions.entries()) { diff --git a/packages/cli/src/chat/utils.ts b/packages/cli/src/chat/utils.ts index be628591a9b..db68f2a0ba2 100644 --- a/packages/cli/src/chat/utils.ts +++ b/packages/cli/src/chat/utils.ts @@ -11,6 +11,43 @@ import { const AI_TOOL = 'ai_tool'; +/** + * When the parked node is the virtual PartialExecutionToolExecutor, resolves the + * real tool node it wraps (stored in the executor's `node` parameter). Pure — does + * not mutate the execution. Returns null when the parked node isn't the executor. + * + * Keyed off `executionData.node` (the parked stack entry) rather than + * `resultData.lastNodeExecuted` so it matches the node the engine resumes. + */ +function getToolExecutorTargetNode(executionData: IExecuteData, workflow: Workflow): INode | null { + if (executionData.node.name !== TOOL_EXECUTOR_NODE_NAME) return null; + + const toolNodeName = executionData.node.parameters?.node as string | undefined; + return toolNodeName ? workflow.getNode(toolNodeName) : null; +} + +/** + * Resolves the node a chat message would resume — the parked node, or the tool node + * behind the virtual PartialExecutionToolExecutor. Pure: does not mutate the + * execution, so callers deciding *whether* to resume can share it with the resume + * path without side effects. Returns null when no resumable node can be resolved. + * + * Resolves from `executionData.node` — the top of `nodeExecutionStack`, i.e. the + * node the engine actually resumes — not `resultData.lastNodeExecuted` (a report of + * what last ran). The two agree in every real waiting state, but reading the + * authoritative field keeps the resume decision from ever authorizing a different + * node than the one that runs. + */ +export function findResumeNode(executionData: IExecuteData, workflow: Workflow): INode | null { + const parkedNode = executionData.node; + + if (parkedNode.name === TOOL_EXECUTOR_NODE_NAME) { + return getToolExecutorTargetNode(executionData, workflow); + } + + return workflow.getNode(parkedNode.name); +} + /** * Redirects execution from PartialExecutionToolExecutor to the actual tool node * it wraps (stored in the executor's `node` parameter). @@ -27,11 +64,7 @@ export function redirectIfToolExecutor( executionData: IExecuteData, workflow: Workflow, ): INode | null { - const lastNodeExecuted = execution.data.resultData.lastNodeExecuted; - if (lastNodeExecuted !== TOOL_EXECUTOR_NODE_NAME) return null; - - const toolNodeName = executionData.node.parameters?.node as string | undefined; - const toolNode = toolNodeName ? workflow.getNode(toolNodeName) : null; + const toolNode = getToolExecutorTargetNode(executionData, workflow); if (!toolNode) return null; executionData.node = toolNode; diff --git a/packages/cli/src/controllers/__tests__/role.controller.test.ts b/packages/cli/src/controllers/__tests__/role.controller.test.ts index a33664554d2..e238193e324 100644 --- a/packages/cli/src/controllers/__tests__/role.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/role.controller.test.ts @@ -1,8 +1,10 @@ -import type { AuthenticatedRequest } from '@n8n/db'; +import type { AuthenticatedRequest, Project } from '@n8n/db'; import type { Role } from '@n8n/permissions'; import { mock } from 'vitest-mock-extended'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; import type { EventService } from '@/events/event.service'; +import type { ProjectService } from '@/services/project.service.ee'; import type { RoleService } from '@/services/role.service'; import { RoleController } from '../role.controller'; @@ -10,7 +12,8 @@ import { RoleController } from '../role.controller'; describe('RoleController', () => { const eventService = mock(); const roleService = mock(); - const controller = new RoleController(roleService, eventService); + const projectService = mock(); + const controller = new RoleController(roleService, eventService, projectService); const managerRequest = () => mock({ @@ -19,6 +22,7 @@ describe('RoleController', () => { beforeEach(() => { vi.restoreAllMocks(); + vi.clearAllMocks(); }); describe('emits action events', () => { @@ -147,4 +151,93 @@ describe('RoleController', () => { }); }); }); + + describe('getRoleProjectMembers', () => { + const projectRole = { slug: 'project:admin', roleType: 'project' } as Role; + + it('should return the members when the caller can list the project', async () => { + const request = managerRequest(); + roleService.getRole.mockResolvedValue(projectRole); + projectService.getProjectWithScope.mockResolvedValue(mock({ id: 'project-1' })); + roleService.getRoleProjectMembers.mockResolvedValue({ + members: [ + { + userId: 'user-1', + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.com', + role: 'project:admin', + }, + ], + }); + + const result = await controller.getRoleProjectMembers( + request, + mock(), + 'project:admin', + 'project-1', + ); + + expect(projectService.getProjectWithScope).toHaveBeenCalledWith(request.user, 'project-1', [ + 'project:list', + ]); + expect(result.members).toHaveLength(1); + }); + + it('should not return the members when the caller cannot list the project', async () => { + const request = managerRequest(); + roleService.getRole.mockResolvedValue(projectRole); + projectService.getProjectWithScope.mockResolvedValue(null); + + await expect( + controller.getRoleProjectMembers(request, mock(), 'project:admin', 'project-1'), + ).rejects.toThrow(NotFoundError); + + expect(roleService.getRoleProjectMembers).not.toHaveBeenCalled(); + }); + }); + + describe('getRoleAssignments', () => { + const projectRole = { slug: 'project:admin', roleType: 'project' } as Role; + + const assignment = (projectId: string, projectName: string) => ({ + projectId, + projectName, + projectIcon: null, + memberCount: 2, + lastAssigned: null, + }); + + it('should only return the projects the caller can list, keeping the total intact', async () => { + const request = managerRequest(); + roleService.getRole.mockResolvedValue(projectRole); + roleService.getRoleAssignments.mockResolvedValue({ + projects: [assignment('project-1', 'Mine'), assignment('project-2', 'Not mine')], + totalProjects: 2, + }); + projectService.getProjectIdsWithScope.mockResolvedValue(['project-1']); + + const result = await controller.getRoleAssignments(request, mock(), 'project:admin'); + + expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith( + request.user, + ['project:list'], + ['project-1', 'project-2'], + ); + expect(result.projects).toEqual([assignment('project-1', 'Mine')]); + // the count stays instance-wide so the delete-impact signal is not silently wrong + expect(result.totalProjects).toBe(2); + }); + + it('should not query visibility when the role has no assignments', async () => { + const request = managerRequest(); + roleService.getRole.mockResolvedValue(projectRole); + roleService.getRoleAssignments.mockResolvedValue({ projects: [], totalProjects: 0 }); + + const result = await controller.getRoleAssignments(request, mock(), 'project:admin'); + + expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled(); + expect(result.projects).toEqual([]); + }); + }); }); diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index 323c5fba3e1..cfdf46f5c6d 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -29,7 +29,9 @@ import { } from '@n8n/decorators'; import { Role as RoleDTO } from '@n8n/permissions'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; +import { ProjectService } from '@/services/project.service.ee'; import { assertCanManageRoleType, canReassignUsers } from '@/services/role-authorization'; import { RoleService } from '@/services/role.service'; @@ -38,6 +40,7 @@ export class RoleController { constructor( private readonly roleService: RoleService, private readonly eventService: EventService, + private readonly projectService: ProjectService, ) {} @Get('/') @@ -67,6 +70,17 @@ export class RoleController { roleType: role.roleType, user: req.user, }); + + // Managing a role is an instance-wide capability, but the identities of a project's + // members are project data. Gate them on the same `project:list` check the sibling + // user and project routes use, so a role manager only sees projects it can already see. + const project = await this.projectService.getProjectWithScope(req.user, projectId, [ + 'project:list', + ]); + if (!project) { + throw new NotFoundError('Project not found'); + } + const result = await this.roleService.getRoleProjectMembers(slug, projectId); return RoleProjectMembersResponseDto.parse(result); } @@ -83,7 +97,27 @@ export class RoleController { user: req.user, }); const result = await this.roleService.getRoleAssignments(slug); - return RoleAssignmentsResponseDto.parse(result); + + if (result.projects.length === 0) { + return RoleAssignmentsResponseDto.parse(result); + } + + // `projects` only names the projects the caller can already see. `totalProjects` stays + // the instance-wide count so the assignments tab can say "showing 1 of 3" rather than + // claim the role is unassigned; a bare number names no project and no member. The + // delete-impact warning is a different field — `usedByProjects`, served by `getRole`. + const visibleProjectIds = new Set( + await this.projectService.getProjectIdsWithScope( + req.user, + ['project:list'], + result.projects.map((project) => project.projectId), + ), + ); + + return RoleAssignmentsResponseDto.parse({ + ...result, + projects: result.projects.filter((project) => visibleProjectIds.has(project.projectId)), + }); } @Get('/:slug/members') diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 5c9f6190ad7..173945762b8 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -4197,10 +4197,21 @@ describe('CredentialsService', () => { expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled(); }); + it('should refuse a test URL without a stored service origin', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + mockDecryptedData({ testUrl: 'https://api.example.com/me' }); + + await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow( + 'The credential test URL is not bound to its service origin', + ); + expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled(); + }); + it('should probe the persisted test URL with parsed accepted status codes', async () => { credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); const data: ICredentialDataDecryptedObject = { testUrl: 'https://api.example.com/me', + serviceOrigin: 'https://api.example.com', acceptedStatusCodes: '[401]', }; mockDecryptedData(data); @@ -4217,13 +4228,30 @@ describe('CredentialsService', () => { 'httpTemplatedCustomAuth', { id: 'cred-id', name: 'Templated cred', type: 'httpTemplatedCustomAuth', data }, 'https://api.example.com/me', - { acceptedStatusCodes: [401] }, + { acceptedStatusCodes: [401], allowedDomains: 'api.example.com' }, ); }); + it('should refuse a test URL outside the stored service origin', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); + mockDecryptedData({ + testUrl: 'https://status.example.net/me', + serviceOrigin: 'https://api.example.com', + }); + + await expect(service.probeById(ownerUser, 'cred-id')).rejects.toThrow( + 'The credential test URL is not bound to its service origin', + ); + expect(credentialsTester.probeCredentialAuth).not.toHaveBeenCalled(); + }); + it('should ignore malformed accepted status codes', async () => { credentialsFinderService.findCredentialForUser.mockResolvedValue(storedCredential); - mockDecryptedData({ testUrl: 'https://api.example.com/me', acceptedStatusCodes: 'nope' }); + mockDecryptedData({ + testUrl: 'https://api.example.com/me', + serviceOrigin: 'https://api.example.com', + acceptedStatusCodes: 'nope', + }); credentialsTester.probeCredentialAuth.mockResolvedValue({ status: 'OK', message: 'Connection successful!', @@ -4236,7 +4264,7 @@ describe('CredentialsService', () => { expect.anything(), expect.anything(), expect.anything(), - { acceptedStatusCodes: undefined }, + { acceptedStatusCodes: undefined, allowedDomains: 'api.example.com' }, ); }); }); diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 1fbc4b11009..0a21c190dda 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -88,6 +88,16 @@ import { /** Sentinel placed at every leaf of a redacted httpCustomAuth JSON shape */ const CUSTOM_AUTH_JSON_REDACTED_VALUE = '***'; +function parseHttpUrl(value: unknown): URL | undefined { + if (typeof value !== 'string') return undefined; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url : undefined; + } catch { + return undefined; + } +} + export type CredentialsGetSharedOptions = | { allowGlobalScope: true; globalScope: Scope } | { allowGlobalScope: false }; @@ -1301,12 +1311,22 @@ export class CredentialsService { const data = await this.decrypt(storedCredential, true); - // Expressions (leading '=') and non-http values are refused, not resolved. - const testUrl = data.testUrl; - if (typeof testUrl !== 'string' || !/^https?:\/\//i.test(testUrl)) { + // Expressions and non-HTTP values are refused, not resolved. + const testTarget = parseHttpUrl(data.testUrl); + if (!testTarget) { throw new BadRequestError('The credential has no test URL to probe'); } + const storedOrigin = typeof data.serviceOrigin === 'string' ? data.serviceOrigin.trim() : ''; + const serviceOrigin = parseHttpUrl(storedOrigin); + if ( + !serviceOrigin || + serviceOrigin.origin !== storedOrigin || + serviceOrigin.origin !== testTarget.origin + ) { + throw new BadRequestError('The credential test URL is not bound to its service origin'); + } + return await this.credentialsTester.probeCredentialAuth( user.id, storedCredential.type, @@ -1316,8 +1336,11 @@ export class CredentialsService { type: storedCredential.type, data, }, - testUrl, - { acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes) }, + testTarget.href, + { + acceptedStatusCodes: parseAcceptedStatusCodes(data.acceptedStatusCodes), + allowedDomains: testTarget.hostname, + }, ); } diff --git a/packages/cli/src/errors/subworkflow-policy-denial.error.ts b/packages/cli/src/errors/subworkflow-policy-denial.error.ts index a845969d966..34a9ffeab2c 100644 --- a/packages/cli/src/errors/subworkflow-policy-denial.error.ts +++ b/packages/cli/src/errors/subworkflow-policy-denial.error.ts @@ -2,6 +2,8 @@ import type { Project } from '@n8n/db'; import { WorkflowOperationError } from 'n8n-workflow'; import type { INode } from 'n8n-workflow'; +type CallerType = 'workflow' | 'agent'; + type Options = { /** ID of the subworkflow whose execution was denied. */ subworkflowId: string; @@ -20,10 +22,29 @@ type Options = { /** Node that triggered the execution of the subworkflow whose execution was denied. */ node?: INode; + + callerType?: CallerType; }; export const SUBWORKFLOW_DENIAL_BASE_DESCRIPTION = 'The sub-workflow you’re trying to execute limits which workflows it can be called by.'; +export const SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION = + 'The sub-workflow you’re trying to execute doesn’t allow this agent to call it.'; + +function getCallerDescription(callerType: CallerType) { + return { + agent: { + caller: 'agent', + accessibleCaller: 'this agent', + baseDescription: SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION, + }, + workflow: { + caller: 'workflow', + accessibleCaller: 'other workflows', + baseDescription: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, + }, + }[callerType]; +} export class SubworkflowPolicyDenialError extends WorkflowOperationError { constructor({ @@ -33,20 +54,22 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError { hasReadAccess, ownerName, node, + callerType = 'workflow', }: Options) { + const { caller, accessibleCaller, baseDescription } = getCallerDescription(callerType); const descriptions = { - default: SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, + default: baseDescription, accessible: [ - SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, - `Update sub-workflow settings to allow other workflows to call it.`, + baseDescription, + `Update sub-workflow settings to allow ${accessibleCaller} to call it.`, ].join(' '), inaccessiblePersonalProject: [ - SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, - `You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`, + baseDescription, + `You will need ${ownerName} to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`, ].join(' '), inaccesibleTeamProject: [ - SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, - `You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this workflow to call it.`, + baseDescription, + `You will need an admin from the ${subworkflowProject.name} project to update the sub-workflow (${subworkflowId}) settings to allow this ${caller} to call it.`, ].join(' '), }; @@ -59,7 +82,7 @@ export class SubworkflowPolicyDenialError extends WorkflowOperationError { }; super( - `The sub-workflow (${subworkflowId}) cannot be called by this workflow`, + `The sub-workflow (${subworkflowId}) cannot be called by this ${caller}`, node, description(), ); diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts index 1817d5ea8c5..15c3bb666ab 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.integration.test.ts @@ -34,6 +34,8 @@ import { ExternalHooks } from '@/external-hooks'; import { Push } from '@/push'; import { OwnershipService } from '@/services/ownership.service'; import { WorkflowPublicationNotifier } from '@/workflows/publication/workflow-publication-notifier'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; +import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; import { createExecution } from '@test-integration/db/executions'; import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants'; @@ -46,6 +48,8 @@ describe('ExecutionRecoveryService', () => { const projectRelationRepository = mockInstance(ProjectRelationRepository); const externalHooks = mockInstance(ExternalHooks); const activeWorkflowManager = mockInstance(ActiveWorkflowManager); + const workflowSharingService = mockInstance(WorkflowSharingService); + const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService); mockInstance(WorkflowPublicationNotifier); let executionRecoveryService: ExecutionRecoveryService; @@ -72,11 +76,13 @@ describe('ExecutionRecoveryService', () => { mock(), ownershipService, projectRelationRepository, + workflowPushNotifier, ); }); beforeEach(() => { instanceSettings.markAsLeader(); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue([]); }); afterEach(async () => { @@ -185,6 +191,44 @@ describe('ExecutionRecoveryService', () => { expect(amendedExecution.status).toBe('crashed'); expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt); }); + + test('pushes `executionRecovered` only to users with workflow access, once a client connects', async () => { + /** + * Arrange + */ + const workflow = await createWorkflow(OOM_WORKFLOW); + const execution = await createExecution( + { + status: 'running', + data: stringify(IN_PROGRESS_EXECUTION_DATA), + }, + workflow, + ); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']); + let editorUiConnectedCallback: (() => Promise) | undefined; + push.once.mockImplementation((event: string, callback: () => Promise) => { + if (event === 'editorUiConnected') editorUiConnectedCallback = callback; + return push; + }); + + /** + * Act + */ + await executionRecoveryService.recoverFromLogs(execution.id, []); + expect(editorUiConnectedCallback).toBeDefined(); + await editorUiConnectedCallback?.(); + + /** + * Assert + */ + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + workflow.id, + ); + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'executionRecovered', data: { executionId: execution.id } }, + ['user-1'], + ); + }); }); describe('if leader, with 1+ messages', () => { @@ -526,6 +570,40 @@ describe('ExecutionRecoveryService', () => { expect(updatedWorkflow.activeVersionId).toBeNull(); }); + test('pushes `workflowAutoDeactivated` only to users with workflow access, once a client connects', async () => { + /** + * Arrange + */ + globalConfig.executions.recovery.workflowDeactivationEnabled = true; + + const workflow = await createCrashedActiveWorkflow(); + mockOwnershipForDeactivation(); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(['user-1']); + let editorUiConnectedCallback: (() => Promise) | undefined; + push.once.mockImplementation((event: string, callback: () => Promise) => { + if (event === 'editorUiConnected') editorUiConnectedCallback = callback; + return push; + }); + + /** + * Act + */ + await executionRecoveryService.autoDeactivateWorkflowsIfNeeded(new Set([workflow.id])); + expect(editorUiConnectedCallback).toBeDefined(); + await editorUiConnectedCallback?.(); + + /** + * Assert + */ + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith( + workflow.id, + ); + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'workflowAutoDeactivated', data: { workflowId: workflow.id } }, + ['user-1'], + ); + }); + test('should unpublish via outbox and record publish history on auto-deactivation', async () => { /** * Arrange diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 01b17943abc..8011f22d9bf 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -11,8 +11,8 @@ import { ExecutionRepository, WorkflowRepository } from '@n8n/db'; import { Container, Service } from '@n8n/di'; import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions'; import { ensureError } from '@n8n/utils/errors/ensure-error'; -import type { DateTime } from 'luxon'; import { sleep } from '@n8n/utils/sleep'; +import type { DateTime } from 'luxon'; import { InstanceSettings } from 'n8n-core'; import { createEmptyRunExecutionData } from 'n8n-workflow'; import { ExecutionStatus, type IRun, type ITaskData } from 'n8n-workflow'; @@ -25,6 +25,7 @@ import { ExecutionPersistence } from '@/executions/execution-persistence'; import { Push } from '@/push'; import { OwnershipService } from '@/services/ownership.service'; import { UserManagementMailer } from '@/user-management/email/user-management-mailer'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; import { isNodeEventMessage, type EventMessageTypes } from '../eventbus/event-message-classes'; @@ -44,6 +45,7 @@ export class ExecutionRecoveryService { private readonly userManagementMailer: UserManagementMailer, private readonly ownershipService: OwnershipService, private readonly projectRelationRepository: ProjectRelationRepository, + private readonly workflowPushNotifier: WorkflowPushNotifier, ) {} async autoDeactivateWorkflowsIfNeeded(workflowIds: Set) { @@ -88,7 +90,10 @@ export class ExecutionRecoveryService { this.push.once('editorUiConnected', async () => { await sleep(1000); - this.push.broadcast({ type: 'workflowAutoDeactivated', data: { workflowId } }); + await this.workflowPushNotifier.notify(workflowId, { + type: 'workflowAutoDeactivated', + data: { workflowId }, + }); }); } catch (error) { // A throw here would abort startup recovery for the remaining @@ -127,9 +132,14 @@ export class ExecutionRecoveryService { await this.runHooks(amendedExecution); + const { workflowId } = amendedExecution; + this.push.once('editorUiConnected', async () => { await sleep(1000); - this.push.broadcast({ type: 'executionRecovered', data: { executionId } }); + await this.workflowPushNotifier.notify(workflowId, { + type: 'executionRecovered', + data: { executionId }, + }); }); return amendedExecution; diff --git a/packages/cli/src/executions/pre-execution-checks/__tests__/subworkflow-policy-checker.test.ts b/packages/cli/src/executions/pre-execution-checks/__tests__/subworkflow-policy-checker.test.ts index fa8958d6c2f..55122ef2857 100644 --- a/packages/cli/src/executions/pre-execution-checks/__tests__/subworkflow-policy-checker.test.ts +++ b/packages/cli/src/executions/pre-execution-checks/__tests__/subworkflow-policy-checker.test.ts @@ -6,6 +6,7 @@ import { v4 as uuid } from 'uuid'; import { mock } from 'vitest-mock-extended'; import { + SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION, SUBWORKFLOW_DENIAL_BASE_DESCRIPTION, SubworkflowPolicyDenialError, } from '@/errors/subworkflow-policy-denial.error'; @@ -118,6 +119,51 @@ describe('SubworkflowPolicyChecker', () => { }); }); + describe('project-scoped caller', () => { + it('should allow workflows owned by the caller project', async () => { + const projectId = uuid(); + const subworkflow = mock({ + id: 'subworkflow-id', + settings: { callerPolicy: 'workflowsFromSameOwner' }, + }); + ownershipService.getWorkflowProjectCached.mockResolvedValue(mock({ id: projectId })); + + await expect(checker.checkForProject(subworkflow, projectId)).resolves.not.toThrow(); + }); + + it('should deny workflows owned by a different project', async () => { + const subworkflow = mock({ + id: 'subworkflow-id', + settings: { callerPolicy: 'workflowsFromSameOwner' }, + }); + ownershipService.getWorkflowProjectCached.mockResolvedValue( + mock({ id: uuid(), name: 'Engineering', type: 'team' }), + ); + + await expect(checker.checkForProject(subworkflow, uuid())).rejects.toMatchObject({ + message: 'The sub-workflow (subworkflow-id) cannot be called by this agent', + description: `${SUBWORKFLOW_AGENT_DENIAL_BASE_DESCRIPTION} You will need an admin from the Engineering project to update the sub-workflow (subworkflow-id) settings to allow this agent to call it.`, + }); + }); + + it.each(['none', 'workflowsFromAList'] as const)( + 'should deny the `%s` caller policy', + async (callerPolicy) => { + const subworkflow = mock({ + id: 'subworkflow-id', + settings: { callerPolicy }, + }); + ownershipService.getWorkflowProjectCached.mockResolvedValue( + mock({ id: uuid(), type: 'team' }), + ); + + await expect(checker.checkForProject(subworkflow, uuid())).rejects.toThrowError( + SubworkflowPolicyDenialError, + ); + }, + ); + }); + describe('`workflows-from-same-owner` caller policy', () => { it('should deny if the two workflows are owned by different projects', async () => { const parentWorkflowProject = mock({ id: uuid() }); diff --git a/packages/cli/src/executions/pre-execution-checks/subworkflow-policy-checker.ts b/packages/cli/src/executions/pre-execution-checks/subworkflow-policy-checker.ts index 3ad8d5d1962..6fe1087b0fc 100644 --- a/packages/cli/src/executions/pre-execution-checks/subworkflow-policy-checker.ts +++ b/packages/cli/src/executions/pre-execution-checks/subworkflow-policy-checker.ts @@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import type { Project } from '@n8n/db'; import { Service } from '@n8n/di'; -import { type Workflow, type INode, type WorkflowSettings } from 'n8n-workflow'; +import { type INode, type IWorkflowBase, type WorkflowSettings } from 'n8n-workflow'; import { SubworkflowPolicyDenialError } from '@/errors/subworkflow-policy-denial.error'; import { AccessService } from '@/services/access.service'; @@ -11,6 +11,7 @@ import { UrlService } from '@/services/url.service'; type Policy = WorkflowSettings.CallerPolicy; type DenialPolicy = Exclude; +type WorkflowWithCallerPolicy = Pick; @Service() export class SubworkflowPolicyChecker { @@ -25,7 +26,12 @@ export class SubworkflowPolicyChecker { /** * Check whether the parent workflow is allowed to call the subworkflow. */ - async check(subworkflow: Workflow, parentWorkflowId: string, node?: INode, userId?: string) { + async check( + subworkflow: WorkflowWithCallerPolicy, + parentWorkflowId: string, + node?: INode, + userId?: string, + ) { const { id: subworkflowId } = subworkflow; if (!subworkflowId) return; // e.g. when running a subworkflow loaded from a file @@ -47,7 +53,7 @@ export class SubworkflowPolicyChecker { this.logDenial({ parentWorkflowId, subworkflowId, policy }); - const errorDetails = await this.errorDetails(subworkflowProject, subworkflow, userId); + const errorDetails = await this.errorDetails(subworkflowProject, subworkflowId, userId); throw new SubworkflowPolicyDenialError({ subworkflowId, @@ -58,9 +64,39 @@ export class SubworkflowPolicyChecker { }); } - private async errorDetails(subworkflowProject: Project, subworkflow: Workflow, userId?: string) { + /** Check whether a project-scoped caller, such as an Agent, may call the subworkflow. */ + async checkForProject( + subworkflow: WorkflowWithCallerPolicy, + parentProjectId: string, + userId?: string, + ) { + const { id: subworkflowId } = subworkflow; + + if (!subworkflowId) return; + + const policy = this.findPolicy(subworkflow); + + if (policy === 'any') return; + + const subworkflowProject = await this.ownershipService.getWorkflowProjectCached(subworkflowId); + + if (policy === 'workflowsFromSameOwner' && parentProjectId === subworkflowProject.id) return; + + this.logDenial({ parentProjectId, subworkflowId, policy }); + + const errorDetails = await this.errorDetails(subworkflowProject, subworkflowId, userId); + throw new SubworkflowPolicyDenialError({ + subworkflowId, + subworkflowProject, + callerType: 'agent', + instanceUrl: this.urlService.getInstanceBaseUrl(), + ...errorDetails, + }); + } + + private async errorDetails(subworkflowProject: Project, subworkflowId: string, userId?: string) { const hasReadAccess = userId - ? await this.accessService.hasReadAccess(userId, subworkflow.id) + ? await this.accessService.hasReadAccess(userId, subworkflowId) : false; /* no user ID in policy check for error workflow, so `false` to keep error message generic */ if (subworkflowProject.type === 'team') return { hasReadAccess }; @@ -76,9 +112,9 @@ export class SubworkflowPolicyChecker { /** * Find the subworkflow's caller policy. */ - private findPolicy(subworkflow: Workflow): WorkflowSettings.CallerPolicy { + private findPolicy(subworkflow: WorkflowWithCallerPolicy): WorkflowSettings.CallerPolicy { return ( - subworkflow.settings.callerPolicy ?? this.globalConfig.workflows.callerPolicyDefaultOption + subworkflow.settings?.callerPolicy ?? this.globalConfig.workflows.callerPolicyDefaultOption ); } @@ -103,9 +139,9 @@ export class SubworkflowPolicyChecker { /** * Whether the subworkflow has the parent workflow listed as a caller. */ - private isListed(subworkflow: Workflow, parentWorkflowId: string) { + private isListed(subworkflow: WorkflowWithCallerPolicy, parentWorkflowId: string) { const callerIds = - subworkflow.settings.callerIds + subworkflow.settings?.callerIds ?.split(',') .map((id) => id.trim()) .filter((id) => id !== '') ?? []; @@ -119,19 +155,16 @@ export class SubworkflowPolicyChecker { workflowsFromSameOwner: 'Subworkflow may be called only by workflows owned by the same project', }; - private logDenial({ - parentWorkflowId, - subworkflowId, - policy, - }: { - parentWorkflowId: string; - subworkflowId: string; - policy: DenialPolicy; - }) { + private logDenial( + details: { + subworkflowId: string; + policy: DenialPolicy; + } & ({ parentWorkflowId: string } | { parentProjectId: string }), + ) { + const { policy, ...context } = details; this.logger.warn('[SubworkflowPolicyChecker] Subworkflow execution denied', { reason: this.denialReasons[policy], - parentWorkflowId, - subworkflowId, + ...context, }); } } diff --git a/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts index 0b8871da1f0..27ce8381c31 100644 --- a/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts +++ b/packages/cli/src/modules/agents/__tests__/workflow-tool-factory.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks'; import type { WorkflowRunner } from '@/workflow-runner'; import { @@ -97,6 +98,7 @@ function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext return { workflowLoader, workflowRunner, + subworkflowPolicyChecker: mock(), activeExecutions, projectId: 'project-1', executionMode: 'manual', diff --git a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts index e92acbf8311..3d23e9b82bc 100644 --- a/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts +++ b/packages/cli/src/modules/agents/agent-runtime-reconstruction.service.ts @@ -6,7 +6,6 @@ import { ModelConfig, ToolDescriptor, } from '@n8n/agents'; -import { proxyFetch } from '@n8n/ai-utilities/http-proxy-agent'; import { N8N_CHAT_ACTION_TOOL_NAME, N8N_CHAT_CONTEXT_TOOL_NAME, @@ -36,6 +35,7 @@ import { nanoid } from 'nanoid'; import { ActiveExecutions } from '@/active-executions'; import { N8N_VERSION } from '@/constants'; import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks'; import type { AgentRunTelemetryType } from '@/interfaces'; import { EphemeralNodeExecutor } from '@/node-execution'; import { OauthService } from '@/oauth/oauth.service'; @@ -611,6 +611,7 @@ export class AgentRuntimeReconstructionService { const tokenManager = new ProxyTokenManager(async () => { return await client.getBuilderApiProxyToken({ id: ownerId }, { userMessageId: nanoid() }); }); + const proxyManagedFetch = createAiProxyFetch(this.outboundHttp); return { baseURL, @@ -629,7 +630,7 @@ export class AgentRuntimeReconstructionService { )) { headers.set(key, value); } - return await proxyFetch(input as string, { ...init, headers }); + return await proxyManagedFetch(input, { ...init, headers }); }, }; } @@ -661,6 +662,7 @@ export class AgentRuntimeReconstructionService { return await resolveWorkflowTool(ref, { workflowLoader: Container.get(WorkflowToolWorkflowLoader), workflowRunner: await getWorkflowRunner(), + subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker), activeExecutions: this.activeExecutions, projectId, executionMode: workflowToolExecutionMode, diff --git a/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts index 815ceaa82e7..a62dde1db08 100644 --- a/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts +++ b/packages/cli/src/modules/agents/tools/__tests__/workflow-tool-factory.test.ts @@ -14,6 +14,7 @@ import { mock } from 'vitest-mock-extended'; import type { ActiveExecutions } from '@/active-executions'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks'; import { WebhookResponseRelay } from '@/scaling/webhook-response-relay'; import type { WorkflowRunner } from '@/workflow-runner'; @@ -46,6 +47,7 @@ function buildContext(run: ReturnType, extras: Partial(), activeExecutions: { has: vi.fn().mockReturnValue(false) } as unknown as ActiveExecutions, projectId: 'p1', executionMode: 'manual', @@ -88,6 +90,20 @@ describe('executeWorkflow → execution classification', () => { }, ); + it('checks the caller policy before starting the workflow', async () => { + const run = vi.fn().mockResolvedValue('exec-1'); + const subworkflowPolicyChecker = mock(); + subworkflowPolicyChecker.checkForProject.mockRejectedValue(new Error('denied')); + const context = buildContext(run, { subworkflowPolicyChecker }); + + await expect(executeWorkflow(workflow, triggerNode, 'manual', {}, context)).rejects.toThrow( + 'denied', + ); + + expect(subworkflowPolicyChecker.checkForProject).toHaveBeenCalledWith(workflow, 'p1'); + expect(run).not.toHaveBeenCalled(); + }); + it('does not execute saved editor pin data', async () => { const run = vi.fn().mockResolvedValue('exec-1'); const workflowWithPinData = { diff --git a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts index ba7b1aeab82..a667dbc3555 100644 --- a/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts +++ b/packages/cli/src/modules/agents/tools/workflow-tool-factory.ts @@ -42,6 +42,7 @@ import { z } from 'zod'; import type { ActiveExecutions } from '@/active-executions'; import { ExecutionPersistence } from '@/executions/execution-persistence'; +import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks'; import { WebhookResponseRelay } from '@/scaling/webhook-response-relay'; import type { WorkflowRunner } from '@/workflow-runner'; @@ -148,6 +149,7 @@ export type WorkflowToolExecutionMode = Extract { - const { workflowRunner, activeExecutions } = context; + const { workflowRunner, subworkflowPolicyChecker, activeExecutions } = context; + + await subworkflowPolicyChecker.checkForProject(workflow, context.projectId); // Build pin data for the trigger const triggerPinData = normalizeTriggerInput( diff --git a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts index 53bfe12d995..a1b2eb44086 100644 --- a/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts +++ b/packages/cli/src/modules/chat-hub/__tests__/chat-hub.service.integration.test.ts @@ -2243,6 +2243,11 @@ describe('chatHub', () => { // Mock ChatExecutionManager.runWorkflow for the resume const executionManager = Container.get(ChatExecutionManager); + // The chat-resume allowlist resolves node types via the registry, which + // this suite doesn't load; it's covered against the real registry in + // chat-resume-node-types.integration.test.ts. Here the parked node is a + // legitimate chat node, so treat it as resumable. + vi.spyOn(executionManager, 'canResumeOverChat').mockReturnValue(true); const runWorkflowSpy = vi .spyOn(executionManager, 'runWorkflow') .mockImplementationOnce(async () => { @@ -2355,6 +2360,152 @@ describe('chatHub', () => { expect(aiMessages[aiMessages.length - 1]?.status).toBe('success'); expect(aiMessages[aiMessages.length - 1]?.content).toBe('Nice to meet you, Alice!'); }); + + it('refuses a follow-up when the suspended node is not chat-resumable', async () => { + // A workflow parked on a non-chat gate (e.g. a Send-and-Wait approval) + // must not be advanced by the next chat message. The allowlist decision + // itself is covered against the real registry elsewhere; here we force a + // refusal to assert the chat-hub wiring: the send fails and the parked + // execution is never resumed. + const workflow = await createActiveWorkflow( + { + name: 'Non-Resumable Gate Workflow', + nodes: [ + { + id: 'chat-trigger-1', + name: 'Chat Trigger', + type: CHAT_TRIGGER_NODE_TYPE, + typeVersion: 1.4, + position: [0, 0], + parameters: { + availableInChat: true, + options: { responseMode: 'responseNodes' }, + }, + }, + { + id: 'respond-1', + name: 'Respond to Chat', + type: CHAT_NODE_TYPE, + typeVersion: 1, + position: [200, 0], + parameters: { message: 'What is your name?', waitUserReply: true }, + }, + ], + connections: { + 'Chat Trigger': { + main: [[{ node: 'Respond to Chat', type: 'main', index: 0 }]], + }, + }, + }, + member, + ); + + // First message: workflow goes into waiting state + spyExecute.mockImplementationOnce(async (_user, workflowData, executionData) => { + const executionId = await executionPersistence.create({ + finished: false, + mode: 'webhook', + status: 'running', + workflowId: workflowData.id, + data: executionData, + workflowData, + }); + + const runData: IRun = { + finished: false, + status: 'waiting', + mode: 'webhook', + startedAt: new Date(), + stoppedAt: new Date(), + storedAt: 'db', + data: createRunExecutionData({ + resultData: { + runData: { + 'Respond to Chat': [ + { + startTime: Date.now(), + executionTime: 100, + executionIndex: 0, + executionStatus: 'success', + source: [], + data: { main: [[{ json: {}, sendMessage: 'What is your name?' }]] }, + }, + ], + }, + lastNodeExecuted: 'Respond to Chat', + }, + }), + }; + + setTimeout(async () => { + await executionRepository.updateExistingExecution(executionId, { + status: 'waiting', + data: runData.data, + }); + await watcherService.handleWorkflowExecuteAfter({ + type: 'workflowExecuteAfter', + mode: runData.mode, + workflow: workflowData, + runData, + newStaticData: {}, + executionId, + }); + }); + + return { executionId }; + }); + + await chatHubService.sendHumanMessage( + member, + { + userId: member.id, + sessionId, + messageId, + message: 'Hello', + model: { provider: 'n8n', workflowId: workflow.id }, + credentials: {}, + previousMessageId: null, + attachments: [], + }, + { authToken: 'authtoken', method: 'POST', endpoint: '/api/chat/message' }, + ); + + const initialMessages = await retryUntil(async () => { + const messages = await messagesRepository.getManyBySessionId(sessionId); + expect(messages.length).toBeGreaterThanOrEqual(2); + expect(messages[1]?.status).toBe('waiting'); + return messages; + }); + const waitingMessageId = initialMessages[1].id; + + // Force the allowlist to refuse, and assert the resume sink is not called. + const executionManager = Container.get(ChatExecutionManager); + vi.spyOn(executionManager, 'canResumeOverChat').mockReturnValue(false); + const runWorkflowSpy = vi.spyOn(executionManager, 'runWorkflow'); + + await expect( + chatHubService.sendHumanMessage( + member, + { + userId: member.id, + sessionId, + messageId: crypto.randomUUID(), + message: 'anything', + model: { provider: 'n8n', workflowId: workflow.id }, + credentials: {}, + previousMessageId: waitingMessageId, + attachments: [], + }, + { authToken: 'authtoken', method: 'POST', endpoint: '/api/chat/message' }, + ), + ).rejects.toThrow('cannot be provided from chat'); + + expect(runWorkflowSpy).not.toHaveBeenCalled(); + + // The parked AI message stays waiting; the gate is untouched. + const afterMessages = await messagesRepository.getManyBySessionId(sessionId); + expect(afterMessages.find((m) => m.id === waitingMessageId)?.status).toBe('waiting'); + }); }); describe('"Using \'Respond to Webhook\' Node" response mode', () => { diff --git a/packages/cli/src/modules/chat-hub/chat-hub.service.ts b/packages/cli/src/modules/chat-hub/chat-hub.service.ts index 3c35863eae7..e006bf87c59 100644 --- a/packages/cli/src/modules/chat-hub/chat-hub.service.ts +++ b/packages/cli/src/modules/chat-hub/chat-hub.service.ts @@ -26,6 +26,7 @@ import { UnexpectedError, } from 'n8n-workflow'; +import { ChatExecutionManager } from '@/chat/chat-execution-manager'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExecutionPersistence } from '@/executions/execution-persistence'; @@ -69,6 +70,7 @@ export class ChatHubService { private readonly chatHubToolService: ChatHubToolService, private readonly chatHubWorkflowService: ChatHubWorkflowService, private readonly globalConfig: GlobalConfig, + private readonly executionManager: ChatExecutionManager, ) { this.logger = this.logger.scoped('chat-hub'); } @@ -140,6 +142,21 @@ export class ChatHubService { if (!execution) { throw new OperationalError('Chat session has expired.'); } + + // Only resume nodes a chat message is meant to drive (see + // ChatExecutionManager.canResumeOverChat) — the same allowlist the chat + // websocket uses. A workflow parked on, e.g., a Send-and-Wait approval gate + // must not be advanced by the next chat message; refuse before any state + // changes so the gate stays waiting for its real responder. + if (!this.executionManager.canResumeOverChat(execution)) { + this.logger.warn( + `Refused chat-hub resume for execution ${execution.id}: suspended node is not resumable over chat`, + ); + throw new BadRequestError( + 'This conversation is waiting for a response that cannot be provided from chat.', + ); + } + this.logger.debug( `Resuming execution ${execution.id} from waiting state for session ${sessionId}`, ); diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts index 37bccb7d2ea..70e3adff7ec 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.lifecycle.service.test.ts @@ -166,6 +166,32 @@ describe('CommunityPackagesLifecycleService', () => { undefined, ); }); + + it('should reject with BadRequestError when package name parsing fails', async () => { + communityPackagesService.parseNpmPackageName.mockImplementationOnce(() => { + throw new Error('Package name "n8n-nodes-invalid" is not allowed'); + }); + + const promise = lifecycle.install({ name: 'n8n-nodes-invalid' }, user, 'ui'); + + await expect(promise).rejects.toBeInstanceOf(BadRequestError); + await expect(promise).rejects.toThrow('Package name "n8n-nodes-invalid" is not allowed'); + expect(communityPackagesService.installPackage).not.toHaveBeenCalled(); + }); + }); + + describe('uninstall', () => { + it('should reject with BadRequestError when package name parsing fails', async () => { + communityPackagesService.parseNpmPackageName.mockImplementationOnce(() => { + throw new Error('Package name "n8n-nodes-invalid" is not allowed'); + }); + + const promise = lifecycle.uninstall('n8n-nodes-invalid', user, 'notFound'); + + await expect(promise).rejects.toBeInstanceOf(BadRequestError); + await expect(promise).rejects.toThrow('Package name "n8n-nodes-invalid" is not allowed'); + expect(communityPackagesService.removePackage).not.toHaveBeenCalled(); + }); }); describe('listInstalledPackages', () => { diff --git a/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts b/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts index e601aecf32f..835e9f9bdd8 100644 --- a/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts +++ b/packages/cli/src/modules/community-packages/__tests__/community-packages.service.test.ts @@ -126,6 +126,25 @@ describe('CommunityPackagesService', () => { }, ); + test.each(['n8n-nodes-base', '@n8n/n8n-nodes-langchain'])( + 'should reject reserved package name %s', + (name) => { + expect(() => communityPackagesService.parseNpmPackageName(name)).toThrow(/reserved/); + }, + ); + + test.each(['n8n-nodes-base@1.2.3', '@n8n/n8n-nodes-langchain@1.2.3'])( + 'should reject reserved package name with version specifier %s', + (name) => { + expect(() => communityPackagesService.parseNpmPackageName(name)).toThrow(/reserved/); + }, + ); + + test('should accept a package name that merely starts with a reserved name', () => { + const parsed = communityPackagesService.parseNpmPackageName('n8n-nodes-base-extended'); + expect(parsed.packageName).toBe('n8n-nodes-base-extended'); + }); + test.each(['beta', 'next', 'latest', 'canary', 'rc-1'])( 'should accept npm dist-tag as version', (tag) => { diff --git a/packages/cli/src/modules/community-packages/community-packages.service.ts b/packages/cli/src/modules/community-packages/community-packages.service.ts index c9da190b551..ee0a6795a40 100644 --- a/packages/cli/src/modules/community-packages/community-packages.service.ts +++ b/packages/cli/src/modules/community-packages/community-packages.service.ts @@ -1,6 +1,6 @@ import { Logger } from '@n8n/backend-common'; import { OutboundHttp, type HttpRequestClient } from '@n8n/backend-network'; -import { LICENSE_FEATURES, Time } from '@n8n/constants'; +import { BUILTIN_NODES_PACKAGES, LICENSE_FEATURES, Time } from '@n8n/constants'; import { OnPubSubEvent } from '@n8n/decorators'; import { Service } from '@n8n/di'; import type { PackageDirectoryLoader } from 'n8n-core'; @@ -45,6 +45,9 @@ const { PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES; const INVALID_OR_SUSPICIOUS_PACKAGE_NAME = /[^0-9a-z@\-._/]/; +/** Built-in package names cannot be installed as community packages. */ +const RESERVED_PACKAGE_NAMES = new Set(BUILTIN_NODES_PACKAGES); + type PackageJson = { name: 'installed-nodes'; private: true; @@ -159,6 +162,10 @@ export class CommunityPackagesService { const packageName = version ? rawString.replace(`@${version}`, '') : rawString; + if (RESERVED_PACKAGE_NAMES.has(packageName)) { + throw new UserError(`Package name "${packageName}" is reserved for n8n built-in packages`); + } + return { packageName, scope, version, rawString }; } diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts index 2fe10299fb8..85f244fb8db 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/execution.service.test.ts @@ -1716,4 +1716,47 @@ describe('EvalExecutionService', () => { expect(result.hints.nodeHints).toEqual({ 'HTTP Request': 'Return user profiles' }); }); }); + + // ── reserved node names ────────────────────────────────────────── + + describe('reserved node names', () => { + // Object literals cannot express an own "__proto__" key; this mirrors the + // shape JSON.parse produces for a persisted runData column. + function runDataWithOwnKey(key: string, value: unknown): Record { + const runData: Record = {}; + Object.defineProperty(runData, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + return runData; + } + + afterEach(() => { + // Undo any pollution a regression would have caused so it can't leak + // into unrelated tests. + for (const key of ['iterationCount', 'outputs', 'outputCount', 'executionMode']) { + delete (Object.prototype as Record)[key]; + } + }); + + it('does not pollute Object.prototype when a run node is named "__proto__"', async () => { + workflowFinderService.findWorkflowForUser.mockResolvedValue(makeWorkflowEntity() as never); + activeExecutions.getPostExecutePromise.mockResolvedValue( + makeIRun({ + data: { + resultData: { runData: runDataWithOwnKey('__proto__', []) }, + } as unknown as IRunExecutionData, + }), + ); + + const result = await service.executeWithLlmMock('wf-1', makeUser()); + + expect('iterationCount' in {}).toBe(false); + expect(Object.getOwnPropertyNames(Object.prototype)).not.toContain('iterationCount'); + // The node is still recorded — as an own key on the result, not on the prototype. + expect(Object.keys(result.nodeResults)).toContain('__proto__'); + }); + }); }); diff --git a/packages/cli/src/modules/instance-ai/eval/execution.service.ts b/packages/cli/src/modules/instance-ai/eval/execution.service.ts index 5ecbe38c769..a6ad506c53f 100644 --- a/packages/cli/src/modules/instance-ai/eval/execution.service.ts +++ b/packages/cli/src/modules/instance-ai/eval/execution.service.ts @@ -432,7 +432,12 @@ export class EvalExecutionService { /** Caller's budget; unbounded when omitted. See awaitRunWithinBudget. */ budget?: RunBudget, ): Promise { - const nodeResults: Record = {}; + // Null-prototype map: node names are the keys here and come from workflow + // input, so a reserved name (`__proto__`, `constructor`, ...) must land as an + // own key instead of resolving up the prototype chain. Without this, the + // `nodeResults[name] ??= {}` + nested-write sinks below would assign onto + // `Object.prototype`. + const nodeResults: Record = Object.create(null); // Fill setup-pending resource locators BEFORE the first normalization pass: // Workflow construction runs getNodeParameters(returnNoneDisplayed=false), diff --git a/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts b/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts index 03b3274c870..bf71c561197 100644 --- a/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts +++ b/packages/cli/src/modules/log-streaming.ee/__tests__/log-streaming.controller.test.ts @@ -1,14 +1,19 @@ import type { OutboundHttp } from '@n8n/backend-network'; import type { InstanceSettingsLoaderConfig } from '@n8n/config'; -import type { AuthenticatedRequest } from '@n8n/db'; +import type { AuthenticatedRequest, CredentialsEntity, User } from '@n8n/db'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import type { MessageEventBusDestinationWebhookOptions } from 'n8n-workflow'; +import type { + MessageEventBusDestinationOptions, + MessageEventBusDestinationWebhookOptions, +} from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import type { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; +import type { MessageEventBusDestination } from '../destinations/message-event-bus-destination.ee'; import type { LogStreamingDestinationService } from '../log-streaming-destination.service'; import { EventBusController } from '../log-streaming.controller'; @@ -19,17 +24,20 @@ describe('EventBusController', () => { logStreamingManagedByEnv: false, }); const outboundHttp = mock(); + const credentialsFinderService = mock(); let controller: EventBusController; beforeEach(() => { vi.clearAllMocks(); instanceSettingsLoaderConfig.logStreamingManagedByEnv = false; + destinationService.findDestination.mockResolvedValue([]); controller = new EventBusController( eventBus, destinationService, instanceSettingsLoaderConfig, outboundHttp, + credentialsFinderService, ); }); @@ -113,6 +121,54 @@ describe('EventBusController', () => { await expect(controller.postDestination(req)).rejects.toThrow(BadRequestError); }); + + it('rejects a destination referencing a credential the user cannot access', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + + const req = { + user: mock(), + body: { + __type: MessageEventBusDestinationTypeNames.webhook, + label: 'Test', + url: 'https://example.com/webhook', + credentials: { httpHeaderAuth: { id: 'cred-1', name: 'My cred' } }, + }, + } as unknown as AuthenticatedRequest; + + await expect(controller.postDestination(req)).rejects.toThrow(ForbiddenError); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'cred-1', + req.user, + ['credential:read'], + ); + expect(destinationService.addDestination).not.toHaveBeenCalled(); + }); + + it('creates a destination when the user can access the referenced credential', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(mock()); + const created = mock(); + created.serialize.mockReturnValue({ id: 'webhook-1' } as MessageEventBusDestinationOptions); + destinationService.addDestination.mockResolvedValue(created); + + const req = { + user: mock(), + body: { + __type: MessageEventBusDestinationTypeNames.webhook, + label: 'Test', + url: 'https://example.com/webhook', + credentials: { httpHeaderAuth: { id: 'cred-1', name: 'My cred' } }, + }, + } as unknown as AuthenticatedRequest; + + await controller.postDestination(req); + + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'cred-1', + req.user, + ['credential:read'], + ); + expect(destinationService.addDestination).toHaveBeenCalled(); + }); }); describe('sendTestMessage', () => { @@ -134,6 +190,42 @@ describe('EventBusController', () => { expect(result).toBe(false); }); + + it('rejects testing a destination whose credentials the user cannot access', async () => { + destinationService.findDestination.mockResolvedValue([ + { + credentials: { httpHeaderAuth: { id: 'cred-1', name: 'x' } }, + } as unknown as MessageEventBusDestinationOptions, + ]); + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + + const req = { user: mock() } as unknown as AuthenticatedRequest; + + await expect(controller.sendTestMessage(req, {}, { id: 'webhook-1' })).rejects.toThrow( + ForbiddenError, + ); + expect(destinationService.testDestination).not.toHaveBeenCalled(); + }); + + it('tests a destination when the user can access its credentials', async () => { + destinationService.findDestination.mockResolvedValue([ + { + credentials: { httpHeaderAuth: { id: 'cred-1', name: 'x' } }, + } as unknown as MessageEventBusDestinationOptions, + ]); + credentialsFinderService.findCredentialForUser.mockResolvedValue(mock()); + destinationService.testDestination.mockResolvedValue(true); + + const req = { user: mock() } as unknown as AuthenticatedRequest; + const result = await controller.sendTestMessage(req, {}, { id: 'webhook-1' }); + + expect(result).toBe(true); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'cred-1', + req.user, + ['credential:read'], + ); + }); }); describe('deleteDestination', () => { diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts new file mode 100644 index 00000000000..0070f1d54bf --- /dev/null +++ b/packages/cli/src/modules/log-streaming.ee/destinations/__tests__/destination-credentials-access.test.ts @@ -0,0 +1,62 @@ +import type { CredentialsEntity, User } from '@n8n/db'; +import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; + +import { assertUserCanUseDestinationCredentials } from '../destination-credentials-access'; + +describe('assertUserCanUseDestinationCredentials', () => { + const credentialsFinderService = mock(); + const user = mock(); + + beforeEach(() => vi.clearAllMocks()); + + it('does nothing when no credentials are referenced', async () => { + await expect( + assertUserCanUseDestinationCredentials(credentialsFinderService, user, {}), + ).resolves.toBeUndefined(); + expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled(); + }); + + it('skips references without an id', async () => { + const options = { + credentials: { httpHeaderAuth: { id: null, name: 'unsaved' } }, + } as unknown as MessageEventBusDestinationOptions; + + await expect( + assertUserCanUseDestinationCredentials(credentialsFinderService, user, options), + ).resolves.toBeUndefined(); + expect(credentialsFinderService.findCredentialForUser).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenError when the user cannot access a referenced credential', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + const options = { + credentials: { httpHeaderAuth: { id: 'cred-1', name: 'secret' } }, + } as unknown as MessageEventBusDestinationOptions; + + await expect( + assertUserCanUseDestinationCredentials(credentialsFinderService, user, options), + ).rejects.toThrow(ForbiddenError); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith('cred-1', user, [ + 'credential:read', + ]); + }); + + it('resolves when the user can access every referenced credential', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(mock()); + const options = { + credentials: { + httpHeaderAuth: { id: 'cred-1', name: 'secret' }, + httpBasicAuth: { id: 'cred-2', name: 'secret2' }, + }, + } as unknown as MessageEventBusDestinationOptions; + + await expect( + assertUserCanUseDestinationCredentials(credentialsFinderService, user, options), + ).resolves.toBeUndefined(); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts b/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts new file mode 100644 index 00000000000..91674143fe7 --- /dev/null +++ b/packages/cli/src/modules/log-streaming.ee/destinations/destination-credentials-access.ts @@ -0,0 +1,36 @@ +import type { User } from '@n8n/db'; +import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; + +import type { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; + +/** + * Verifies the requesting user has access to every credential referenced by a + * log streaming destination, mirroring the credential access check applied to + * credentials referenced by workflow nodes. Credential access is project-scoped + * while the eventBusDestination scopes are global, so destination credentials + * must be authorized explicitly here. + */ +export async function assertUserCanUseDestinationCredentials( + credentialsFinderService: CredentialsFinderService, + user: User, + options: Pick, +): Promise { + const { credentials } = options; + if (!credentials) return; + + for (const reference of Object.values(credentials)) { + const credentialId = reference?.id; + if (!credentialId) continue; + + const credential = await credentialsFinderService.findCredentialForUser(credentialId, user, [ + 'credential:read', + ]); + + if (!credential) { + throw new ForbiddenError( + 'You do not have access to the credentials selected for this log streaming destination.', + ); + } + } +} diff --git a/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts b/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts index 41d4e4759c4..17c50df76fc 100644 --- a/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts +++ b/packages/cli/src/modules/log-streaming.ee/log-streaming.controller.ts @@ -10,12 +10,14 @@ import type { AuthenticatedRequest } from '@n8n/db'; import { Delete, Get, GlobalScope, Licensed, Post, Query, RestController } from '@n8n/decorators'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { eventNamesAll } from '@/eventbus/event-message-classes'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { createMessageEventBusDestination } from './create-message-event-bus-destination'; +import { assertUserCanUseDestinationCredentials } from './destinations/destination-credentials-access'; import { LogStreamingDestinationService } from './log-streaming-destination.service'; @RestController('/eventbus') @@ -25,6 +27,7 @@ export class EventBusController { private readonly destinationService: LogStreamingDestinationService, private readonly instanceSettingsLoaderConfig: InstanceSettingsLoaderConfig, private readonly outboundHttp: OutboundHttp, + private readonly credentialsFinderService: CredentialsFinderService, ) {} private assertNotManagedByEnv() { @@ -64,11 +67,11 @@ export class EventBusController { throw new BadRequestError(parseResult.error.errors[0].message); } - const destination = createMessageEventBusDestination( - this.eventBus, - this.outboundHttp, - parseResult.data as MessageEventBusDestinationOptions, - ); + const options = parseResult.data as MessageEventBusDestinationOptions; + + await assertUserCanUseDestinationCredentials(this.credentialsFinderService, req.user, options); + + const destination = createMessageEventBusDestination(this.eventBus, this.outboundHttp, options); const result = await this.destinationService.addDestination(destination); return result.serialize(); @@ -78,10 +81,20 @@ export class EventBusController { @Get('/testmessage') @GlobalScope('eventBusDestination:test') async sendTestMessage( - _req: AuthenticatedRequest, + req: AuthenticatedRequest, _res: unknown, @Query query: TestDestinationQueryDto, ): Promise { + // Testing decrypts and sends the destination's stored credential, so the + // caller must have access to it — mirrors the check on create/update. + const [destination] = await this.destinationService.findDestination(query.id); + if (destination) { + await assertUserCanUseDestinationCredentials( + this.credentialsFinderService, + req.user, + destination, + ); + } return await this.destinationService.testDestination(query.id); } diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts index d71a6fb66e4..8aee65f7488 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/constants.ts @@ -1,3 +1,6 @@ +/** Upper bound on workflow SDK code accepted by the validate/create MCP tools. */ +export const MAX_WORKFLOW_CODE_LENGTH = 300_000; + /** * Re-export code-builder tool constants for use in MCP tool definitions. * Keeps tool names and labels consistent between the code-builder agent and the MCP server. diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts index 7b3cd23e5b0..f2f29e0d0d1 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/create-workflow-from-code.tool.ts @@ -8,7 +8,11 @@ import { import z from 'zod'; import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check'; -import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, CODE_BUILDER_VALIDATE_TOOL } from './constants'; +import { + MAX_WORKFLOW_CODE_LENGTH, + MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, + CODE_BUILDER_VALIDATE_TOOL, +} from './constants'; import { validateWorkflowCredentialReferences } from './credential-validation'; import { autoPopulateNodeCredentials, @@ -73,8 +77,9 @@ function normalizeWorkflowDescription(description?: string) { const inputSchema = { code: z .string() + .max(MAX_WORKFLOW_CODE_LENGTH) .describe( - `Full TypeScript/JavaScript workflow code using the n8n Workflow SDK. Must be validated first with ${CODE_BUILDER_VALIDATE_TOOL.toolName}.`, + `Full TypeScript/JavaScript workflow code using the n8n Workflow SDK. Must be validated first with ${CODE_BUILDER_VALIDATE_TOOL.toolName}. Max ${MAX_WORKFLOW_CODE_LENGTH} characters.`, ), skillsUsed: z.array(z.string()).optional().describe(SKILLS_USED_PARAM_DESCRIPTION), name: z diff --git a/packages/cli/src/modules/mcp/tools/workflow-builder/validate-workflow-code.tool.ts b/packages/cli/src/modules/mcp/tools/workflow-builder/validate-workflow-code.tool.ts index ce5d00b0fc7..130c129fa38 100644 --- a/packages/cli/src/modules/mcp/tools/workflow-builder/validate-workflow-code.tool.ts +++ b/packages/cli/src/modules/mcp/tools/workflow-builder/validate-workflow-code.tool.ts @@ -11,7 +11,7 @@ import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; import { getSdkReferenceHint } from '../workflow-validation.utils'; import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check'; -import { CODE_BUILDER_VALIDATE_TOOL } from './constants'; +import { CODE_BUILDER_VALIDATE_TOOL, MAX_WORKFLOW_CODE_LENGTH } from './constants'; export type ValidateWorkflowCodeToolOptions = { /** @@ -26,8 +26,9 @@ export type ValidateWorkflowCodeToolOptions = { const inputSchema = { code: z .string() + .max(MAX_WORKFLOW_CODE_LENGTH) .describe( - 'Full TypeScript/JavaScript workflow code using the n8n Workflow SDK. Must include the workflow export.', + `Full TypeScript/JavaScript workflow code using the n8n Workflow SDK. Must include the workflow export. Max ${MAX_WORKFLOW_CODE_LENGTH} characters.`, ), } satisfies z.ZodRawShape; diff --git a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts index 242a2871f50..22ee959df6f 100644 --- a/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts +++ b/packages/cli/src/modules/oauth-server/__tests__/oauth-server.service.test.ts @@ -416,6 +416,126 @@ describe('OAuthServerService', () => { 'Database error', ); }); + + describe('validation', () => { + const baseClientInfo = { + client_id: 'new-client-123', + client_name: 'New Client', + redirect_uris: ['https://example.com/callback'], + grant_types: ['authorization_code'], + token_endpoint_auth_method: 'none', + response_types: ['code'], + scope: 'read', + logo_uri: undefined, + tos_uri: undefined, + }; + + beforeEach(() => { + oauthClientRepository.insert.mockResolvedValue({} as any); + }); + + it('accepts a client_name at the maximum length', async () => { + const clientInfo = { ...baseClientInfo, client_name: 'a'.repeat(255) }; + + await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual( + clientInfo, + ); + }); + + it('accepts a client_name at the maximum length containing supplementary-plane characters', async () => { + // Each 🚀 is one code point but two UTF-16 code units — 255 of them + // must still be accepted, not treated as 510 characters. + const clientInfo = { ...baseClientInfo, client_name: '🚀'.repeat(255) }; + + await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual( + clientInfo, + ); + }); + + it('rejects a client_name over the maximum length', async () => { + const clientInfo = { ...baseClientInfo, client_name: 'a'.repeat(256) }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'client_name exceeds maximum length', + ); + }); + + it('rejects grant_types containing an unsupported value', async () => { + const clientInfo = { ...baseClientInfo, grant_types: ['client_credentials'] }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'grant_types contains an unsupported value', + ); + }); + + it('rejects grant_types containing an oversized entry', async () => { + const clientInfo = { ...baseClientInfo, grant_types: ['a'.repeat(10_000)] }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'grant_types contains an unsupported value', + ); + }); + + it('rejects grant_types exceeding the maximum count', async () => { + const clientInfo = { + ...baseClientInfo, + grant_types: ['authorization_code', 'refresh_token', 'authorization_code'], + }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'grant_types exceeds maximum count', + ); + }); + + it('rejects redirect_uris exceeding the maximum count', async () => { + const clientInfo = { + ...baseClientInfo, + redirect_uris: Array.from({ length: 11 }, (_, i) => `https://example.com/callback${i}`), + }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'redirect_uris exceeds maximum count', + ); + }); + + it('rejects a redirect_uri over the maximum length', async () => { + const clientInfo = { + ...baseClientInfo, + redirect_uris: [`https://example.com/${'a'.repeat(2048)}`], + }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'redirect_uri exceeds maximum length', + ); + }); + + it('accepts a supported token_endpoint_auth_method', async () => { + const clientInfo = { + ...baseClientInfo, + token_endpoint_auth_method: 'client_secret_post', + }; + + await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual( + clientInfo, + ); + }); + + it('accepts a missing token_endpoint_auth_method', async () => { + const clientInfo = { ...baseClientInfo, token_endpoint_auth_method: undefined }; + + await expect(service.clientsStore.registerClient!(clientInfo)).resolves.toEqual( + clientInfo, + ); + }); + + it('rejects an oversized token_endpoint_auth_method', async () => { + const clientInfo = { ...baseClientInfo, token_endpoint_auth_method: 'a'.repeat(10_000) }; + + await expect(service.clientsStore.registerClient!(clientInfo)).rejects.toThrow( + 'token_endpoint_auth_method contains an unsupported value', + ); + }); + }); }); }); diff --git a/packages/cli/src/modules/oauth-server/oauth-server.service.ts b/packages/cli/src/modules/oauth-server/oauth-server.service.ts index 279d8c02126..29af785c38b 100644 --- a/packages/cli/src/modules/oauth-server/oauth-server.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-server.service.ts @@ -43,6 +43,46 @@ import { isSameProtectedResource } from './resource-identity'; /** Maximum number of redirect URIs per client */ const MAX_REDIRECT_URIS = 10; +/** Maximum length for a client_name, matching the column size declared in the CreateOAuthEntities migration */ +const MAX_CLIENT_NAME_LENGTH = 255; + +/** + * Grant types this OAuth server implements. Kept in sync with the + * `grant_types_supported` list advertised by the metadata endpoint + * (see oauth.controller.ts). + */ +const SUPPORTED_GRANT_TYPES = ['authorization_code', 'refresh_token']; + +/** + * Token endpoint auth methods this OAuth server implements. Kept in sync + * with the `token_endpoint_auth_methods_supported` list advertised by the + * metadata endpoint (see oauth.controller.ts). + */ +const SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = ['none', 'client_secret_post', 'client_secret_basic']; + +/** + * Counts Unicode code points rather than UTF-16 code units, so a value + * containing supplementary-plane characters (surrogate pairs) isn't counted + * as longer than it is. Stops once past `limit` to avoid scanning an + * oversized value in full. + */ +function countCodePointsUpTo(value: string, limit: number): number { + let count = 0; + + for (const _codePoint of value) { + count++; + + if (count > limit) { + break; + } + } + + return count; +} + +/** Maximum number of grant types per client — one entry per supported grant type is all a client ever needs */ +const MAX_GRANT_TYPES = SUPPORTED_GRANT_TYPES.length; + export type ConnectedOAuthClientOwner = { id: string; firstName: string | null; @@ -264,10 +304,24 @@ export class OAuthServerService implements OAuthServerProvider { throw new Error('client_name is required'); } + if (countCodePointsUpTo(client.client_name, MAX_CLIENT_NAME_LENGTH) > MAX_CLIENT_NAME_LENGTH) { + throw new Error(`client_name exceeds maximum length of ${MAX_CLIENT_NAME_LENGTH} characters`); + } + if (!client.grant_types || client.grant_types.length === 0) { throw new Error('grant_types is required'); } + if (client.grant_types.length > MAX_GRANT_TYPES) { + throw new Error(`grant_types exceeds maximum count of ${MAX_GRANT_TYPES}`); + } + + for (const grantType of client.grant_types) { + if (!SUPPORTED_GRANT_TYPES.includes(grantType)) { + throw new Error('grant_types contains an unsupported value'); + } + } + if (!client.redirect_uris || client.redirect_uris.length === 0) { throw new Error('redirect_uris is required'); } @@ -283,6 +337,13 @@ export class OAuthServerService implements OAuthServerProvider { ); } } + + if ( + client.token_endpoint_auth_method !== undefined && + !SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS.includes(client.token_endpoint_auth_method) + ) { + throw new Error('token_endpoint_auth_method contains an unsupported value'); + } } /** diff --git a/packages/cli/src/modules/oauth-server/oauth-session.service.ts b/packages/cli/src/modules/oauth-server/oauth-session.service.ts index 68a44163b1a..a7516d3ab7a 100644 --- a/packages/cli/src/modules/oauth-server/oauth-session.service.ts +++ b/packages/cli/src/modules/oauth-server/oauth-session.service.ts @@ -14,7 +14,7 @@ export interface OAuthSessionPayload { requestedScopes?: string[]; } -const COOKIE_NAME = 'n8n-oauth-session'; +export const OAUTH_SESSION_COOKIE_NAME = 'n8n-oauth-session'; const SESSION_EXPIRY_MS = 10 * Time.minutes.toMilliseconds; // 10 minutes /** @@ -33,7 +33,7 @@ export class OAuthSessionService { expiresIn: '10m', }); - res.cookie(COOKIE_NAME, sessionToken, { + res.cookie(OAUTH_SESSION_COOKIE_NAME, sessionToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', @@ -52,13 +52,13 @@ export class OAuthSessionService { * Clear OAuth session cookie */ clearSession(res: Response): void { - res.clearCookie(COOKIE_NAME); + res.clearCookie(OAUTH_SESSION_COOKIE_NAME); } /** * Extract session token from request cookies */ getSessionToken(cookies: Record): string | undefined { - return cookies[COOKIE_NAME]; + return cookies[OAUTH_SESSION_COOKIE_NAME]; } } diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts index 2306ba13059..2527eaa57ae 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control.service.test.ts @@ -302,33 +302,202 @@ describe('SourceControlService', () => { }); }); - it('should throw an error if file path validation fails', async () => { + it('acts on the authorized resource, ignoring the client-supplied file path and status', async () => { + // ARRANGE + // The status service authorizes only the caller's own resource, with the + // server-derived path and status. const user = mock(); - (isContainedWithin as Mock).mockReturnValueOnce(false); + const now = new Date().toISOString(); + const authorizedResource: SourceControlledFile = { + id: 'authorized-wf', + name: 'Authorized Workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + file: 'workflows/authorized-wf.json', + updatedAt: now, + }; + mockStatusService.getStatus.mockResolvedValueOnce([authorizedResource]); + sourceControlExportService.exportCredentialsToWorkFolder.mockResolvedValueOnce({ + count: 0, + missingIds: [], + folder: '', + files: [], + }); + (isContainedWithin as Mock).mockReturnValue(true); + gitService.push.mockResolvedValueOnce(mock()); + + const authorizedPath = `${preferencesService.gitFolder}/workflows/authorized-wf.json`; + const otherProjectPath = `${preferencesService.gitFolder}/workflows/other-project-wf.json`; + + // ACT + // The caller pairs an authorized (id, type) with another resource's file path + // and flips the status to 'deleted'. Both must be ignored. + await sourceControlService.pushWorkfolder(user, { + fileNames: [ + { + id: 'authorized-wf', + type: 'workflow', + status: 'deleted', + file: 'workflows/other-project-wf.json', + name: 'anything', + location: 'local', + conflict: false, + updatedAt: now, + }, + ], + commitMessage: 'chore: tidy up', + }); + + // ASSERT + // Nothing is deleted (server status is 'modified'), and the other project's file + // is never touched. + expect(sourceControlExportService.rmFilesFromExportFolder).toHaveBeenCalledWith(new Set()); + const [staged, deleted] = gitService.stage.mock.calls[0]; + expect(deleted).toEqual(new Set()); + expect(staged).not.toContain(otherProjectPath); + // The authorized resource, at its server-derived path, is acted upon instead. + expect(staged).toContain(authorizedPath); + }); + + it('throws ForbiddenError when pushing a resource that is not allowed', async () => { + // ARRANGE + const user = mock(); + mockStatusService.getStatus.mockResolvedValueOnce([ + { + id: 'authorized-wf', + name: 'Authorized Workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + file: 'workflows/authorized-wf.json', + updatedAt: new Date().toISOString(), + }, + ]); + (isContainedWithin as Mock).mockReturnValue(true); + + // ACT & ASSERT await expect( sourceControlService.pushWorkfolder(user, { fileNames: [ { - file: '/etc/passwd', - id: 'test', - name: 'secret-file', - type: 'file', - status: 'modified', + id: 'out-of-scope-wf', + type: 'workflow', + status: 'deleted', + file: 'workflows/out-of-scope-wf.json', + name: 'out-of-scope', location: 'local', conflict: false, updatedAt: new Date().toISOString(), - pushed: false, }, ], }), - ).rejects.toThrow('File path /etc/passwd is invalid'); + ).rejects.toThrow('You are not allowed to push these changes'); expect(gitService.stage).not.toHaveBeenCalled(); expect(gitService.commit).not.toHaveBeenCalled(); expect(gitService.push).not.toHaveBeenCalled(); }); + it('rejects the push when an authorized resource resolves outside the git folder', async () => { + // ARRANGE + // Even though the resource is authorized, its server-derived path escapes the git + // work folder. The defense-in-depth path validation must still reject the push. + const user = mock(); + mockStatusService.getStatus.mockResolvedValueOnce([ + { + id: 'authorized-wf', + name: 'Authorized Workflow', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + file: '/outside-git/authorized-wf.json', + updatedAt: new Date().toISOString(), + }, + ]); + (isContainedWithin as Mock).mockReturnValueOnce(false); + + // ACT & ASSERT + await expect( + sourceControlService.pushWorkfolder(user, { + fileNames: [ + { + id: 'authorized-wf', + type: 'workflow', + status: 'modified', + file: 'workflows/authorized-wf.json', + name: 'authorized', + location: 'local', + conflict: false, + updatedAt: new Date().toISOString(), + }, + ], + }), + ).rejects.toThrow('File path /outside-git/authorized-wf.json is invalid'); + + expect(gitService.stage).not.toHaveBeenCalled(); + expect(gitService.commit).not.toHaveBeenCalled(); + expect(gitService.push).not.toHaveBeenCalled(); + }); + + it('preserves a server-reported conflict when several status records share a (type, id)', async () => { + // ARRANGE + // Status returns two records for the same (type, id): the conflicting one first, so a + // de-duplication that kept only the last record would discard the conflict. + const user = mock(); + const now = new Date().toISOString(); + mockStatusService.getStatus.mockResolvedValueOnce([ + { + id: 'wf-1', + name: 'Workflow 1', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: true, + file: 'workflows/wf-1.json', + updatedAt: now, + }, + { + id: 'wf-1', + name: 'Workflow 1', + type: 'workflow', + status: 'modified', + location: 'local', + conflict: false, + file: 'workflows/wf-1.json', + updatedAt: now, + }, + ]); + (isContainedWithin as Mock).mockReturnValue(true); + + // ACT + const result = await sourceControlService.pushWorkfolder(user, { + fileNames: [ + { + id: 'wf-1', + type: 'workflow', + status: 'modified', + file: 'workflows/wf-1.json', + name: 'Workflow 1', + location: 'local', + conflict: false, + updatedAt: now, + }, + ], + }); + + // ASSERT + // The conflict must surface as a 409 and block the push. + expect(result.statusCode).toBe(409); + expect(gitService.stage).not.toHaveBeenCalled(); + expect(gitService.commit).not.toHaveBeenCalled(); + expect(gitService.push).not.toHaveBeenCalled(); + }); + it('should include the tags file even if not explicitly specified', async () => { // ARRANGE const user = mock(); diff --git a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts index c0017ea875d..0d089cb5604 100644 --- a/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control.service.ee.ts @@ -318,6 +318,56 @@ export class SourceControlService { ); } + /** + * Resolves which resources a push may act on. The status service is the single source of + * truth for what the user may push: it scopes resources to the user's projects and derives + * each resource's file path and status on the server. The client only selects resources by + * (id, type); file path and status are always taken from the matching authorized entry, so + * an authorized (id, type) cannot be pointed at another resource's file. + */ + private async resolveAuthorizedFilesToPush( + user: User, + requestedFiles: SourceControlledFile[], + ): Promise { + const allowedResources = await this.sourceControlStatusService.getStatus(user, { + direction: 'push', + verbose: false, + preferLocalVersion: true, + }); + + // No explicit selection: push everything the user is allowed to. + let filesToPush = allowedResources; + + if (requestedFiles.length) { + const allowedKeys = new Set( + allowedResources.map((allowed) => `${allowed.type}:${allowed.id}`), + ); + const requestedKeys = new Set(); + for (const requested of requestedFiles) { + const key = `${requested.type}:${requested.id}`; + if (!allowedKeys.has(key)) { + throw new ForbiddenError('You are not allowed to push these changes'); + } + requestedKeys.add(key); + } + // Keep every authorized record for the requested resources. Filtering rather than + // mapping each request to a single record means a server-reported conflict for a + // (type, id) is never dropped by de-duplication, so the conflict check in the caller + // still sees it. + filesToPush = allowedResources.filter((allowed) => + requestedKeys.has(`${allowed.type}:${allowed.id}`), + ); + } + + // Defense in depth: every path we act on must stay inside the git work folder. + // Server-derived paths already are, but this guards against a regression producing + // a path outside it. + return filesToPush.map((file) => ({ + ...file, + file: normalizeAndValidateSourceControlledFilePath(this.gitFolder, file.file), + })); + } + private async pushWorkfolderWithoutLock( user: User, options: PushWorkFolderRequestDto, @@ -334,41 +384,7 @@ export class SourceControlService { const context = await this.sourceControlContextFactory.createContext(user); - let filesToPush: SourceControlledFile[] = options.fileNames.map((file) => { - const normalizedPath = normalizeAndValidateSourceControlledFilePath( - this.gitFolder, - file.file, - ); - - return { - ...file, - file: normalizedPath, - }; - }); - - const allowedResources = await this.sourceControlStatusService.getStatus(user, { - direction: 'push', - verbose: false, - preferLocalVersion: true, - }); - - // Fallback to all allowed resources if no fileNames are provided - if (!filesToPush.length) { - filesToPush = allowedResources; - } - - // If fileNames are provided, we need to check if they are allowed - if ( - filesToPush !== allowedResources && - filesToPush.some( - (file) => - !allowedResources.some((allowed) => { - return allowed.id === file.id && allowed.type === file.type; - }), - ) - ) { - throw new ForbiddenError('You are not allowed to push these changes'); - } + const filesToPush = await this.resolveAuthorizedFilesToPush(user, options.fileNames); let statusResult: SourceControlledFile[] = filesToPush; diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts index 9a4c887b508..fc3422b8cfc 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.controller.ee.test.ts @@ -7,6 +7,7 @@ import { mock } from 'vitest-mock-extended'; import type { AuthService } from '@/auth/auth.service'; import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import type { EventService } from '@/events/event.service'; import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-access-denied.error'; import type { AuthlessRequest } from '@/requests'; @@ -180,6 +181,30 @@ describe('OidcController', () => { expect(res.redirect).not.toHaveBeenCalled(); }); + test('Should not issue a session when OIDC is disabled during the login flow', async () => { + const req = mock({ + originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value', + browserId: 'browser-id-123', + cookies: { + [OIDC_STATE_COOKIE_NAME]: 'state_value', + [OIDC_NONCE_COOKIE_NAME]: 'nonce_value', + }, + }); + const res = mock(); + + // loginUser resolves, but OIDC is disabled while it was awaiting: the + // pre-issue re-check must reject before a cookie is minted. + oidcService.loginUser.mockResolvedValueOnce({ user }); + oidcService.assertOidcLoginEnabled.mockImplementationOnce(() => { + throw new ForbiddenError('OIDC login is not enabled'); + }); + + await expect(controller.callbackHandler(req, res)).rejects.toThrow(ForbiddenError); + + expect(authService.issueCookie).not.toHaveBeenCalled(); + expect(res.redirect).not.toHaveBeenCalled(); + }); + test('Should store the encrypted ID token in a cookie for RP-initiated logout', async () => { const req = mock({ originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value', diff --git a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts index 074d79b58d1..5455d413d4a 100644 --- a/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts +++ b/packages/cli/src/modules/sso-oidc/__tests__/oidc.service.ee.test.ts @@ -63,6 +63,12 @@ describe('OidcService', () => { loadOnStartup: true, }; + const setOidcState = (loginEnabled: boolean, isActive: boolean) => { + vi.spyOn(ssoHelpers, 'isOidcCurrentAuthenticationMethod').mockReturnValue(isActive); + const svc = oidcService as unknown as { oidcConfig: Record }; + svc.oidcConfig = { ...svc.oidcConfig, loginEnabled }; + }; + beforeEach(async () => { vi.resetAllMocks(); Container.reset(); @@ -109,6 +115,7 @@ describe('OidcService', () => { ); await oidcService.init(); + setOidcState(true, true); }); describe('reload', () => { @@ -739,6 +746,38 @@ describe('OidcService', () => { }); }); + describe('login flow requires OIDC to be the active, enabled method', () => { + const login = async () => + await oidcService.loginUser( + new URL('https://example.com/callback'), + oidcService.generateState().signed, + oidcService.generateNonce().signed, + ); + + it('generateLoginUrl is rejected when login is disabled', async () => { + setOidcState(false, true); + await expect(oidcService.generateLoginUrl()).rejects.toThrow(ForbiddenError); + }); + + it('generateLoginUrl is rejected when another method is active', async () => { + setOidcState(true, false); + await expect(oidcService.generateLoginUrl()).rejects.toThrow(ForbiddenError); + }); + + it('loginUser is rejected and issues no session when login is disabled', async () => { + setOidcState(false, true); + await expect(login()).rejects.toThrow(ForbiddenError); + expect(client.authorizationCodeGrant).not.toHaveBeenCalled(); + expect(authIdentityRepository.findOne).not.toHaveBeenCalled(); + }); + + it('loginUser is rejected when another method is active', async () => { + setOidcState(true, false); + await expect(login()).rejects.toThrow(ForbiddenError); + expect(client.authorizationCodeGrant).not.toHaveBeenCalled(); + }); + }); + describe('applySsoProvisioning', () => { const claims = { sub: 'user-123', n8n_instance_role: 'global:member' }; const userInfo = { email: 'test@example.com', email_verified: true }; diff --git a/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts index 77eda46e51c..cf55b496a50 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.controller.ee.ts @@ -158,6 +158,7 @@ export class OidcController { throw error; } + this.oidcService.assertOidcLoginEnabled(); this.authService.issueCookie(res, user, true, req.browserId); // Persist the encrypted ID token so a later sign-out can perform OIDC diff --git a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts index d202b97aff8..66a1e377d4e 100644 --- a/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts +++ b/packages/cli/src/modules/sso-oidc/oidc.service.ee.ts @@ -219,7 +219,14 @@ export class OidcService { return nonce; } + assertOidcLoginEnabled(): void { + if (!this.oidcConfig.loginEnabled || !isOidcCurrentAuthenticationMethod()) { + throw new ForbiddenError('OIDC login is not enabled'); + } + } + async generateLoginUrl(): Promise<{ url: URL; state: string; nonce: string }> { + this.assertOidcLoginEnabled(); await this.loadOpenIdClient(); const configuration = await this.getOidcConfiguration(); @@ -267,6 +274,7 @@ export class OidcService { storedState: string, storedNonce: string, ): Promise<{ user: User; idToken?: string }> { + this.assertOidcLoginEnabled(); await this.loadOpenIdClient(); const configuration = await this.getOidcConfiguration(); diff --git a/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts b/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts index 8e50ed82384..39d1a84f30d 100644 --- a/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts @@ -4,12 +4,14 @@ import { InstanceSettingsLoaderConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { eventNamesAll } from '@/eventbus/event-message-classes'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { createMessageEventBusDestination } from '@/modules/log-streaming.ee/create-message-event-bus-destination'; +import { assertUserCanUseDestinationCredentials } from '@/modules/log-streaming.ee/destinations/destination-credentials-access'; import { LogStreamingDestinationService } from '@/modules/log-streaming.ee/log-streaming-destination.service'; import { toInternalDestinationOptions, toPublicDestination } from './log-streaming.mapper'; @@ -84,10 +86,18 @@ const logStreamingHandlers: LogStreamingHandlers = { throw new BadRequestError(parseResult.error.errors[0].message); } + const options = toInternalDestinationOptions(parseResult.data); + + await assertUserCanUseDestinationCredentials( + Container.get(CredentialsFinderService), + req.user, + options, + ); + const destination = createMessageEventBusDestination( Container.get(MessageEventBus), Container.get(OutboundHttp), - toInternalDestinationOptions(parseResult.data), + options, ); const result = await Container.get(LogStreamingDestinationService).addDestination( destination, @@ -110,10 +120,18 @@ const logStreamingHandlers: LogStreamingHandlers = { } // the path id is the update target; addDestination upserts on it + const options = { ...toInternalDestinationOptions(parseResult.data), id: req.params.id }; + + await assertUserCanUseDestinationCredentials( + Container.get(CredentialsFinderService), + req.user, + options, + ); + const destination = createMessageEventBusDestination( Container.get(MessageEventBus), Container.get(OutboundHttp), - { ...toInternalDestinationOptions(parseResult.data), id: req.params.id }, + options, ); const result = await Container.get(LogStreamingDestinationService).addDestination( destination, @@ -127,7 +145,12 @@ const logStreamingHandlers: LogStreamingHandlers = { isLicensed('feat:logStreaming'), apiKeyHasScopeWithGlobalScopeFallback({ scope: 'eventBusDestination:test' }), async (req, res) => { - await findDestinationOrFail(req.params.id); + const destination = await findDestinationOrFail(req.params.id); + await assertUserCanUseDestinationCredentials( + Container.get(CredentialsFinderService), + req.user, + destination, + ); // a delivery failure is a failed test, not a server error → { success: false } let success: boolean; try { diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index f013f443e69..32424632be8 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -114,8 +114,9 @@ export abstract class AbstractPush extends TypedEmitter) { const { connections } = this; + const userIdSet = new Set(userIds); const userPushRefs = Object.keys(connections).filter((pushRef) => - userIds.includes(this.userIdByPushRef[pushRef]), + userIdSet.has(this.userIdByPushRef[pushRef]), ); this.sendTo(pushMsg, userPushRefs); diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 04aef44f2a4..3cdf98e5245 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -29,6 +29,7 @@ import * as ResponseHelper from '@/response-helper'; import { resolveContentSecurityPolicies } from '@/security/content-security-policy'; import type { FrontendService } from '@/services/frontend.service'; import { Telemetry } from '@/telemetry'; +import * as requestPath from '@/utils/request-path'; import '@/controllers/active-workflows.controller'; import '@/controllers/annotation-tags.controller.ee'; @@ -343,25 +344,7 @@ export class Server extends AbstractServer { const cacheOptions = inE2ETests || inDevelopment ? {} : { maxAge }; const { staticCacheDir } = Container.get(InstanceSettings); - // Protect type files with authentication regardless of UI availability - const authService = Container.get(AuthService); - const protectedTypeFiles = [ - '/types/nodes.json', - '/types/credentials.json', - '/types/node-versions.json', - ]; - protectedTypeFiles.forEach((path) => { - this.app.get( - path, - authService.createAuthMiddleware({ allowSkipMFA: true, allowSkipPreviewAuth: true }), - async (_, res: express.Response) => { - res.setHeader('Cache-Control', 'no-cache, must-revalidate'); - res.sendFile(path.substring(1), { - root: staticCacheDir, - }); - }, - ); - }); + this.protectTypeFiles(staticCacheDir); if (frontendService) { this.app.use( @@ -445,7 +428,9 @@ export class Server extends AbstractServer { this.endpointPresetCredentials, ...this.globalConfig.endpoints.additionalNonUIRoutes.split(':'), ].filter((u) => !!u); - const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`); + // Matched against the normalised path, so a non-UI asset requested in another + // case or spelling reaches the static handler rather than the editor page. + const nonUIRoutesRegex = new RegExp(`^/(${nonUIRoutes.join('|')})/?.*$`, 'i'); // `index.html` does not change while n8n runs. Read it once and keep it split // around the nonce placeholders, so serving a request is only a join. @@ -469,7 +454,7 @@ export class Server extends AbstractServer { method === 'GET' && (!accept || req.accepts('html') || accept.includes('*/*')) && !req.path.endsWith('.wasm') && - !nonUIRoutesRegex.test(req.path) + !nonUIRoutesRegex.test(requestPath.normalize(req.path)) ) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate, proxy-revalidate'); @@ -491,19 +476,10 @@ export class Server extends AbstractServer { next(); } }; - const setCustomCacheHeader = (res: express.Response) => { - if (/^\/types\/(nodes|credentials).json$/.test(res.req.url)) { - res.setHeader('Cache-Control', 'no-cache, must-revalidate'); - } - }; - this.app.use( '/', historyApiHandler, - express.static(staticCacheDir, { - ...cacheOptions, - setHeaders: setCustomCacheHeader, - }), + express.static(staticCacheDir, cacheOptions), express.static(EDITOR_UI_DIST_DIR, cacheOptions), ); } else { @@ -511,6 +487,35 @@ export class Server extends AbstractServer { } } + /** Authenticates every request that can reach a type file, whether or not the editor is served. */ + private protectTypeFiles(staticCacheDir: string) { + const authMiddleware = Container.get(AuthService).createAuthMiddleware({ + allowSkipMFA: true, + allowSkipPreviewAuth: true, + }); + + // Match exact urls. We always expect them in this form, and only a matched route + // path can skip AuthService's browser-id check, which the editor's fetch needs. + const typeFiles = ['/types/nodes.json', '/types/credentials.json', '/types/node-versions.json']; + typeFiles.forEach((typeFile) => { + this.app.get(typeFile, authMiddleware, async (_, res: express.Response) => { + res.setHeader('Cache-Control', 'no-cache, must-revalidate'); + res.sendFile(typeFile.substring(1), { root: staticCacheDir }); + }); + }); + + // Deny any request that can reach /types/* that isn't caught above, rather than + // authenticating it: no client asks for those forms, and authenticating here would + // clear the session cookie of a signed-in caller, since this matches no route. + this.app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { + if (requestPath.mayReachDirectory(req.path, 'types')) { + res.status(401).end(); + return; + } + next(); + }); + } + private configureSettingsRoute() { const { frontendService } = this; const authService = Container.get(AuthService); diff --git a/packages/cli/src/services/__tests__/active-workflows.service.test.ts b/packages/cli/src/services/__tests__/active-workflows.service.test.ts index 9d93e6060ce..4c6b23ed7c3 100644 --- a/packages/cli/src/services/__tests__/active-workflows.service.test.ts +++ b/packages/cli/src/services/__tests__/active-workflows.service.test.ts @@ -1,5 +1,5 @@ import { GLOBAL_ADMIN_ROLE, GLOBAL_MEMBER_ROLE, WorkflowEntity } from '@n8n/db'; -import type { User, SharedWorkflowRepository, WorkflowRepository } from '@n8n/db'; +import type { User, WorkflowRepository } from '@n8n/db'; import { mock } from 'vitest-mock-extended'; import type { ActivationErrorsService } from '@/activation-errors.service'; @@ -7,21 +7,21 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import type { ProjectScopeService } from '@/permissions.ee/project-scope.service'; import { ActiveWorkflowsService } from '@/services/active-workflows.service'; import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; describe('ActiveWorkflowsService', () => { const user = mock(); const workflowRepository = mock(); - const sharedWorkflowRepository = mock(); + const workflowSharingService = mock(); const workflowFinderService = mock(); const activationErrorsService = mock(); const projectScopeService = mock(); const service = new ActiveWorkflowsService( mock(), workflowRepository, - sharedWorkflowRepository, + workflowSharingService, activationErrorsService, workflowFinderService, - projectScopeService, ); const activeIds = ['1', '2', '3', '4']; @@ -49,34 +49,26 @@ describe('ActiveWorkflowsService', () => { const ids = await service.getAllActiveIdsFor(user); expect(ids).toEqual(['2', '3', '4']); - expect(projectScopeService.getProjectRoleSlugs).toHaveBeenCalledWith(user, ['workflow:list']); - expect(sharedWorkflowRepository.findWorkflowIdsInUserProjects).not.toHaveBeenCalled(); + expect(workflowSharingService.getSharedWorkflowIds).not.toHaveBeenCalled(); }); - it('should filter out workflow ids that the user cannot list', async () => { - user.role = GLOBAL_MEMBER_ROLE; - user.id = 'user-1'; - projectScopeService.getProjectRoleSlugs.mockResolvedValue(['project:admin']); - sharedWorkflowRepository.findWorkflowIdsInUserProjects.mockResolvedValue(new Set(['3'])); - const ids = await service.getAllActiveIdsFor(user); + it.each([ + { accessible: ['3'], expected: ['3'] }, + { accessible: [], expected: [] }, + ])( + 'should return only workflow ids the member has access to (accessible: $accessible)', + async ({ accessible, expected }) => { + user.role = GLOBAL_MEMBER_ROLE; + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(accessible); - expect(ids).toEqual(['3']); - expect(projectScopeService.getProjectRoleSlugs).toHaveBeenCalledWith(user, ['workflow:list']); - expect(sharedWorkflowRepository.findWorkflowIdsInUserProjects).toHaveBeenCalledWith( - activeIds, - 'user-1', - ['project:admin'], - ); - }); + const ids = await service.getAllActiveIdsFor(user); - it('should return no ids when the user cannot list workflows in any project', async () => { - user.role = GLOBAL_MEMBER_ROLE; - projectScopeService.getProjectRoleSlugs.mockResolvedValue([]); - sharedWorkflowRepository.findWorkflowIdsInUserProjects.mockResolvedValue(new Set()); - const ids = await service.getAllActiveIdsFor(user); - - expect(ids).toEqual([]); - }); + expect(ids).toEqual(expected); + expect(workflowSharingService.getSharedWorkflowIds).toHaveBeenCalledWith(user, { + scopes: ['workflow:read'], + }); + }, + ); }); describe('getActivationError', () => { diff --git a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts index a058f196de1..7b01dd37d79 100644 --- a/packages/cli/src/services/__tests__/credentials-tester.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-tester.service.test.ts @@ -354,6 +354,25 @@ describe('CredentialsTester', () => { }); }); + it('pins the probe request to the target host', async () => { + mockRoutingNodeResult({ resolve: [[{ json: {} }]] }); + + await credentialsTester.probeCredentialAuth( + 'user-id', + 'httpHeaderAuth', + credentials(), + targetUrl, + { allowedDomains: 'fal.run' }, + ); + + const nodeTypeArg = (RoutingNode as unknown as Mock).mock.calls[0][1] as INodeType; + expect(nodeTypeArg.description.properties[0].routing?.request).toEqual({ + url: targetUrl, + method: 'GET', + allowedDomains: 'fal.run', + }); + }); + it('fails only on an explicit auth rejection', async () => { mockRoutingNodeResult({ reject: httpError(401) }); diff --git a/packages/cli/src/services/active-workflows.service.ts b/packages/cli/src/services/active-workflows.service.ts index 0223c66dfa8..232449f05a5 100644 --- a/packages/cli/src/services/active-workflows.service.ts +++ b/packages/cli/src/services/active-workflows.service.ts @@ -1,22 +1,22 @@ import { Logger } from '@n8n/backend-common'; import type { User } from '@n8n/db'; -import { SharedWorkflowRepository, WorkflowRepository } from '@n8n/db'; +import { WorkflowRepository } from '@n8n/db'; import { Service } from '@n8n/di'; +import { hasGlobalScope } from '@n8n/permissions'; import { ActivationErrorsService } from '@/activation-errors.service'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { ProjectScopeService } from '@/permissions.ee/project-scope.service'; import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; +import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @Service() export class ActiveWorkflowsService { constructor( private readonly logger: Logger, private readonly workflowRepository: WorkflowRepository, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly workflowSharingService: WorkflowSharingService, private readonly activationErrorsService: ActivationErrorsService, private readonly workflowFinderService: WorkflowFinderService, - private readonly projectScopeService: ProjectScopeService, ) {} async getAllActiveIdsInStorage() { @@ -28,21 +28,19 @@ export class ActiveWorkflowsService { async getAllActiveIdsFor(user: User) { const activationErrors = await this.activationErrorsService.getAll(); const activeWorkflowIds = await this.workflowRepository.getActiveIds(); - const projectRoleSlugs = await this.projectScopeService.getProjectRoleSlugs(user, [ - 'workflow:list', - ]); - const listableWorkflowIds = - projectRoleSlugs === null - ? new Set(activeWorkflowIds) - : await this.sharedWorkflowRepository.findWorkflowIdsInUserProjects( - activeWorkflowIds, - user.id, - projectRoleSlugs, - ); + const hasFullAccess = hasGlobalScope(user, 'workflow:read'); + if (hasFullAccess) { + return activeWorkflowIds.filter((workflowId) => !activationErrors[workflowId]); + } + // Every ID here is a workflow the requesting user specifically has + // workflow:read access to. + const accessibleWorkflowIds = new Set( + await this.workflowSharingService.getSharedWorkflowIds(user, { scopes: ['workflow:read'] }), + ); return activeWorkflowIds.filter( - (workflowId) => listableWorkflowIds.has(workflowId) && !activationErrors[workflowId], + (workflowId) => accessibleWorkflowIds.has(workflowId) && !activationErrors[workflowId], ); } diff --git a/packages/cli/src/services/credentials-tester.service.ts b/packages/cli/src/services/credentials-tester.service.ts index 44ff83c507d..fedb8fc9789 100644 --- a/packages/cli/src/services/credentials-tester.service.ts +++ b/packages/cli/src/services/credentials-tester.service.ts @@ -257,7 +257,7 @@ export class CredentialsTester { credentialType: string, credentialsDecrypted: ICredentialsDecrypted, targetUrl: string, - options: { acceptedStatusCodes?: number[] } = {}, + options: { acceptedStatusCodes?: number[]; allowedDomains?: string } = {}, ): Promise { try { await this.prepareCredentialsForTest(userId, credentialType, credentialsDecrypted); @@ -274,7 +274,15 @@ export class CredentialsTester { userId, credentialType, credentialsDecrypted, - { testRequest: { request: { url: targetUrl, method: 'GET' } } }, + { + testRequest: { + request: { + url: targetUrl, + method: 'GET', + ...(options.allowedDomains ? { allowedDomains: options.allowedDomains } : {}), + }, + }, + }, 'authProbe', options.acceptedStatusCodes, ); diff --git a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts index 7598c931cb9..031881b0f3f 100644 --- a/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts +++ b/packages/cli/src/task-runners/task-broker/auth/__tests__/task-broker-auth.service.test.ts @@ -116,6 +116,26 @@ describe('TaskBrokerAuthService', () => { }); }); + it('should be valid only once when consumed concurrently', async () => { + // Arrange + const grantToken = await authService.createGrantToken('runner1'); + + // Act + const results = await Promise.all([ + authService.tryConsumeGrantToken(grantToken), + authService.tryConsumeGrantToken(grantToken), + ]); + + // Assert + expect(results).toHaveLength(2); + expect(results.filter((result) => result.isValid)).toEqual([ + { isValid: true, boundRunnerId: 'runner1' }, + ]); + expect(results.filter((result) => !result.isValid)).toEqual([ + { isValid: false, boundRunnerId: null }, + ]); + }); + it('should be invalid for an expired grant token', async () => { // Arrange const grantToken = await authService.createGrantToken(); diff --git a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts index 7a79b4dee2e..ba133caf0ad 100644 --- a/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts +++ b/packages/cli/src/task-runners/task-broker/auth/task-broker-auth.service.ts @@ -58,12 +58,10 @@ export class TaskBrokerAuthService { */ async tryConsumeGrantToken(grantToken: string): Promise { const key = this.cacheKeyForGrantToken(grantToken); - const consumed = await this.cacheService.get(key); - // Not found from cache --> Invalid token + const consumed = await this.cacheService.take(key); + // Not found in cache --> Invalid or already consumed token if (consumed === undefined) return { isValid: false, boundRunnerId: null }; - await this.cacheService.delete(key); - return { isValid: true, boundRunnerId: consumed === NO_BOUND_RUNNER ? null : consumed, diff --git a/packages/cli/src/utils/__tests__/request-path.test.ts b/packages/cli/src/utils/__tests__/request-path.test.ts new file mode 100644 index 00000000000..842140f1626 --- /dev/null +++ b/packages/cli/src/utils/__tests__/request-path.test.ts @@ -0,0 +1,94 @@ +import { mayReachDirectory, normalize } from '../request-path'; + +describe('normalize', () => { + it.each([ + ['leaves an already canonical path unchanged', '/types/nodes.json', '/types/nodes.json'], + ['collapses a run of slashes', '/types///nodes.json', '/types/nodes.json'], + ['collapses a leading double slash', '//types/nodes.json', '/types/nodes.json'], + ['decodes an encoded slash', '/types%2Fnodes.json', '/types/nodes.json'], + ['decodes an encoded letter', '/types/%6Eodes.json', '/types/nodes.json'], + ['decodes only once', '/types/%252fnodes.json', '/types/%2fnodes.json'], + ['keeps a malformed percent sequence', '/types/%zz', '/types/%zz'], + ['resolves a single dot segment', '/types/./nodes.json', '/types/nodes.json'], + ['resolves a double dot segment', '/types/x/../nodes.json', '/types/nodes.json'], + ['resolves an encoded double dot segment', '/types/x/%2e%2e/nodes.json', '/types/nodes.json'], + ['drops a double dot climbing above the root', '/../../etc/passwd', '/etc/passwd'], + ['lower-cases the result', '/TYPES/Nodes.JSON', '/types/nodes.json'], + ['treats a backslash as a separator', '\\types\\nodes.json', '/types/nodes.json'], + ['returns the root unchanged', '/', '/'], + ['strips a trailing slash', '/types/', '/types'], + ['prefixes a path without a leading slash', 'types/nodes.json', '/types/nodes.json'], + ['returns the root for an empty input', '', '/'], + ['trims trailing dots from a directory segment', '/types../nodes.json', '/types/nodes.json'], + [ + 'trims a trailing space from a directory segment', + '/types%20/nodes.json', + '/types/nodes.json', + ], + ['trims a trailing dot from a file segment', '/types/nodes.json.', '/types/nodes.json'], + ['drops a segment of a single space', '/types/%20/nodes.json', '/types/nodes.json'], + ['drops a segment made only of dots', '/types/.../nodes.json', '/types/nodes.json'], + [ + 'resolves a space-suffixed double dot segment', + '/foo/..%20/assets/main.js', + '/assets/main.js', + ], + ['resolves a space-suffixed single dot segment', '/types/.%20/nodes.json', '/types/nodes.json'], + [ + 'drops a space-suffixed segment of three dots', + '/types/x/...%20/nodes.json', + '/types/x/nodes.json', + ], + [ + 'drops a segment made only of dots and spaces', + '/types/.%20.%20/nodes.json', + '/types/nodes.json', + ], + ['returns the root for a path made only of such segments', '/.../%20%20/', '/'], + ])('%s', (_title, input, expected) => { + expect(normalize(input)).toBe(expected); + }); +}); + +describe('mayReachDirectory', () => { + it.each([ + ['the directory itself', '/types'], + ['a file in the directory', '/types/nodes.json'], + ['a mixed-case form', '/TYPES/Nodes.JSON'], + ['an encoded separator', '/types%2Fnodes.json'], + ['a repeated separator', '/types//nodes.json'], + ['a backslash separator', '\\types\\nodes.json'], + ['a dot segment resolving into the directory', '/types/x/../nodes.json'], + ['a trailing dot on the directory segment', '/types./nodes.json'], + ['a trailing space on the directory segment', '/types%20/nodes.json'], + ['a malformed percent sequence anywhere in the path', '/foo/%zz/bar'], + ])('reports %s as reaching the directory', (_title, requestPath) => { + expect(mayReachDirectory(requestPath, 'types')).toBe(true); + }); + + it('reports a match against a mixed-case directory argument', () => { + expect(mayReachDirectory('/types/nodes.json', 'Types')).toBe(true); + }); + + it.each([ + ['a space-suffixed double dot', '/foo/..%20/types/nodes.json'], + ['a dot-suffixed double dot', '/foo/..%20./types/nodes.json'], + ['a space-suffixed double dot inside the directory', '/types/foo/..%20/nodes.json'], + ['a space-suffixed single dot', '/foo/.%20/types/nodes.json'], + ['a three-dot segment', '/foo/...%20/types/nodes.json'], + ['an unreadable segment with the directory trailing a dot', '/foo/..%20/types./nodes.json'], + ['a space-suffixed double dot climbing out of the directory', '/types/..%20/nodes.json'], + ])('reports %s as reaching the directory', (_title, requestPath) => { + expect(mayReachDirectory(requestPath, 'types')).toBe(true); + }); + + it.each([ + ['an unrelated path', '/public-asset.txt'], + ['a path sharing the prefix', '/types-extra.txt'], + ['the directory nested under another route', '/rest/credential-resolvers/types'], + ['an unreadable segment without the directory', '/foo/..%20/bar/nodes.json'], + ['a dot segment climbing back out of the directory', '/types/../foo/nodes.json'], + ])('reports %s as not reaching the directory', (_title, requestPath) => { + expect(mayReachDirectory(requestPath, 'types')).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/request-path.ts b/packages/cli/src/utils/request-path.ts new file mode 100644 index 00000000000..5aca02d59f5 --- /dev/null +++ b/packages/cli/src/utils/request-path.ts @@ -0,0 +1,69 @@ +import path from 'node:path'; + +const DOT_SEGMENT = /^\.{1,2}$/; +const DOTS_AND_SPACES = /^[. ]+$/; +const TRAILING_DOTS_AND_SPACES = /[. ]+$/; +const TRAILING_SPACES = / +$/; + +/** Percent-decodes a request path, or `null` for a malformed percent sequence. */ +function tryDecode(requestPath: string): string | null { + try { + return decodeURIComponent(requestPath); + } catch { + return null; + } +} + +function toSegments(decoded: string): string[] { + return decoded.replace(/\\/g, '/').split('/'); +} + +/** Trims trailing spaces first, so a segment that is then `.` or `..` stays a dot segment. */ +function trimSegment(segment: string): string { + const withoutTrailingSpaces = segment.replace(TRAILING_SPACES, ''); + if (DOT_SEGMENT.test(withoutTrailingSpaces)) return withoutTrailingSpaces; + + return withoutTrailingSpaces.replace(TRAILING_DOTS_AND_SPACES, ''); +} + +function normalizeSegments(segments: string[]): string { + const trimmed = segments.map(trimSegment).filter((segment) => segment !== ''); + + return path.posix.normalize(`/${trimmed.join('/')}`).toLowerCase(); +} + +/** + * Normalises a request path into the form a filesystem-backed static handler + * would resolve it to, lower-cased, for comparison against a known route prefix. + */ +export function normalize(requestPath: string): string { + const decoded = tryDecode(requestPath) ?? requestPath; + + return normalizeSegments(toSegments(decoded)); +} + +/** + * Whether a request path can reach `directory` under any reading of the path. + * A segment with no single reading counts as reaching it, as does a malformed + * percent sequence anywhere in the path. + */ +export function mayReachDirectory(requestPath: string, directory: string): boolean { + const decoded = tryDecode(requestPath); + if (decoded === null) return true; + + const lowerDirectory = directory.toLowerCase(); + const segments = toSegments(decoded); + const normalized = normalizeSegments(segments); + if (normalized === `/${lowerDirectory}` || normalized.startsWith(`/${lowerDirectory}/`)) { + return true; + } + + let hasUnreadableSegment = false; + let matchesDirectory = false; + for (const segment of segments) { + if (DOTS_AND_SPACES.test(segment) && !DOT_SEGMENT.test(segment)) hasUnreadableSegment = true; + if (trimSegment(segment).toLowerCase() === lowerDirectory) matchesDirectory = true; + } + + return hasUnreadableSegment && matchesDirectory; +} diff --git a/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts index d41c31a8286..5dde120a011 100644 --- a/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/slack-interaction-webhooks.test.ts @@ -78,8 +78,12 @@ describe('SlackInteractionWebhooks', () => { return { res: mock({ status, send }), status, send }; }; - /** A waiting execution whose workflow contains a single node with the given id. */ - const waitingExecutionWithNode = (nodeId: string, lastNodeExecuted = 'SlackNode') => + /** A waiting execution whose workflow contains a single node with the given id and type. */ + const waitingExecutionWithNode = ( + nodeId: string, + lastNodeExecuted = 'SlackNode', + nodeType = 'n8n-nodes-base.slack', + ) => mock({ status: 'waiting', finished: false, @@ -90,7 +94,7 @@ describe('SlackInteractionWebhooks', () => { nodes: [ { name: 'SlackNode', - type: 'n8n-nodes-base.slack', + type: nodeType, typeVersion: 1, parameters: {}, id: nodeId, @@ -238,6 +242,21 @@ describe('SlackInteractionWebhooks', () => { expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull(); }); + it('responds 404 without resuming when the resumed node is not a Slack node', async () => { + const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET); + const req = createRequest(reference); + const { res, status } = createResponse(); + executionPersistence.findSingleExecution.mockResolvedValue( + waitingExecutionWithNode('node-1', 'SlackNode', 'n8n-nodes-base.telegram'), + ); + + const result = await slackInteractionWebhooks.executeWebhook(req, res); + + expect(status).toHaveBeenCalledWith(404); + expect(result).toEqual({ noWebhookResponse: true }); + expect(slackInteractionWebhooks.getWebhookExecutionDataArgs).toBeNull(); + }); + it('resolves the node id from lastNodeExecuted and routes into the shared resume', async () => { const reference = buildHitlCallbackReference('exec-1', 'a', TEST_HMAC_SECRET); const req = createRequest(reference); diff --git a/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts b/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts index d907f991c3f..a6302423f40 100644 --- a/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts +++ b/packages/cli/src/webhooks/__tests__/telegram-interaction-webhooks.test.ts @@ -201,4 +201,42 @@ describe('TelegramInteractionWebhooks', () => { expect(result).toEqual({ noWebhookResponse: true }); expect(getWebhookExecutionData).not.toHaveBeenCalled(); }); + + it('responds 404 and does not resume when the resumed node is not a Telegram node', async () => { + const reference = buildHitlCallbackReference('e1', 'a', TEST_HMAC_SECRET); + const req = makeReq(reference); + const res = makeRes(); + + executionPersistence.findSingleExecution.mockResolvedValue( + mock({ + status: 'waiting', + finished: false, + data: { resultData: { lastNodeExecuted: 'Slack', error: undefined } }, + workflowData: { + id: 'workflow-1', + name: 'Test Workflow', + active: true, + settings: {}, + staticData: {}, + connections: {}, + nodes: [ + { + name: 'Slack', + id: 'node-1', + type: 'n8n-nodes-base.slack', + typeVersion: 1, + parameters: {}, + position: [0, 0] as [number, number], + }, + ], + }, + }), + ); + + const result = await svc.executeWebhook(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(result).toEqual({ noWebhookResponse: true }); + expect(getWebhookExecutionData).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/webhooks/__tests__/webhook-request-sanitizer.test.ts b/packages/cli/src/webhooks/__tests__/webhook-request-sanitizer.test.ts index 0c2bf64a8d2..5c91d4a74c5 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-request-sanitizer.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-request-sanitizer.test.ts @@ -1,6 +1,10 @@ import type { Request } from 'express'; import { mock } from 'vitest-mock-extended'; +import { OIDC_NONCE_COOKIE_NAME, OIDC_STATE_COOKIE_NAME } from '@/constants'; +import { OAUTH_SESSION_COOKIE_NAME } from '@/modules/oauth-server/oauth-session.service'; +import { OIDC_ID_TOKEN_COOKIE_NAME } from '@/modules/sso-oidc/constants'; +import { OAUTH_BINDING_COOKIE_NAME } from '@/oauth/oauth-browser-binding.service'; import { sanitizeWebhookRequest } from '@/webhooks/webhook-request-sanitizer'; describe('webhookRequestSanitizer', () => { @@ -333,4 +337,30 @@ describe('webhookRequestSanitizer', () => { expect(mockRequest.cookies).toEqual({ 'other-cookie': 'value' }); }); }); + + describe('cookies n8n issues for its own flows', () => { + const N8N_ISSUED_COOKIES = [ + OAUTH_SESSION_COOKIE_NAME, + OAUTH_BINDING_COOKIE_NAME, + OIDC_ID_TOKEN_COOKIE_NAME, + OIDC_STATE_COOKIE_NAME, + OIDC_NONCE_COOKIE_NAME, + ]; + + it.each(N8N_ISSUED_COOKIES)('should remove %s from the cookie header', (name) => { + mockRequest.headers = { cookie: `${name}=abc123; other-cookie=value` }; + + sanitizeWebhookRequest(mockRequest); + + expect(mockRequest.headers.cookie).toBe('other-cookie=value'); + }); + + it.each(N8N_ISSUED_COOKIES)('should remove %s from the parsed cookies', (name) => { + mockRequest.cookies = { [name]: 'abc123', 'other-cookie': 'value' }; + + sanitizeWebhookRequest(mockRequest); + + expect(mockRequest.cookies).toEqual({ 'other-cookie': 'value' }); + }); + }); }); diff --git a/packages/cli/src/webhooks/__tests__/webhook-response-headers.test.ts b/packages/cli/src/webhooks/__tests__/webhook-response-headers.test.ts index 3259a1dc4c8..bf132e801e0 100644 --- a/packages/cli/src/webhooks/__tests__/webhook-response-headers.test.ts +++ b/packages/cli/src/webhooks/__tests__/webhook-response-headers.test.ts @@ -1,3 +1,5 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance } from '@n8n/backend-test-utils'; import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { Response } from 'express'; @@ -7,6 +9,12 @@ import { mock } from 'vitest-mock-extended'; import { applyFormSandboxCSP, WebhookResponseHeaders } from '@/webhooks/webhook-response-headers'; describe('WebhookResponseHeaders', () => { + const logger = mockInstance(Logger); + + beforeEach(() => { + vi.clearAllMocks(); + }); + describe('set()', () => { it('should store and apply valid headers', () => { const headers = new WebhookResponseHeaders(); @@ -68,6 +76,36 @@ describe('WebhookResponseHeaders', () => { expect(res.setHeaders).not.toHaveBeenCalled(); }); + it.each([ + 'set-cookie', + 'strict-transport-security', + 'clear-site-data', + 'Set-Cookie', + 'Strict-Transport-Security', + 'Clear-Site-Data', + 'SET-COOKIE', + 'STRICT-TRANSPORT-SECURITY', + 'CLEAR-SITE-DATA', + ])('should not allow overriding the protected header %s', (headerName) => { + const headers = new WebhookResponseHeaders(); + headers.set(headerName, 'some-value'); + + const res = mock(); + headers.applyToResponse(res); + + expect(res.setHeaders).not.toHaveBeenCalled(); + }); + + it('should log a warning when dropping a protected header', () => { + const headers = new WebhookResponseHeaders(); + headers.set('Set-Cookie', 'a=b'); + + expect(logger.warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ headerName: 'Set-Cookie' }), + ); + }); + it('should lower-case header names', () => { const headers = new WebhookResponseHeaders(); headers.set('X-My-Header', 'test'); @@ -163,6 +201,22 @@ describe('WebhookResponseHeaders', () => { expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-valid', 'value']])); }); + it('should drop headers that control client state', () => { + const headers = new WebhookResponseHeaders(); + headers.addFromObject({ + 'x-valid': 'value', + 'Set-Cookie': 'a=b', + 'Strict-Transport-Security': 'max-age=0', + 'Clear-Site-Data': '"cache"', + }); + + const res = mock(); + headers.applyToResponse(res); + + expect(res.setHeaders).toHaveBeenCalledTimes(1); + expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-valid', 'value']])); + }); + it('should convert non-string values to strings', () => { const headers = new WebhookResponseHeaders(); headers.addFromObject({ @@ -240,6 +294,24 @@ describe('WebhookResponseHeaders', () => { expect(res.setHeaders).not.toHaveBeenCalled(); }); + + it('should drop headers that control client state', () => { + const headers = new WebhookResponseHeaders(); + headers.addFromNodeHeaders({ + entries: [ + { name: 'Set-Cookie', value: 'a=b' }, + { name: 'strict-transport-security', value: 'max-age=0' }, + { name: 'CLEAR-SITE-DATA', value: '"cache"' }, + { name: 'x-valid', value: 'ok' }, + ], + }); + + const res = mock(); + headers.applyToResponse(res); + + expect(res.setHeaders).toHaveBeenCalledTimes(1); + expect(res.setHeaders).toHaveBeenCalledWith(new Map([['x-valid', 'ok']])); + }); }); describe('applyToResponse()', () => { diff --git a/packages/cli/src/webhooks/hitl-interaction-webhooks.ts b/packages/cli/src/webhooks/hitl-interaction-webhooks.ts index 0313a931bc4..bbf112459eb 100644 --- a/packages/cli/src/webhooks/hitl-interaction-webhooks.ts +++ b/packages/cli/src/webhooks/hitl-interaction-webhooks.ts @@ -34,6 +34,9 @@ export abstract class HitlInteractionWebhooks extends WaitingWebhooks { req: WaitingWebhookRequest, ): Promise; + /** The node type this route is allowed to resume (e.g. 'n8n-nodes-base.slack'). */ + protected abstract readonly platformNodeType: string; + /** * Response when the request carries no resumable reference. Defaults to 400; platforms * that also deliver fire-and-forget interactions here (Slack URL buttons) override to 200. @@ -62,12 +65,14 @@ export abstract class HitlInteractionWebhooks extends WaitingWebhooks { const lastNodeExecuted = execution.data.resultData.lastNodeExecuted as string; const workflow = this.createWorkflow(execution.workflowData); - const nodeId = workflow.getNode(lastNodeExecuted)?.id; - if (!nodeId) return { ok: false, status: 404 }; + const node = workflow.getNode(lastNodeExecuted); + if (!node) return { ok: false, status: 404 }; + + if (node.type !== this.platformNodeType) return { ok: false, status: 404 }; return { ok: true, - value: { execution, executionId: parsed.executionId, lastNodeExecuted, nodeId }, + value: { execution, executionId: parsed.executionId, lastNodeExecuted, nodeId: node.id }, }; } diff --git a/packages/cli/src/webhooks/slack-interaction-webhooks.ts b/packages/cli/src/webhooks/slack-interaction-webhooks.ts index ffb8567222b..6b70179e332 100644 --- a/packages/cli/src/webhooks/slack-interaction-webhooks.ts +++ b/packages/cli/src/webhooks/slack-interaction-webhooks.ts @@ -18,6 +18,8 @@ interface SlackInteractionPayload { */ @Service() export class SlackInteractionWebhooks extends HitlInteractionWebhooks { + protected readonly platformNodeType = 'n8n-nodes-base.slack'; + protected async parseCallback( req: WaitingWebhookRequest, ): Promise { diff --git a/packages/cli/src/webhooks/telegram-interaction-webhooks.ts b/packages/cli/src/webhooks/telegram-interaction-webhooks.ts index 1275f94052a..78420d65cfd 100644 --- a/packages/cli/src/webhooks/telegram-interaction-webhooks.ts +++ b/packages/cli/src/webhooks/telegram-interaction-webhooks.ts @@ -19,6 +19,8 @@ interface TelegramCallbackUpdate { */ @Service() export class TelegramInteractionWebhooks extends HitlInteractionWebhooks { + protected readonly platformNodeType = 'n8n-nodes-base.telegram'; + protected async parseCallback( req: WaitingWebhookRequest, ): Promise { diff --git a/packages/cli/src/webhooks/webhook-request-sanitizer.ts b/packages/cli/src/webhooks/webhook-request-sanitizer.ts index a2e4887445f..f877050fd1d 100644 --- a/packages/cli/src/webhooks/webhook-request-sanitizer.ts +++ b/packages/cli/src/webhooks/webhook-request-sanitizer.ts @@ -1,15 +1,34 @@ import type { Request } from 'express'; -import { AUTH_COOKIE_NAME, FORM_AUTH_COOKIE_PREFIX, FORM_OAUTH_COOKIE_NAME } from '@/constants'; +import { + AUTH_COOKIE_NAME, + FORM_AUTH_COOKIE_PREFIX, + FORM_OAUTH_COOKIE_NAME, + OIDC_NONCE_COOKIE_NAME, + OIDC_STATE_COOKIE_NAME, +} from '@/constants'; +import { OAUTH_SESSION_COOKIE_NAME } from '@/modules/oauth-server/oauth-session.service'; +import { OIDC_ID_TOKEN_COOKIE_NAME } from '@/modules/sso-oidc/constants'; +import { OAUTH_BINDING_COOKIE_NAME } from '@/oauth/oauth-browser-binding.service'; const BROWSER_ID_COOKIE_NAME = 'n8n-browserId'; -// The form cookies belong to the form endpoints, which skip sanitizing entirely -// for their own node types (see `authAllowlistedNodes`), so a form page still -// receives them. Every other webhook has no use for them. +/** + * Cookies n8n issues for its own UI and sign-in flows. They are set without a `path`, so + * browsers send them to `/webhook/*` too. + * + * `n8n-form-oauth` is excluded: the form OAuth2 flow reads it back off the raw header on the + * redirect hop, so it has to keep flowing. Hence an explicit list of names rather than an + * `n8n-` prefix rule, which would take that cookie with it. + */ const DISALLOWED_COOKIES = new Set([ AUTH_COOKIE_NAME, BROWSER_ID_COOKIE_NAME, + OAUTH_SESSION_COOKIE_NAME, + OAUTH_BINDING_COOKIE_NAME, + OIDC_ID_TOKEN_COOKIE_NAME, + OIDC_STATE_COOKIE_NAME, + OIDC_NONCE_COOKIE_NAME, FORM_OAUTH_COOKIE_NAME, ]); diff --git a/packages/cli/src/webhooks/webhook-response-headers.ts b/packages/cli/src/webhooks/webhook-response-headers.ts index ae497c99c82..81334198ff7 100644 --- a/packages/cli/src/webhooks/webhook-response-headers.ts +++ b/packages/cli/src/webhooks/webhook-response-headers.ts @@ -19,8 +19,16 @@ export type WebhookNodeResponseHeaders = { }>; }; -/** Headers that users are not allowed to set via webhook response config */ -const PROTECTED_HEADERS = new Set(['content-security-policy']); +/** + * Headers that users are not allowed to set via webhook response config, because they control + * client-side state the instance owns rather than the response payload. + */ +const PROTECTED_HEADERS = new Set([ + 'content-security-policy', + 'set-cookie', + 'strict-transport-security', + 'clear-site-data', +]); /** Response headers. Keys are always lower-cased. Invalid headers are silently skipped. */ export class WebhookResponseHeaders { @@ -33,10 +41,15 @@ export class WebhookResponseHeaders { return instance; } - /** Add a single header. Silently skips invalid or protected headers. */ + /** Add a single header. Skips invalid or protected headers, logging a warning for each. */ set(name: string, value: string): void { const lowerName = name.toLowerCase(); - if (PROTECTED_HEADERS.has(lowerName)) return; + if (PROTECTED_HEADERS.has(lowerName)) { + Container.get(Logger).warn('Dropping protected webhook response header', { + headerName: name, + }); + return; + } try { validateHeaderName(lowerName); validateHeaderValue(lowerName, value); diff --git a/packages/cli/src/workflows/__tests__/workflow-push-notifier.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-push-notifier.service.test.ts new file mode 100644 index 00000000000..c1cf516f4ae --- /dev/null +++ b/packages/cli/src/workflows/__tests__/workflow-push-notifier.service.test.ts @@ -0,0 +1,42 @@ +import { mock } from 'vitest-mock-extended'; + +import type { Push } from '@/push'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; +import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; + +describe('WorkflowPushNotifier', () => { + const push = mock(); + const workflowSharingService = mock(); + const notifier = new WorkflowPushNotifier(push, workflowSharingService); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('sends the push message only to users with access to the workflow', async () => { + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue([ + 'user-1', + 'user-2', + ]); + + const pushMsg = { + type: 'workflowActivated' as const, + data: { workflowId: 'wf-1', activeVersionId: 'v-1' }, + }; + + await notifier.notify('wf-1', pushMsg); + + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith('wf-1'); + expect(push.sendToUsers).toHaveBeenCalledWith(pushMsg, ['user-1', 'user-2']); + }); + + test('sends to nobody when the recipient lookup resolves to no users', async () => { + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue([]); + + const pushMsg = { type: 'workflowDeactivated' as const, data: { workflowId: 'wf-1' } }; + + await notifier.notify('wf-1', pushMsg); + + expect(push.sendToUsers).toHaveBeenCalledWith(pushMsg, []); + }); +}); diff --git a/packages/cli/src/workflows/__tests__/workflow-sharing.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-sharing.service.test.ts new file mode 100644 index 00000000000..8048a2bca3b --- /dev/null +++ b/packages/cli/src/workflows/__tests__/workflow-sharing.service.test.ts @@ -0,0 +1,111 @@ +import type { Logger, LicenseState } from '@n8n/backend-common'; +import type { ProjectRelationRepository, SharedWorkflowRepository, UserRepository } from '@n8n/db'; +import { mock } from 'vitest-mock-extended'; + +import type { RoleService } from '@/services/role.service'; + +import { WorkflowSharingService } from '../workflow-sharing.service'; + +describe('WorkflowSharingService', () => { + const sharedWorkflowRepository = mock(); + const projectRelationRepository = mock(); + const roleService = mock(); + const licenseState = mock(); + const userRepository = mock(); + const logger = mock(); + + const service = new WorkflowSharingService( + sharedWorkflowRepository, + roleService, + projectRelationRepository, + licenseState, + userRepository, + logger, + ); + + describe('getUserIdsWithAccessToWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks(); + roleService.rolesWithScope.mockImplementation(async (namespace) => { + if (namespace === 'global') return ['global:owner', 'global:admin']; + if (namespace === 'project') return ['project:editor']; + return ['workflow:owner', 'workflow:editor']; + }); + }); + + it('resolves projects only through sharing rows whose own role grants workflow:read, then delegates to the repository', async () => { + sharedWorkflowRepository.findProjectIdsByRole.mockResolvedValue(['project-1', 'project-2']); + userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue(['user-1', 'user-2']); + + const result = await service.getUserIdsWithAccessToWorkflow('workflow-1'); + + expect(roleService.rolesWithScope).toHaveBeenCalledWith('workflow', ['workflow:read']); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('global', ['workflow:read']); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['workflow:read']); + expect(sharedWorkflowRepository.findProjectIdsByRole).toHaveBeenCalledWith('workflow-1', [ + 'workflow:owner', + 'workflow:editor', + ]); + expect(userRepository.findIdsWithGlobalOrProjectRoles).toHaveBeenCalledWith({ + projectIds: ['project-1', 'project-2'], + projectRoleSlugs: ['project:editor'], + globalRoleSlugs: ['global:owner', 'global:admin'], + }); + expect(result).toEqual(['user-1', 'user-2']); + }); + + it('excludes a project the workflow is only shared into with a role that does not grant workflow:read', async () => { + roleService.rolesWithScope.mockImplementation(async (namespace) => + namespace === 'workflow' ? ['workflow:owner'] : [], + ); + sharedWorkflowRepository.findProjectIdsByRole.mockResolvedValue([]); + userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue([]); + + await service.getUserIdsWithAccessToWorkflow('workflow-1'); + + expect(sharedWorkflowRepository.findProjectIdsByRole).toHaveBeenCalledWith('workflow-1', [ + 'workflow:owner', + ]); + expect(userRepository.findIdsWithGlobalOrProjectRoles).toHaveBeenCalledWith({ + projectIds: [], + projectRoleSlugs: [], + globalRoleSlugs: [], + }); + }); + + it('still resolves global-scope recipients when the workflow has no associated projects', async () => { + sharedWorkflowRepository.findProjectIdsByRole.mockResolvedValue([]); + userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue(['owner-1']); + + const result = await service.getUserIdsWithAccessToWorkflow('workflow-1'); + + expect(userRepository.findIdsWithGlobalOrProjectRoles).toHaveBeenCalledWith({ + projectIds: [], + projectRoleSlugs: ['project:editor'], + globalRoleSlugs: ['global:owner', 'global:admin'], + }); + expect(result).toEqual(['owner-1']); + }); + }); + + describe('getUserIdsWithAccessToWorkflowSafe', () => { + it('resolves to nobody, rather than throwing, when the lookup fails', async () => { + sharedWorkflowRepository.findProjectIdsByRole.mockRejectedValue(new Error('db unavailable')); + + const result = await service.getUserIdsWithAccessToWorkflowSafe('workflow-1'); + + expect(result).toEqual([]); + expect(logger.error).toHaveBeenCalled(); + }); + + it('otherwise returns the same result as the unsafe lookup', async () => { + sharedWorkflowRepository.findProjectIdsByRole.mockResolvedValue([]); + roleService.rolesWithScope.mockResolvedValue([]); + userRepository.findIdsWithGlobalOrProjectRoles.mockResolvedValue(['user-1']); + + const result = await service.getUserIdsWithAccessToWorkflowSafe('workflow-1'); + + expect(result).toEqual(['user-1']); + }); + }); +}); diff --git a/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts b/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts index 5e9d5599074..bb83ab34c97 100644 --- a/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts +++ b/packages/cli/src/workflows/publication/__tests__/publication-status-reporter.test.ts @@ -5,15 +5,17 @@ import type { WorkflowPublicationOutboxRepository, WorkflowPublicationTriggerStatusRepository, } from '@n8n/db'; +import type { ErrorReporter } from 'n8n-core'; import type { Mock } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import type { ErrorReporter } from 'n8n-core'; import type { ActivationErrorsService } from '@/activation-errors.service'; import { PolicyViolationError } from '@/policy/policy-violation.error'; import type { Push } from '@/push'; import type { Publisher } from '@/scaling/pubsub/publisher.service'; import { PublicationStatusReporter } from '@/workflows/publication/publication-status-reporter'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; +import type { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; describe('PublicationStatusReporter', () => { const logger = mock(); @@ -28,15 +30,17 @@ describe('PublicationStatusReporter', () => { const publisher = mock(); const triggerStatusRepository = mock(); const entityManager = mock(); + const workflowSharingService = mock(); + const workflowPushNotifier = new WorkflowPushNotifier(push, workflowSharingService); const reporter = new PublicationStatusReporter( logger, errorReporter, outboxRepository, activationErrorsService, - push, publisher, triggerStatusRepository, + workflowPushNotifier, ); function makeRecord( @@ -54,6 +58,8 @@ describe('PublicationStatusReporter', () => { } as WorkflowPublicationOutbox; } + const userIds = ['user-1', 'user-2']; + beforeEach(() => { vi.clearAllMocks(); outboxRepository.markCompleted.mockResolvedValue(undefined); @@ -63,6 +69,7 @@ describe('PublicationStatusReporter', () => { activationErrorsService.register.mockResolvedValue(undefined); triggerStatusRepository.replaceForWorkflow.mockResolvedValue(undefined); publisher.publishCommand.mockResolvedValue(undefined); + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValue(userIds); (outboxRepository.manager.transaction as unknown as Mock).mockImplementation( async (runInTransaction: (trx: EntityManager) => Promise) => await runInTransaction(entityManager), @@ -101,10 +108,14 @@ describe('PublicationStatusReporter', () => { expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager, undefined); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowActivated', - data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, - }); + expect(workflowSharingService.getUserIdsWithAccessToWorkflowSafe).toHaveBeenCalledWith('wf-1'); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowActivated', + data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, + }, + userIds, + ); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-publication-status', payload: { @@ -126,10 +137,13 @@ describe('PublicationStatusReporter', () => { 'External webhook deregistration failed for: "Trello Trigger": remote unreachable', ); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowDeactivated', - data: { workflowId: 'wf-1' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowDeactivated', + data: { workflowId: 'wf-1' }, + }, + userIds, + ); }); test('unpublished reports the abandoned deregistrations once, at error level', async () => { @@ -212,10 +226,13 @@ describe('PublicationStatusReporter', () => { 'External webhook deregistration failed for: "Trello Trigger": remote unreachable', ); expect(errorReporter.error).toHaveBeenCalledTimes(1); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowActivated', - data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowActivated', + data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, + }, + userIds, + ); }); }); @@ -230,10 +247,13 @@ describe('PublicationStatusReporter', () => { expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager, undefined); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowDeactivated', - data: { workflowId: 'wf-1' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowDeactivated', + data: { workflowId: 'wf-1' }, + }, + userIds, + ); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-publication-status', payload: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, @@ -252,7 +272,7 @@ describe('PublicationStatusReporter', () => { expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager, undefined); expect(activationErrorsService.deregister).toHaveBeenCalledWith('wf-1'); expect(outboxRepository.markFailed).not.toHaveBeenCalled(); - expect(push.broadcast).not.toHaveBeenCalled(); + expect(push.sendToUsers).not.toHaveBeenCalled(); expect(publisher.publishCommand).not.toHaveBeenCalled(); expect(logger.warn).toHaveBeenCalledWith( expect.stringContaining(message), @@ -267,10 +287,13 @@ describe('PublicationStatusReporter', () => { expect(outboxRepository.markFailed).toHaveBeenCalledWith(1, 'Published version not found'); expect(errorReporter.error).not.toHaveBeenCalled(); expect(activationErrorsService.deregister).not.toHaveBeenCalled(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowFailedToActivate', - data: { workflowId: 'wf-1', errorMessage: 'Published version not found' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowFailedToActivate', + data: { workflowId: 'wf-1', errorMessage: 'Published version not found' }, + }, + userIds, + ); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-publication-status', payload: { @@ -293,10 +316,13 @@ describe('PublicationStatusReporter', () => { entityManager, ); expect(outboxRepository.markCompleted).not.toHaveBeenCalled(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowFailedToActivate', - data: { workflowId: 'wf-1', errorMessage: 'registration failed' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowFailedToActivate', + data: { workflowId: 'wf-1', errorMessage: 'registration failed' }, + }, + userIds, + ); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-publication-status', payload: { @@ -317,10 +343,13 @@ describe('PublicationStatusReporter', () => { expect(errorReporter.error).not.toHaveBeenCalled(); expect(outboxRepository.markFailed).toHaveBeenCalledWith(1, error.message, entityManager); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowFailedToActivate', - data: { workflowId: 'wf-1', errorMessage: error.message }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowFailedToActivate', + data: { workflowId: 'wf-1', errorMessage: error.message }, + }, + userIds, + ); }); test('failed with triggerStatuses writes rows before marking failed', async () => { @@ -439,7 +468,7 @@ describe('PublicationStatusReporter', () => { ], }, }; - expect(push.broadcast).toHaveBeenCalledWith(expectedPushMsg); + expect(push.sendToUsers).toHaveBeenCalledWith(expectedPushMsg, userIds); expect(publisher.publishCommand).toHaveBeenCalledWith({ command: 'display-workflow-publication-status', payload: expectedPushMsg, @@ -454,26 +483,62 @@ describe('PublicationStatusReporter', () => { await expect(reporter.report(makeRecord(), { type: 'unpublished' })).resolves.toBeUndefined(); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowDeactivated', - data: { workflowId: 'wf-1' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowDeactivated', + data: { workflowId: 'wf-1' }, + }, + userIds, + ); expect(outboxRepository.markCompleted).toHaveBeenCalledWith(1, entityManager, undefined); // The rejection is handled asynchronously; flush the microtask queue. await new Promise(process.nextTick); expect(errorReporter.error).toHaveBeenCalledWith(publishError, { shouldBeLogged: true }); }); - test('a relayed publication status is broadcast to local clients', () => { - reporter.handleDisplayWorkflowPublicationStatus({ + test('a failed recipient lookup does not suppress the pubsub relay', async () => { + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockResolvedValueOnce([]); + + await expect(reporter.report(makeRecord(), { type: 'unpublished' })).resolves.toBeUndefined(); + + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'display-workflow-publication-status', + payload: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, + }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, + [], + ); + }); + + test('the pubsub relay still fires even if the recipient lookup itself throws', async () => { + workflowSharingService.getUserIdsWithAccessToWorkflowSafe.mockRejectedValueOnce( + new Error('db unavailable'), + ); + + await expect(reporter.report(makeRecord(), { type: 'unpublished' })).rejects.toThrow( + 'db unavailable', + ); + + expect(publisher.publishCommand).toHaveBeenCalledWith({ + command: 'display-workflow-publication-status', + payload: { type: 'workflowDeactivated', data: { workflowId: 'wf-1' } }, + }); + }); + + test('a relayed publication status is delivered to clients with access', async () => { + await reporter.handleDisplayWorkflowPublicationStatus({ type: 'workflowActivated', data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, }); - expect(push.broadcast).toHaveBeenCalledWith({ - type: 'workflowActivated', - data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, - }); + expect(push.sendToUsers).toHaveBeenCalledWith( + { + type: 'workflowActivated', + data: { workflowId: 'wf-1', activeVersionId: 'v-2' }, + }, + userIds, + ); expect(publisher.publishCommand).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/workflows/publication/publication-status-reporter.ts b/packages/cli/src/workflows/publication/publication-status-reporter.ts index a4a1659334c..6352a6de4ed 100644 --- a/packages/cli/src/workflows/publication/publication-status-reporter.ts +++ b/packages/cli/src/workflows/publication/publication-status-reporter.ts @@ -13,7 +13,6 @@ import { OperationalError } from 'n8n-workflow'; import { ActivationErrorsService } from '@/activation-errors.service'; import { isPolicyRefusal } from '@/policy/policy-violation.error'; -import { Push } from '@/push'; import { Publisher } from '@/scaling/pubsub/publisher.service'; import type { FailedTriggerPublicationStatus, @@ -21,6 +20,7 @@ import type { PublicationSkipReason, TriggerPublicationStatus, } from '@/workflows/publication/publication-result'; +import { WorkflowPushNotifier } from '@/workflows/workflow-push-notifier.service'; import type { TriggerTeardownFailure } from '@/workflows/triggers/workflow-trigger-activator'; /** @@ -42,9 +42,9 @@ export class PublicationStatusReporter { private readonly errorReporter: ErrorReporter, private readonly outboxRepository: WorkflowPublicationOutboxRepository, private readonly activationErrorsService: ActivationErrorsService, - private readonly push: Push, private readonly publisher: Publisher, private readonly triggerStatusRepository: WorkflowPublicationTriggerStatusRepository, + private readonly workflowPushNotifier: WorkflowPushNotifier, ) { this.logger = this.logger.scoped('workflow-publication'); } @@ -54,7 +54,7 @@ export class PublicationStatusReporter { case 'completed': { const warningMessage = this.surfaceTeardownFailures(result.teardownFailures); await this.complete(record, this.toRows(record, result.triggerStatuses), warningMessage); - this.pushStatus({ + await this.pushStatus({ type: 'workflowActivated', data: { workflowId: record.workflowId, activeVersionId: record.publishedVersionId }, }); @@ -64,7 +64,7 @@ export class PublicationStatusReporter { case 'unpublished': { const warningMessage = this.surfaceTeardownFailures(result.teardownFailures); await this.complete(record, /*triggerStatuses=*/ [], warningMessage); - this.pushStatus({ + await this.pushStatus({ type: 'workflowDeactivated', data: { workflowId: record.workflowId }, }); @@ -85,7 +85,7 @@ export class PublicationStatusReporter { outboxId: record.id, }); await this.outboxRepository.markFailed(record.id, errorMessage); - this.pushFailedToActivate(record.workflowId, errorMessage); + await this.pushFailedToActivate(record.workflowId, errorMessage); return; } @@ -109,7 +109,7 @@ export class PublicationStatusReporter { if (!isPolicyRefusal(result.error)) { this.errorReporter.error(result.error, { shouldBeLogged: true }); } - this.pushFailedToActivate(record.workflowId, result.error.message); + await this.pushFailedToActivate(record.workflowId, result.error.message); return; } @@ -152,7 +152,7 @@ export class PublicationStatusReporter { await this.outboxRepository.markPartialSuccess(record.id, errorMessage, trx); }); - this.pushStatus({ + await this.pushStatus({ type: 'workflowPartiallyActivated', data: { workflowId: record.workflowId, @@ -191,8 +191,8 @@ export class PublicationStatusReporter { } /** Pushes a failed-to-activate status to clients connected to any main. */ - private pushFailedToActivate(workflowId: string, errorMessage: string): void { - this.pushStatus({ + private async pushFailedToActivate(workflowId: string, errorMessage: string): Promise { + await this.pushStatus({ type: 'workflowFailedToActivate', data: { workflowId, errorMessage }, }); @@ -205,17 +205,22 @@ export class PublicationStatusReporter { * is leader-only), but clients may be connected to a follower. The relay is * fire-and-forget so a pubsub failure never fails the terminal-status report. */ - private pushStatus(pushMsg: WorkflowPublicationStatusMessage): void { - this.push.broadcast(pushMsg); + private async pushStatus(pushMsg: WorkflowPublicationStatusMessage): Promise { + // Relayed before the lookup, so a recipient-lookup failure only drops + // the local push, not the relay. void this.publisher .publishCommand({ command: 'display-workflow-publication-status', payload: pushMsg }) .catch((error) => this.errorReporter.error(error, { shouldBeLogged: true })); + + await this.workflowPushNotifier.notify(pushMsg.data.workflowId, pushMsg); } /** Displays a publication status relayed by the leader (see {@link pushStatus}). */ @OnPubSubEvent('display-workflow-publication-status', { instanceType: 'main' }) - handleDisplayWorkflowPublicationStatus(pushMsg: WorkflowPublicationStatusMessage): void { - this.push.broadcast(pushMsg); + async handleDisplayWorkflowPublicationStatus( + pushMsg: WorkflowPublicationStatusMessage, + ): Promise { + await this.workflowPushNotifier.notify(pushMsg.data.workflowId, pushMsg); } /** diff --git a/packages/cli/src/workflows/workflow-push-notifier.service.ts b/packages/cli/src/workflows/workflow-push-notifier.service.ts new file mode 100644 index 00000000000..c9e5939017f --- /dev/null +++ b/packages/cli/src/workflows/workflow-push-notifier.service.ts @@ -0,0 +1,20 @@ +import type { PushMessage } from '@n8n/api-types'; +import { Service } from '@n8n/di'; + +import { Push } from '@/push'; +import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; + +/** Sends a push message only to users who can currently read the workflow it concerns. */ +@Service() +export class WorkflowPushNotifier { + constructor( + private readonly push: Push, + private readonly workflowSharingService: WorkflowSharingService, + ) {} + + async notify(workflowId: string, pushMsg: PushMessage) { + const userIds = + await this.workflowSharingService.getUserIdsWithAccessToWorkflowSafe(workflowId); + this.push.sendToUsers(pushMsg, userIds); + } +} diff --git a/packages/cli/src/workflows/workflow-sharing.service.ts b/packages/cli/src/workflows/workflow-sharing.service.ts index 202a918af29..0c0afef999a 100644 --- a/packages/cli/src/workflows/workflow-sharing.service.ts +++ b/packages/cli/src/workflows/workflow-sharing.service.ts @@ -1,6 +1,6 @@ -import { LicenseState } from '@n8n/backend-common'; +import { Logger, LicenseState } from '@n8n/backend-common'; import type { User } from '@n8n/db'; -import { ProjectRelationRepository, SharedWorkflowRepository } from '@n8n/db'; +import { ProjectRelationRepository, SharedWorkflowRepository, UserRepository } from '@n8n/db'; import { Service } from '@n8n/di'; import { hasGlobalScope, @@ -24,6 +24,8 @@ export class WorkflowSharingService { private readonly roleService: RoleService, private readonly projectRelationRepository: ProjectRelationRepository, private readonly licenseState: LicenseState, + private readonly userRepository: UserRepository, + private readonly logger: Logger, ) {} /** @@ -70,6 +72,47 @@ export class WorkflowSharingService { return sharedWorkflows.map(({ workflowId }) => workflowId); } + /** + * IDs of users who can read the workflow: those holding a global role that + * grants `workflow:read`, plus those holding a project role granting it in + * a project the workflow is shared into with a sharing role granting it. + */ + async getUserIdsWithAccessToWorkflow(workflowId: string): Promise { + const [workflowRoles, globalRoles, projectRoles] = await Promise.all([ + this.roleService.rolesWithScope('workflow', ['workflow:read']), + this.roleService.rolesWithScope('global', ['workflow:read']), + this.roleService.rolesWithScope('project', ['workflow:read']), + ]); + + const projectIds = await this.sharedWorkflowRepository.findProjectIdsByRole( + workflowId, + workflowRoles, + ); + + return await this.userRepository.findIdsWithGlobalOrProjectRoles({ + projectIds, + projectRoleSlugs: projectRoles, + globalRoleSlugs: globalRoles, + }); + } + + /** + * Same as {@link getUserIdsWithAccessToWorkflow}, but never throws — logs + * and resolves to no users on failure. Use where losing a push is + * acceptable but failing the caller's own operation is not. + */ + async getUserIdsWithAccessToWorkflowSafe(workflowId: string): Promise { + try { + return await this.getUserIdsWithAccessToWorkflow(workflowId); + } catch (error) { + this.logger.error(`Failed to resolve who can access workflow "${workflowId}"`, { + workflowId, + error: error instanceof Error ? error : new Error(String(error)), + }); + return []; + } + } + /** * Scope-based access list that respects whether sharing is licensed. * Without sharing, only owner-role workflows in projects the user owns. diff --git a/packages/cli/test/integration/access-control/instance-roles.test.ts b/packages/cli/test/integration/access-control/instance-roles.test.ts index 673e05d53d7..6baaab89295 100644 --- a/packages/cli/test/integration/access-control/instance-roles.test.ts +++ b/packages/cli/test/integration/access-control/instance-roles.test.ts @@ -1,5 +1,6 @@ import { createTeamProject, + linkUserToProject, mockInstance, randomCredentialPayload, testDb, @@ -228,14 +229,15 @@ describe('Instance (global) custom role authorization', () => { await agent.delete(`/roles/${globalRole.slug}`).expect(403); }); - test('role:manageProject can read assignments and project members for a project role', async () => { - const { agent } = await createGlobalRoleUser( + test('role:manageProject can read assignments and the members of its own projects', async () => { + const { user, agent } = await createGlobalRoleUser( testServer, MANAGE_PROJECT, 'Project Role Manager', ); const owner = await createOwner(); const project = await createTeamProject('Project Role Members', owner); + await linkUserToProject(user, project, 'project:admin'); const projectRole = await createCustomRoleWithScopeSlugs(['workflow:read'], { roleType: 'project', displayName: 'Assignable Project Role', @@ -245,6 +247,22 @@ describe('Instance (global) custom role authorization', () => { await agent.get(`/roles/${projectRole.slug}/assignments/${project.id}/members`).expect(200); }); + test('role:manageProject cannot read the members of a project it has no access to', async () => { + const { agent } = await createGlobalRoleUser( + testServer, + MANAGE_PROJECT, + 'Project Role Manager', + ); + const owner = await createOwner(); + const project = await createTeamProject('Someone Else’s Project', owner); + const projectRole = await createCustomRoleWithScopeSlugs(['workflow:read'], { + roleType: 'project', + displayName: 'Assignable Project Role', + }); + + await agent.get(`/roles/${projectRole.slug}/assignments/${project.id}/members`).expect(404); + }); + test('role:manageProject cannot read assignments or project members for a global role', async () => { const { agent } = await createGlobalRoleUser( testServer, diff --git a/packages/cli/test/integration/controllers/role.controller-db.test.ts b/packages/cli/test/integration/controllers/role.controller-db.test.ts index 6d811a634a3..6f97b06c81b 100644 --- a/packages/cli/test/integration/controllers/role.controller-db.test.ts +++ b/packages/cli/test/integration/controllers/role.controller-db.test.ts @@ -5,13 +5,15 @@ import { PROJECT_EDITOR_ROLE, PROJECT_OWNER_ROLE, PROJECT_VIEWER_ROLE, + ProjectRepository, RoleRepository, + UserRepository, } from '@n8n/db'; -import type { User } from '@n8n/db'; +import type { Project, User } from '@n8n/db'; import { Container } from '@n8n/di'; -import { cleanupRolesAndScopes } from '../shared/db/roles'; -import { createMember, createOwner } from '../shared/db/users'; +import { cleanupRolesAndScopes, createCustomRoleWithScopeSlugs } from '../shared/db/roles'; +import { createMember, createOwner, createUser } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import { setupTestServer } from '../shared/utils'; @@ -250,4 +252,114 @@ describe('RoleController - Integration Tests', () => { .expect(403); }); }); + + describe('project assignments visibility for a delegated role manager', () => { + let roleManager: User; + let roleManagerAgent: SuperAgentTest; + let ownProject: Project; + let otherProject: Project; + // Projects a single test creates for itself. Tracked here so cleanup still runs when + // that test fails — a leaked relation to a custom role makes the outer hook's role + // delete fail on the FK, which would mask the real failure. + const scratchProjectIds: string[] = []; + + const projectNamesOf = (body: { data: { projects: Array<{ projectName: string }> } }) => + body.data.projects.map((project) => project.projectName); + + beforeEach(async () => { + scratchProjectIds.length = 0; + + // A non-admin caller whose custom global role only lets it manage project roles. + const role = await createCustomRoleWithScopeSlugs(['role:read', 'role:manageProject'], { + roleType: 'global', + displayName: 'Project Role Manager', + }); + roleManager = await createUser({ role }); + roleManagerAgent = testServer.authAgentFor(roleManager); + + ownProject = await createTeamProject('Own Project', owner); + await linkUserToProject(roleManager, ownProject, 'project:admin'); + await linkUserToProject(member, ownProject, 'project:editor'); + + otherProject = await createTeamProject('Other Project', owner); + await linkUserToProject(member, otherProject, 'project:editor'); + }); + + afterEach(async () => { + // The user holds an FK to the custom role, so it has to go before the + // outer hook deletes non-system roles. + await Container.get(UserRepository).delete({ id: roleManager.id }); + await Container.get(ProjectRepository).delete([ + ownProject.id, + otherProject.id, + ...scratchProjectIds, + ]); + }); + + it('should only list the projects the caller can list, while keeping the total intact', async () => { + const asOwner = await ownerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`) + .expect(200); + const asRoleManager = await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`) + .expect(200); + + expect(projectNamesOf(asOwner.body)).toEqual( + expect.arrayContaining(['Own Project', 'Other Project']), + ); + expect(projectNamesOf(asRoleManager.body)).toContain('Own Project'); + expect(projectNamesOf(asRoleManager.body)).not.toContain('Other Project'); + + // the instance-wide count stays honest so the delete-impact signal is not understated + expect(asRoleManager.body.data.totalProjects).toBe(asOwner.body.data.totalProjects); + expect(asRoleManager.body.data.projects.length).toBeLessThan( + asRoleManager.body.data.totalProjects, + ); + }); + + it('should return the members of a project the caller can list', async () => { + const response = await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${ownProject.id}/members`) + .expect(200); + + expect(response.body.data.members).toHaveLength(1); + expect(response.body.data.members[0].email).toBe(member.email); + }); + + it('should not return the members of a project the caller cannot list', async () => { + await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${otherProject.id}/members`) + .expect(404); + }); + + it('should not return the members of a project that does not exist', async () => { + await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/non-existing-project/members`) + .expect(404); + }); + + // `project:list` only lands on a custom project role as the auto-added companion to + // `project:read` (see projectAutoAddedListScopes), so a role built from workflow scopes + // alone carries neither. Such a member fails closed here, matching what + // `GET /rest/users?filter[projectId]` already does for the same caller. + it('should not return the members of a project the caller joined with a role lacking project scopes', async () => { + const minimalRole = await createCustomRoleWithScopeSlugs(['workflow:read', 'workflow:list'], { + roleType: 'project', + displayName: 'Workflow Reader Only', + }); + const minimalProject = await createTeamProject('Minimal Role Project', owner); + scratchProjectIds.push(minimalProject.id); + await linkUserToProject(roleManager, minimalProject, minimalRole.slug); + await linkUserToProject(member, minimalProject, 'project:editor'); + + const assignments = await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments`) + .expect(200); + expect(projectNamesOf(assignments.body)).not.toContain('Minimal Role Project'); + + await roleManagerAgent + .get(`/roles/${PROJECT_EDITOR_ROLE.slug}/assignments/${minimalProject.id}/members`) + .expect(404); + }); + }); }); diff --git a/packages/cli/test/integration/log-streaming.controller.test.ts b/packages/cli/test/integration/log-streaming.controller.test.ts index 802cca3d3c1..deb828591f2 100644 --- a/packages/cli/test/integration/log-streaming.controller.test.ts +++ b/packages/cli/test/integration/log-streaming.controller.test.ts @@ -1,8 +1,9 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { InstanceSettingsLoaderConfig } from '@n8n/config'; -import { GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; +import { GLOBAL_OWNER_ROLE, type CredentialsEntity, type User } from '@n8n/db'; import { Container } from '@n8n/di'; +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import { LogStreamingDestinationService } from '@/modules/log-streaming.ee/log-streaming-destination.service'; @@ -16,6 +17,7 @@ vi.unmock('@/eventbus/message-event-bus/message-event-bus'); mockInstance(Publisher); mockInstance(ExecutionRecoveryService); +const credentialsFinderService = mockInstance(CredentialsFinderService); const testServer = utils.setupTestServer({ endpointGroups: ['eventBus'], @@ -157,6 +159,71 @@ describe('POST /eventbus/destination', () => { }); }); +describe('credential access enforcement', () => { + const payloadWithCredential = (credentialId: string) => ({ + __type: '$$MessageEventBusDestinationWebhook', + url: 'http://localhost:3456', + method: 'POST', + label: 'With credentials', + enabled: false, + subscribedEvents: ['n8n.test.message'], + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + credentials: { httpHeaderAuth: { id: credentialId, name: 'Some cred' } }, + options: {}, + }); + + afterEach(() => { + credentialsFinderService.findCredentialForUser.mockReset(); + }); + + test('rejects with 403 when the user cannot access the referenced credential', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + + const response = await authOwnerAgent + .post('/eventbus/destination') + .send(payloadWithCredential('some-credential-id')); + + expect(response.statusCode).toBe(403); + expect(credentialsFinderService.findCredentialForUser).toHaveBeenCalledWith( + 'some-credential-id', + expect.objectContaining({ id: owner.id }), + ['credential:read'], + ); + }); + + test('allows creation when the user can access the referenced credential', async () => { + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'some-credential-id', + } as CredentialsEntity); + + const response = await authOwnerAgent + .post('/eventbus/destination') + .send(payloadWithCredential('some-credential-id')); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveProperty('id'); + }); + + test('rejects testing a destination whose credentials the user cannot access with 403', async () => { + // create it while access is granted, so a destination with credentials is persisted + credentialsFinderService.findCredentialForUser.mockResolvedValue({ + id: 'some-credential-id', + } as CredentialsEntity); + const created = await authOwnerAgent + .post('/eventbus/destination') + .send(payloadWithCredential('some-credential-id')); + expect(created.statusCode).toBe(200); + const { id } = created.body.data; + + // access revoked → testing must be rejected before the credential is decrypted/sent + credentialsFinderService.findCredentialForUser.mockResolvedValue(null); + const response = await authOwnerAgent.get('/eventbus/testmessage').query({ id }); + + expect(response.statusCode).toBe(403); + }); +}); + describe('when log streaming is managed by env', () => { beforeAll(() => { Container.get(InstanceSettingsLoaderConfig).logStreamingManagedByEnv = true; diff --git a/packages/cli/test/integration/policy/content-import.policy.test.ts b/packages/cli/test/integration/policy/content-import.policy.test.ts deleted file mode 100644 index 1379a6910d4..00000000000 --- a/packages/cli/test/integration/policy/content-import.policy.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -/** - * Pins the `contentImport` host wiring's advisory posture through the real policy decision - * pipeline. Unit tests mock `PolicyEnforcementService`, so they prove the service method is - * called with the right arguments but not that a registered check actually runs, or that a - * regression turning `evaluate` into something that can fail the batch would be caught — which - * is the one failure mode this host point exists to rule out (a single violating workflow must - * never take down a multi-artifact import). - */ -import type { SourceControlledFile } from '@n8n/api-types'; -import { getPersonalProject, getWorkflowById, newWorkflow, testDb } from '@n8n/backend-test-utils'; -import { - CredentialsRepository, - FolderRepository, - ProjectRepository, - SharedCredentialsRepository, - SharedWorkflowRepository, - TagRepository, - UserRepository, - WorkflowRepository, - WorkflowTagMappingRepository, -} from '@n8n/db'; -import type { Project, User } from '@n8n/db'; -import type { - ContentImportContext, - PolicyCheckResult, - RegisteredPolicyCheck, -} from '@n8n/decorators'; -import { PolicyCheck, PolicyCheckMetadata } from '@n8n/decorators'; -import { Container } from '@n8n/di'; -import type { InstanceSettings } from 'n8n-core'; -import { nanoid } from 'nanoid'; -import { readFile } from 'node:fs/promises'; -import { v4 as uuid } from 'uuid'; -import { mock } from 'vitest-mock-extended'; - -import type { IWorkflowToImport } from '@/interfaces'; -import { PolicyDecisionService } from '@/modules/policy-infrastructure/policy-decision.service'; -import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory'; -import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee'; -import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service'; -import { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; -import { ImportService } from '@/services/import.service'; -import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service'; - -import { createOwner } from '../shared/db/users'; - -// `readFile` must be mocked at the module level: the source-control service imports it as a -// named binding, which `vi.spyOn` on a namespace import can't intercept under Vitest. -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal(); - const readFile = vi.fn(actual.readFile); - return { ...actual, readFile, default: { ...actual, readFile } }; -}); - -const CHECK_ID = 'test-content-import-deny'; -const VIOLATION_KIND = 'test-content-import-denied'; - -const deniedMessage = (name: string) => `Workflow "${name}" is denied on import`; - -/** - * The decorator registers the check once per process, so it can't be swapped per test. Each - * test instead names the workflow it wants denied or blown up, which also keeps the check from - * touching the workflows other tests need to import cleanly. - */ -const deniedWorkflowNames = new Set(); -const throwingWorkflowNames = new Set(); - -@PolicyCheck() -class ContentImportDenyCheck implements RegisteredPolicyCheck { - readonly id = CHECK_ID; - - async onContentImport({ workflow }: ContentImportContext): Promise { - if (throwingWorkflowNames.has(workflow.name)) { - throw new Error(`content-import check exploded for "${workflow.name}"`); - } - - if (!deniedWorkflowNames.has(workflow.name)) { - return { violations: [] }; - } - - return { - violations: [ - { - kind: VIOLATION_KIND, - checkId: this.id, - message: deniedMessage(workflow.name), - subject: workflow.name, - subjectType: 'workflow', - scope: 'instance', - }, - ], - }; - } -} - -describe('contentImport policy wiring', () => { - let importService: ImportService; - let sourceControlImportService: SourceControlImportService; - let workflowRepository: WorkflowRepository; - let owner: User; - let ownerProject: Project; - - beforeAll(async () => { - await testDb.init(); - - // Without this the "allowed" cases below would still pass with the check never - // registered, which is exactly the silent allow-all this suite exists to catch. - expect(Container.get(PolicyCheckMetadata).getClasses()).toContain(ContentImportDenyCheck); - - // `setImplementation` is single-shot; this test file's own container hasn't installed a - // backend yet, so this is the one place it happens. - Container.get(PolicyEnforcementService).setImplementation(Container.get(PolicyDecisionService)); - - workflowRepository = Container.get(WorkflowRepository); - - // Built directly, mirroring `test/integration/import.service.test.ts`, but with the - // *real* `PolicyEnforcementService` wired above instead of a mock — everything else - // unrelated to the policy call stays a stub. - importService = new ImportService( - mock(), - Container.get(CredentialsRepository), - Container.get(TagRepository), - mock(), - mock(), - mock(), - mock(), - Container.get(UserRepository), - mock(), - Container.get(PolicyEnforcementService), - Container.get(SharedWorkflowRepository), - ); - - // Same idea, mirroring `test/integration/environments/source-control-import.service.test.ts`, - // again with the real `PolicyEnforcementService` swapped in for the mock. - sourceControlImportService = new SourceControlImportService( - mock(), - mock(), - mock(), - Container.get(CredentialsRepository), - Container.get(ProjectRepository), - mock(), - Container.get(TagRepository), - Container.get(SharedWorkflowRepository), - Container.get(SharedCredentialsRepository), - Container.get(UserRepository), - mock(), - Container.get(WorkflowRepository), - Container.get(WorkflowTagMappingRepository), - mock(), - mock(), - mock(), - Container.get(FolderRepository), - mock({ n8nFolder: '/some-path' }), - Container.get(SourceControlContextFactory), - Container.get(SourceControlScopedService), - Container.get(WorkflowHistoryService), - mock(), - mock(), - mock(), - mock(), - Container.get(PolicyEnforcementService), - mock(), - mock(), - mock(), - mock(), - mock(), - ); - - owner = await createOwner(); - ownerProject = await getPersonalProject(owner); - }); - - beforeEach(() => { - deniedWorkflowNames.clear(); - throwingWorkflowNames.clear(); - }); - - afterEach(async () => { - await testDb.truncate([ - 'WorkflowEntity', - 'SharedWorkflow', - 'WorkflowHistory', - 'WorkflowPublishHistory', - ]); - }); - - afterAll(async () => { - await testDb.terminate(); - }); - - describe('ImportService.importWorkflows()', () => { - test('flags only the denied workflow in the report and leaves the rest of the batch unaffected', async () => { - const clean = newWorkflow({ id: uuid(), name: 'Clean workflow' }); - const flagged = newWorkflow({ id: uuid(), name: 'Denied workflow' }); - deniedWorkflowNames.add(flagged.name); - - const { violations } = await importService.importWorkflows( - [clean, flagged], - ownerProject.id, - owner.id, - {}, - ); - - expect(violations).toStrictEqual([ - { - workflowId: flagged.id, - name: flagged.name, - contentImportPolicy: { - violations: [ - { - kind: VIOLATION_KIND, - checkId: CHECK_ID, - message: deniedMessage(flagged.name), - subject: flagged.name, - subjectType: 'workflow', - scope: 'instance', - }, - ], - checkErrors: [], - }, - }, - ]); - - // The batch completes for every workflow regardless of the violation: `evaluate` - // reports, it never blocks. - await expect(getWorkflowById(clean.id)).resolves.toBeDefined(); - await expect(getWorkflowById(flagged.id)).resolves.toBeDefined(); - }); - - test('imports normally when no check objects', async () => { - const workflow = newWorkflow({ id: uuid(), name: 'Unremarkable workflow' }); - - const { violations } = await importService.importWorkflows( - [workflow], - ownerProject.id, - owner.id, - {}, - ); - - expect(violations).toStrictEqual([]); - await expect(getWorkflowById(workflow.id)).resolves.toBeDefined(); - }); - - test('does not fail the batch when the registered check throws for one workflow', async () => { - const clean = newWorkflow({ id: uuid(), name: 'Clean workflow' }); - const broken = newWorkflow({ id: uuid(), name: 'Broken workflow' }); - throwingWorkflowNames.add(broken.name); - - const { violations } = await importService.importWorkflows( - [clean, broken], - ownerProject.id, - owner.id, - {}, - ); - - // A check that errors instead of answering surfaces as a `checkError`, not a - // violation — but either way nothing here fails the import. - expect(violations).toStrictEqual([ - { - workflowId: broken.id, - name: broken.name, - contentImportPolicy: { - violations: [], - checkErrors: [{ checkId: CHECK_ID, correlationId: expect.any(String) }], - }, - }, - ]); - await expect(getWorkflowById(clean.id)).resolves.toBeDefined(); - await expect(getWorkflowById(broken.id)).resolves.toBeDefined(); - }); - }); - - describe('SourceControlImportService.importWorkflowFromWorkFolder()', () => { - const mockFileData = new Map(); - const fsReadFile = vi.mocked(readFile); - - const putWorkflowFile = (workflowId: string, workflow: IWorkflowToImport) => { - const file = `/mock/${workflowId}.json`; - mockFileData.set(file, JSON.stringify(workflow)); - return file; - }; - - const makeWorkflowImport = (overrides: Partial = {}): IWorkflowToImport => ({ - id: overrides.id ?? nanoid(), - name: overrides.name ?? 'Test Workflow', - versionId: overrides.versionId ?? nanoid(), - nodes: - overrides.nodes ?? - ([ - { - id: 'node-1', - name: 'Start', - type: 'n8n-nodes-base.manualTrigger', - typeVersion: 1, - position: [250, 300] as [number, number], - parameters: {}, - }, - ] as IWorkflowToImport['nodes']), - connections: overrides.connections ?? {}, - settings: overrides.settings ?? {}, - parentFolderId: overrides.parentFolderId ?? null, - active: overrides.active ?? false, - isArchived: overrides.isArchived ?? false, - activeVersionId: overrides.activeVersionId ?? null, - }); - - beforeEach(() => { - mockFileData.clear(); - - fsReadFile.mockImplementation(async (path) => { - const pathStr = typeof path === 'string' ? path : path.toString(); - if (!mockFileData.has(pathStr)) { - throw new Error(`Trying to access invalid file in test: ${pathStr}`); - } - return mockFileData.get(pathStr)!; - }); - }); - - test('flags only the denied workflow in the result and leaves the rest of the batch unaffected', async () => { - const clean = makeWorkflowImport({ name: 'Clean workflow' }); - const flagged = makeWorkflowImport({ name: 'Denied workflow' }); - deniedWorkflowNames.add(flagged.name); - - const cleanFile = putWorkflowFile(clean.id, clean); - const flaggedFile = putWorkflowFile(flagged.id, flagged); - - const result = await sourceControlImportService.importWorkflowFromWorkFolder( - [ - mock({ id: clean.id, file: cleanFile }), - mock({ id: flagged.id, file: flaggedFile }), - ], - owner.id, - ); - - expect(result).toEqual([ - expect.objectContaining({ id: clean.id }), - expect.objectContaining({ - id: flagged.id, - contentImportPolicy: { - violations: [ - { - kind: VIOLATION_KIND, - checkId: CHECK_ID, - message: deniedMessage(flagged.name), - subject: flagged.name, - subjectType: 'workflow', - scope: 'instance', - }, - ], - checkErrors: [], - }, - }), - ]); - expect(result[0]).not.toHaveProperty('contentImportPolicy'); - - // The pull completes for every workflow regardless of the violation. - await expect(workflowRepository.findOne({ where: { id: clean.id } })).resolves.toBeTruthy(); - await expect(workflowRepository.findOne({ where: { id: flagged.id } })).resolves.toBeTruthy(); - }); - - test('imports normally when no check objects', async () => { - const workflow = makeWorkflowImport({ name: 'Unremarkable workflow' }); - const file = putWorkflowFile(workflow.id, workflow); - - const result = await sourceControlImportService.importWorkflowFromWorkFolder( - [mock({ id: workflow.id, file })], - owner.id, - ); - - expect(result).toEqual([expect.objectContaining({ id: workflow.id })]); - expect(result[0]).not.toHaveProperty('contentImportPolicy'); - }); - - test('does not fail the pull when the registered check throws for one workflow', async () => { - const broken = makeWorkflowImport({ name: 'Broken workflow' }); - throwingWorkflowNames.add(broken.name); - const file = putWorkflowFile(broken.id, broken); - - const result = await sourceControlImportService.importWorkflowFromWorkFolder( - [mock({ id: broken.id, file })], - owner.id, - ); - - // A check that errors instead of answering surfaces as a `checkError`, not a - // violation — but either way nothing here fails the pull. - expect(result).toEqual([ - expect.objectContaining({ - id: broken.id, - contentImportPolicy: { - violations: [], - checkErrors: [{ checkId: CHECK_ID, correlationId: expect.any(String) }], - }, - }), - ]); - await expect(workflowRepository.findOne({ where: { id: broken.id } })).resolves.toBeTruthy(); - }); - }); -}); diff --git a/packages/cli/test/integration/server-static-assets.test.ts b/packages/cli/test/integration/server-static-assets.test.ts new file mode 100644 index 00000000000..549049aa126 --- /dev/null +++ b/packages/cli/test/integration/server-static-assets.test.ts @@ -0,0 +1,277 @@ +import { Logger } from '@n8n/backend-common'; +import { mockInstance, mockLogger, testDb } from '@n8n/backend-test-utils'; +import { GlobalConfig } from '@n8n/config'; +import { HTML_NONCE_PLACEHOLDER } from '@n8n/constants'; +import { Container } from '@n8n/di'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { InstanceSettings } from 'n8n-core'; +import { mock } from 'vitest-mock-extended'; + +import { AuthService } from '@/auth/auth.service'; +import { AUTH_COOKIE_NAME } from '@/constants'; +import { ControllerRegistry } from '@/controller.registry'; +import { CredentialsOverwrites } from '@/credentials-overwrites'; +import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus'; +import type { EventService } from '@/events/event.service'; +import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay'; +import { ExternalHooks } from '@/external-hooks'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { MfaService } from '@/mfa/mfa.service'; +import { PostHogClient } from '@/posthog'; +import { Push } from '@/push'; +import type { FrontendService } from '@/services/frontend.service'; +import { Telemetry } from '@/telemetry'; + +import { createOwner } from './shared/db/users'; + +vi.mock('@/public-api', () => ({ + loadPublicApiVersions: async () => ({ + apiRouters: [(_req: unknown, _res: unknown, next: () => void) => next()], + apiLatestVersion: 1, + }), +})); + +vi.mock('@/mfa/helpers', () => ({ + handleMfaDisable: async () => {}, + isMfaFeatureEnabled: () => false, +})); + +/** The static mount differs per `frontendService` state, so both are built and checked. */ +const MOUNTS = ['without a frontend service', 'with a frontend service'] as const; +type Mount = (typeof MOUNTS)[number]; + +describe('Server static assets', () => { + const httpServers: http.Server[] = []; + const ports = {} as Record; + let authCookie: string; + + // Raw `http.request` rather than `supertest`: superagent normalises a path + // before it leaves the process, so the forms under test would never reach the + // server. + const request = async ( + method: string, + requestPath: string, + port: number, + headers: http.OutgoingHttpHeaders = {}, + ) => + await new Promise<{ + statusCode: number; + headers: http.IncomingHttpHeaders; + body: string; + }>((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, method, path: requestPath, headers }, + (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk: string) => (body += chunk)); + res.on('end', () => + resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body }), + ); + }, + ); + req.on('error', reject); + req.end(); + }); + + const get = async (requestPath: string, port: number, headers?: http.OutgoingHttpHeaders) => + await request('GET', requestPath, port, headers); + + beforeAll(async () => { + const staticCacheDir = mkdtempSync(path.join(tmpdir(), 'n8n-static-cache-')); + mkdirSync(path.join(staticCacheDir, 'types')); + writeFileSync(path.join(staticCacheDir, 'types', 'nodes.json'), '[{"name":"a-node"}]'); + writeFileSync(path.join(staticCacheDir, 'types', 'credentials.json'), '[{"name":"a-cred"}]'); + writeFileSync(path.join(staticCacheDir, 'types', 'node-versions.json'), '{"a-node":1}'); + writeFileSync( + path.join(staticCacheDir, 'index.html'), + ``, + ); + writeFileSync(path.join(staticCacheDir, 'public-asset.txt'), 'public'); + writeFileSync(path.join(staticCacheDir, 'types-extra.txt'), 'extra'); + + Container.set(Logger, mockLogger()); + mockInstance(PostHogClient); + mockInstance(Push); + mockInstance(Telemetry); + mockInstance(ControllerRegistry); + mockInstance(MessageEventBus); + mockInstance(LogStreamingEventRelay); + mockInstance(CredentialsOverwrites); + mockInstance(MfaService); + const externalHooks = mockInstance(ExternalHooks); + + await testDb.init(); + const owner = await createOwner(); + const jwt = Container.get(AuthService).issueJWT(owner, false, 'test-browser-id'); + authCookie = `${AUTH_COOKIE_NAME}=${jwt}`; + + const globalConfig = Container.get(GlobalConfig); + globalConfig.credentials.overwrite.endpoint = ''; + + const instanceSettings = mock({ + staticCacheDir, + n8nFolder: staticCacheDir, + hostId: 'test-host', + }); + Container.set(InstanceSettings, instanceSettings); + + const { Server } = await import('@/server.js'); + + for (const mount of MOUNTS) { + const server = new Server( + mock(), + Container.get(PostHogClient), + mock(), + instanceSettings, + ); + + if (mount === 'with a frontend service') { + // Both are set by `start()`, which the test does not run + Reflect.set(server, 'externalHooks', externalHooks); + Reflect.set( + server, + 'frontendService', + mock({ + getSettings: async () => mock>>(), + }), + ); + } + + await server.configure(); + + const httpServer = server.app.listen(0); + httpServers.push(httpServer); + ports[mount] = (httpServer.address() as AddressInfo).port; + } + }); + + afterAll(async () => { + for (const httpServer of httpServers) { + if (httpServer.listening) { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + } + await testDb.terminate(); + }); + + describe.each(MOUNTS)('%s', (mount) => { + // Only the frontend-service branch installs the history-api handler, which gives + // an unknown page its own cache header + test('builds the expected static mount', async () => { + const { headers } = await get('/unknown-page', ports[mount], { accept: 'text/html' }); + + if (mount === 'with a frontend service') { + expect(headers['cache-control']).toContain('no-store'); + } else { + expect(headers['cache-control']).toBeUndefined(); + } + }); + + test('does not serve an upper-cased non-UI route as the editor page', async () => { + const { headers } = await get('/ASSETS/main.js', ports[mount], { accept: 'text/html' }); + + expect(headers['cache-control']).toBeUndefined(); + }); + + describe('type files', () => { + const assertProtected = async (requestPath: string, method = 'GET') => { + const { statusCode, body } = await request(method, requestPath, ports[mount]); + + expect(statusCode).toBe(401); + expect(body).not.toContain('a-node'); + expect(body).not.toContain('a-cred'); + }; + + test.each([ + '/types/nodes.json', + '/types/credentials.json', + '/types/node-versions.json', + '/types', + ])( + 'requires authentication for %s', + async (requestPath) => await assertProtected(requestPath), + ); + + test.each([ + '/types//nodes.json', + '/types/%2fnodes.json', + '/types/x/../nodes.json', + '/foo/..%20/types/nodes.json', + ])( + 'requires authentication for %s', + async (requestPath) => await assertProtected(requestPath), + ); + + test('requires authentication for a mixed-case canonical path', async () => { + await assertProtected('/TYPES/nodes.json'); + }); + + test.each(['HEAD', 'POST'])( + 'requires authentication for %s /types/nodes.json', + async (method) => await assertProtected('/types/nodes.json', method), + ); + + test.each([ + ['/types/nodes.json', 'a-node'], + ['/types/credentials.json', 'a-cred'], + ['/types/node-versions.json', 'a-node'], + ])('serves %s to an authenticated request', async (requestPath, content) => { + const { statusCode, body } = await get(requestPath, ports[mount], { + cookie: authCookie, + }); + + expect(statusCode).toBe(200); + expect(body).toContain(content); + }); + + test('denies a non-canonical form without clearing the session cookie', async () => { + const { statusCode, headers } = await get('/types//nodes.json', ports[mount], { + cookie: authCookie, + }); + + expect(statusCode).toBe(401); + expect(headers['set-cookie']).toBeUndefined(); + }); + }); + + test.each([ + ['/public-asset.txt', 'public'], + ['/types-extra.txt', 'extra'], + ])('serves the unrelated static asset %s without authentication', async (requestPath, body) => { + // A non-HTML `Accept`, so the frontend mount's history-api handler declines the + // request and it reaches the static handler + const response = await get(requestPath, ports[mount], { accept: 'text/plain' }); + + expect(response.statusCode).toBe(200); + expect(response.body).toBe(body); + }); + + // The frontend-service branch answers a GET with no `Accept` with the editor page, since + // only it can fill in the page's nonce placeholders + test('answers a request with no Accept header per mount', async () => { + const response = await get('/public-asset.txt', ports[mount]); + + expect(response.statusCode).toBe(200); + + if (mount === 'with a frontend service') { + expect(response.body).toContain(''); + } else { + expect(response.body).toBe('public'); + } + }); + + // A malformed percent sequence anywhere in the path is undecodable as a whole, so the + // gate can't rule out it naming the protected directory and denies unconditionally, even + // for a path with no relation to it — an intentional, conservative trade-off. + test('denies a request with a malformed percent sequence, even outside /types', async () => { + const response = await get('/public-asset.txt%zz', ports[mount]); + + expect(response.statusCode).toBe(401); + }); + }); +}); diff --git a/packages/cli/test/integration/services/active-workflows.service.test.ts b/packages/cli/test/integration/services/active-workflows.service.test.ts new file mode 100644 index 00000000000..3a3e07ecae0 --- /dev/null +++ b/packages/cli/test/integration/services/active-workflows.service.test.ts @@ -0,0 +1,48 @@ +import { createActiveWorkflow, shareWorkflowWithUsers, testDb } from '@n8n/backend-test-utils'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { ActiveWorkflowsService } from '@/services/active-workflows.service'; + +import { createUser } from '../shared/db/users'; + +let owner: User; +let member: User; +let activeWorkflowsService: ActiveWorkflowsService; + +beforeAll(async () => { + await testDb.init(); + owner = await createUser({ role: GLOBAL_OWNER_ROLE }); + member = await createUser({ role: GLOBAL_MEMBER_ROLE }); + activeWorkflowsService = Container.get(ActiveWorkflowsService); +}); + +beforeEach(async () => { + await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'WorkflowHistory']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('ActiveWorkflowsService', () => { + describe('getAllActiveIdsFor', () => { + it('does not return active workflow ids the user has no access to', async () => { + const ownerWorkflow = await createActiveWorkflow({}, owner); + + const ids = await activeWorkflowsService.getAllActiveIdsFor(member); + + expect(ids).not.toContain(ownerWorkflow.id); + expect(ids).toEqual([]); + }); + + it('returns active workflow ids the user has been granted access to', async () => { + const sharedWorkflow = await createActiveWorkflow({}, owner); + await shareWorkflowWithUsers(sharedWorkflow, [member]); + + const ids = await activeWorkflowsService.getAllActiveIdsFor(member); + + expect(ids).toContain(sharedWorkflow.id); + }); + }); +}); diff --git a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts index 87b91b4d8cd..f815ece55dc 100644 --- a/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow-sharing.service.test.ts @@ -117,6 +117,45 @@ describe('WorkflowSharingService', () => { }); }); + describe('getUserIdsWithAccessToWorkflow', () => { + it('includes the global owner and the owning member, and excludes an unrelated member', async () => { + const workflow = await createWorkflow({}, member); + + const userIds = await workflowSharingService.getUserIdsWithAccessToWorkflow(workflow.id); + + expect(userIds).toContain(owner.id); + expect(userIds).toContain(member.id); + expect(userIds).not.toContain(anotherMember.id); + }); + + it('excludes a project member whose role does not grant workflow:read', async () => { + const project = await projectService.createTeamProject(member, { name: 'Team Project' }); + const workflow = await createWorkflow(undefined, project); + await projectService.addUser(project.id, { + userId: anotherMember.id, + role: 'project:chatUser', + }); + + const userIds = await workflowSharingService.getUserIdsWithAccessToWorkflow(workflow.id); + + expect(userIds).toContain(member.id); + expect(userIds).not.toContain(anotherMember.id); + }); + + it('includes a project member whose role grants workflow:read', async () => { + const project = await projectService.createTeamProject(member, { name: 'Team Project' }); + const workflow = await createWorkflow(undefined, project); + await projectService.addUser(project.id, { + userId: anotherMember.id, + role: 'project:viewer', + }); + + const userIds = await workflowSharingService.getUserIdsWithAccessToWorkflow(workflow.id); + + expect(userIds).toContain(anotherMember.id); + }); + }); + describe('rolesGrantingScope', () => { it('should return no options for users holding the scope globally', async () => { const options = await workflowSharingService.rolesGrantingScope(owner, 'workflow:read'); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts index 9a46a0e45e2..8b8887acaf4 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts @@ -209,6 +209,24 @@ describe('isFilePathBlocked', () => { }, ); + it.each(['/tmp/foo.git', '/tmp/.github', '/tmp/.git-credentials', '/tmp/a.git/b'])( + 'should per default allow access to %s', + async (path) => { + expect(isFilePathBlocked(await resolvePath(path))).toBe(false); + }, + ); + + it('should evaluate the default pattern without degradation on paths with many segments', async () => { + const manySegments = `/tmp/${'a/'.repeat(2000)}x`; + + const start = performance.now(); + const blocked = isFilePathBlocked(await resolvePath(manySegments)); + const elapsed = performance.now() - start; + + expect(blocked).toBe(false); + expect(elapsed).toBeLessThan(1000); + }); + it('should allow access when pattern matching is disabled', async () => { securityConfig.blockFilePatterns = ''; expect(isFilePathBlocked(await resolvePath('/tmp/.git'))).toBe(false); @@ -222,7 +240,7 @@ describe('isFilePathBlocked', () => { describe('cross-platform path handling', () => { beforeEach(() => { // Use default .git blocking pattern - securityConfig.blockFilePatterns = '^(.*\\/)*\\.git(\\/.*)*$'; + securityConfig.blockFilePatterns = originalBlockedFilePatterns; }); it('should handle Windows-style paths for .git directory', async () => { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts index b450d811bba..da69dfd4a6b 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ssrf-integration.test.ts @@ -12,6 +12,7 @@ import type { } from 'n8n-workflow'; import { UserError } from 'n8n-workflow'; import nock from 'nock'; +import dns from 'node:dns'; import type { LookupAddress } from 'node:dns'; import type { MockProxy } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended'; @@ -232,10 +233,13 @@ describe('SSRF end-to-end integration', () => { expect(helpers.getSecureEgressFilter()).toBe(ssrfBridge); }); - test('returns undefined when egress filtering is not configured', () => { + test('returns a passthrough filter using the plain system lookup when egress filtering is not configured', () => { const helpers = createRequestHelpers(undefined); - expect(helpers.getSecureEgressFilter()).toBeUndefined(); + const filter = helpers.getSecureEgressFilter(); + + expect(filter).toBeDefined(); + expect(filter.createSecureLookup()).toBe(dns.lookup); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts index 4b4192ecff0..d687f2ac4e8 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/request-helpers/factory.ts @@ -1,4 +1,4 @@ -import { OutboundHttp } from '@n8n/backend-network'; +import { passthroughEgressFilter, OutboundHttp } from '@n8n/backend-network'; import { Container } from '@n8n/di'; import type { IAllExecuteFunctions, @@ -87,7 +87,7 @@ export const getRequestHelperFunctions = ( } return await Container.get(OutboundHttp).requests().request(requestOptions); }, - getSecureEgressFilter: () => additionalData.ssrfBridge, + getSecureEgressFilter: () => additionalData.ssrfBridge ?? passthroughEgressFilter, async requestWithAuthenticationPaginated( this: IExecuteFunctions, requestOptions, diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index ea8c20728b3..c3fe23bf4c2 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3085,6 +3085,8 @@ "roles.instance.assignments.fetch.error": "Error loading members", "roles.fetch.error": "Error loading roles", "projectRoles.assignments.emptyState": "This role is not assigned in any projects yet.", + "projectRoles.assignments.restrictedEmptyState": "This role is assigned in {count} project you don't have access to. | This role is assigned in {count} projects you don't have access to.", + "projectRoles.assignments.restrictedNotice": "Showing {count} of {total} projects. Projects you're not a member of are hidden.", "projectRoles.assignments.projectColumn": "Project", "projectRoles.assignments.membersColumn": "Members assigned", "projectRoles.assignments.lastAssignedColumn": "Last assigned", @@ -7004,6 +7006,11 @@ "instanceAi.confirmation.alwaysAllowSuffix": "during this session", "instanceAi.confirmation.allowPrompt": "Allow AI Assistant to {action}?", "instanceAi.confirmation.deny": "Deny", + "instanceAi.confirmation.credentialDestination.title": "Send credential data to {origin}?", + "instanceAi.confirmation.credentialDestination.description": "The '{nodeName}' node will send credentials here for tests and executions.", + "instanceAi.confirmation.credentialDestination.descriptionMultiple": "These nodes will send credentials here for tests and executions: {nodeNames}.", + "instanceAi.confirmation.credentialDestination.approve": "Use destination", + "instanceAi.confirmation.credentialDestination.deny": "Don't use destination", "instanceAi.confirmation.approved": "Allowed", "instanceAi.confirmation.denied": "Denied", "instanceAi.confirmation.deferred": "Skipped", diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts index 9b7e534c289..b500bdbe4cd 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiConfirmationPanel.test.ts @@ -20,16 +20,26 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({ ...(await importOriginal()), useI18n: () => ({ baseText: (key: string, opts?: { interpolate?: Record }) => { + const translations: Record = { + 'instanceAi.confirmation.credentialDestination.title': 'Send credential data to {origin}?', + 'instanceAi.confirmation.credentialDestination.description': + "The '{nodeName}' node will send credentials here for tests and executions.", + 'instanceAi.confirmation.credentialDestination.descriptionMultiple': + 'These nodes will send credentials here for tests and executions: {nodeNames}.', + 'instanceAi.confirmation.credentialDestination.approve': 'Use destination', + 'instanceAi.confirmation.credentialDestination.deny': "Don't use destination", + }; if (key === 'agents.chat.approval.description') { return `The agent wants to run the ${opts?.interpolate?.toolName ?? ''} tool.`; } + const value = translations[key] ?? key; if (opts?.interpolate) { return Object.entries(opts.interpolate).reduce( (str, [k, v]) => str.replace(`{${k}}`, v), - key, + value, ); } - return key; + return value; }, }), })); @@ -172,6 +182,58 @@ describe('InstanceAiConfirmationPanel telemetry', () => { }); describe('approval confirmation', () => { + it('requires a specific decision for the credential destination', async () => { + injectPendingConfirmation( + thread, + { + requestId: 'req-destination', + severity: 'warning', + message: 'Review where this credential will be used', + workflowId: 'workflow-1', + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, + }, + { action: 'setup', workflowId: 'workflow-1' }, + 'workflows', + ); + const confirmSpy = vi.spyOn(thread, 'confirmAction').mockResolvedValue(true); + + const { getByText, getByTestId, queryByTestId } = renderComponent({ + props: { kind: 'floating' }, + }); + + expect(getByText('Send credential data to https://api.example.com?')).toBeVisible(); + expect( + getByText("The 'Fetch account' node will send credentials here for tests and executions."), + ).toBeVisible(); + expect(getByText('Use destination')).toBeVisible(); + expect(getByText("Don't use destination")).toBeVisible(); + expect(queryByTestId('instance-ai-panel-confirm-always-allow')).toBeNull(); + + await userEvent.click(getByTestId('instance-ai-panel-confirm-approve')); + + expect(confirmSpy).toHaveBeenCalledWith('req-destination', { + kind: 'credentialDestination', + approved: true, + origin: 'https://api.example.com', + }); + expect(mockTelemetryTrack).toHaveBeenCalledWith( + 'User finished providing input', + expect.objectContaining({ + type: 'credential-destination', + provided_inputs: [ + { + label: 'Review where this credential will be used', + options: ['approve', 'deny'], + option_chosen: 'approve', + }, + ], + }), + ); + }); + it('tracks approve with correct payload', async () => { injectPendingConfirmation(thread, { requestId: 'req-1', diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.liveRunState.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.liveRunState.test.ts index e677ff4f06e..32763593f2a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.liveRunState.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.liveRunState.test.ts @@ -125,6 +125,24 @@ describe('instanceAi.liveRunState', () => { expect(shouldRearmRunAfterConfirm({ kind: 'approval', approved: false })).toBe(false); }); + test('only re-arms after a credential destination is approved', () => { + const destination = { origin: 'https://api.example.com' }; + expect( + shouldRearmRunAfterConfirm({ + kind: 'credentialDestination', + approved: true, + ...destination, + }), + ).toBe(true); + expect( + shouldRearmRunAfterConfirm({ + kind: 'credentialDestination', + approved: false, + ...destination, + }), + ).toBe(false); + }); + test('re-arms on both legs of the MCP connect card', () => { expect(shouldRearmRunAfterConfirm({ kind: 'mcpConnect', approved: true })).toBe(true); expect(shouldRearmRunAfterConfirm({ kind: 'mcpConnect', approved: false })).toBe(true); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts index cfba01ce85c..80ca467379d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts @@ -7,7 +7,11 @@ import { mockedStore } from '@/__tests__/utils'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; import { fetchThreadMessages, fetchThreadStatus } from '../instanceAi.memory.api'; import { ensureThread, postMessage, postConfirmation, postCancel } from '../instanceAi.api'; -import { INSTANCE_AI_THREAD_SOURCE_FALLBACK, type InstanceAiTargetApproval } from '@n8n/api-types'; +import { + INSTANCE_AI_THREAD_SOURCE_FALLBACK, + type InstanceAiCredentialDestination, + type InstanceAiTargetApproval, +} from '@n8n/api-types'; import { createThreadRuntime, getAgentBuilderTargetFromThreadMetadata, @@ -1822,6 +1826,7 @@ describe('createThreadRuntime - session always-allow', () => { severity?: 'info' | 'warning' | 'destructive'; channelConfig?: { integrationType: string; agentId: string }; targetApproval?: InstanceAiTargetApproval; + credentialDestination?: InstanceAiCredentialDestination; workflowId?: string; }, ): void { @@ -1853,6 +1858,9 @@ describe('createThreadRuntime - session always-allow', () => { message: 'Approve?', ...(opts.channelConfig ? { channelConfig: opts.channelConfig } : {}), ...(opts.targetApproval ? { targetApproval: opts.targetApproval } : {}), + ...(opts.credentialDestination + ? { credentialDestination: opts.credentialDestination } + : {}), ...(opts.workflowId ? { workflowId: opts.workflowId } : {}), }, }, @@ -1934,6 +1942,26 @@ describe('createThreadRuntime - session always-allow', () => { expect(mockPostConfirmation).not.toHaveBeenCalled(); }); + it('does not auto-approve credential destinations with a generic setup grant', async () => { + const runtime = registry.getOrCreateRuntime(activeThreadId); + runtime.addAlwaysAllowKey('workflows', { action: 'setup' }); + + pushPendingApproval(runtime, { + messageId: 'msg-destination', + requestId: 'req-destination', + toolName: 'workflows', + args: { action: 'setup', workflowId: 'workflow-1' }, + credentialDestination: { + origin: 'https://api.example.com', + nodeNames: ['Fetch account'], + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(runtime.resolvedConfirmationIds.has('req-destination')).toBe(false); + expect(mockPostConfirmation).not.toHaveBeenCalled(); + }); + it('distinguishes submit-workflow create vs update grants by workflowId presence', async () => { const runtime = registry.getOrCreateRuntime(activeThreadId); runtime.addAlwaysAllowKey('submit-workflow', {}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue index e97e9e62e0d..95ca3cb56e5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiConfirmationPanel.vue @@ -1,7 +1,7 @@