diff --git a/packages/@n8n/task-runner-python/src/task_runner.py b/packages/@n8n/task-runner-python/src/task_runner.py index 8f8791c1a05..677c4551c54 100644 --- a/packages/@n8n/task-runner-python/src/task_runner.py +++ b/packages/@n8n/task-runner-python/src/task_runner.py @@ -126,6 +126,9 @@ class TaskRunner: self.websocket_url, additional_headers=headers, max_size=self.config.max_payload_size, + # The broker connection must stay direct, never routed + # through a proxy configured via environment variables. + proxy=None, ) self.logger.info("Connected to broker") await self._listen_for_messages() diff --git a/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py b/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py index 43a97d7f24f..00a9b24c424 100644 --- a/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py +++ b/packages/@n8n/task-runner-python/tests/unit/test_task_runner.py @@ -94,6 +94,25 @@ class TestTaskRunnerConnectionRetry: assert mock_connect.call_count == 1 + @pytest.mark.asyncio + async def test_broker_connection_is_never_proxied(self, config): + runner = TaskRunner(config) + + def connection_side_effect(*args, **kwargs): + runner.is_shutting_down = True + raise ConnectionRefusedError("Connection refused") + + with ( + patch("src.task_runner.websockets.connect") as mock_connect, + patch.object(runner, "logger"), + patch("src.task_runner.asyncio.sleep"), + ): + mock_connect.side_effect = connection_side_effect + + await runner.start() + + assert mock_connect.call_args.kwargs["proxy"] is None + class TestTaskRunnerDrain: @pytest.fixture diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index c262140d79c..f64850fde7a 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -37,6 +37,7 @@ } }, "dependencies": { + "@n8n/backend-network": "workspace:*", "@n8n/config": "workspace:*", "@n8n/di": "workspace:*", "@n8n/errors": "workspace:*", diff --git a/packages/@n8n/task-runner/src/__tests__/start.test.ts b/packages/@n8n/task-runner/src/__tests__/start.test.ts new file mode 100644 index 00000000000..ea459795327 --- /dev/null +++ b/packages/@n8n/task-runner/src/__tests__/start.test.ts @@ -0,0 +1,46 @@ +import { Container } from '@n8n/di'; +import http from 'node:http'; +import https from 'node:https'; +import { mock } from 'vitest-mock-extended'; + +import { TaskRunnerSentry } from '../task-runner-sentry'; + +vi.mock('../js-task-runner/js-task-runner', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + JsTaskRunner: class { + on() {} + + async stop() {} + }, +})); + +describe('start', () => { + it('should install env-proxy global agents and exempt the broker host when proxy env vars are set', async () => { + const previousHttpAgent = http.globalAgent; + const previousHttpsAgent = https.globalAgent; + const previousNoProxy = process.env.NO_PROXY; + process.env.HTTPS_PROXY = 'http://proxy.host.invalid:3128'; + // The NODE_PATH shim in start.ts relies on a CJS-only internal API. + vi.stubEnv('NODE_PATH', undefined); + Container.set(TaskRunnerSentry, mock()); + + try { + await import('../start.js'); + + expect(http.globalAgent.constructor.name).toBe('EnvProxyHttpAgent'); + expect(https.globalAgent.constructor.name).toBe('EnvProxyHttpsAgent'); + expect(process.env.NO_PROXY?.split(',')).toContain('127.0.0.1'); + } finally { + delete process.env.HTTPS_PROXY; + if (previousNoProxy === undefined) { + delete process.env.NO_PROXY; + delete process.env.no_proxy; + } else { + process.env.NO_PROXY = previousNoProxy; + } + vi.unstubAllEnvs(); + http.globalAgent = previousHttpAgent; + https.globalAgent = previousHttpsAgent; + } + }); +}); diff --git a/packages/@n8n/task-runner/src/start.ts b/packages/@n8n/task-runner/src/start.ts index 6e41d1fbdaf..7df9b445040 100644 --- a/packages/@n8n/task-runner/src/start.ts +++ b/packages/@n8n/task-runner/src/start.ts @@ -1,3 +1,5 @@ +import { installGlobalProxyAgent } from '@n8n/backend-network'; +import { ensureHostsBypassProxy } from '@n8n/backend-network/proxy'; import { Container } from '@n8n/di'; import { ensureError } from '@n8n/utils/errors/ensure-error'; import { setGlobalState } from 'n8n-workflow'; @@ -70,6 +72,12 @@ function createSignalHandler( void (async function start() { const config = Container.get(MainConfig); + // Env-proxy global agents must be installed before any outbound traffic + // (Sentry, user task code). The task broker connection must stay direct, + // so its host is exempted from proxying for the process lifetime. + ensureHostsBypassProxy([new URL(config.baseRunnerConfig.taskBrokerUri).hostname]); + installGlobalProxyAgent(); + setGlobalState({ defaultTimezone: config.baseRunnerConfig.timezone, }); diff --git a/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts index 913fde8d6e8..667675b57e9 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process-py.test.ts @@ -50,6 +50,14 @@ describe('PyTaskRunnerProcess', () => { 'N8N_RUNNERS_ALLOW_TRANSITIVE_IMPORTS', 'N8N_RUNNERS_BUILTINS_DENY', 'N8N_BLOCK_RUNNER_ENV_ACCESS', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'ALL_PROXY', + 'all_proxy', + 'NO_PROXY', + 'no_proxy', ])('should propagate %s from env as is', async (envVar) => { process.env[envVar] = 'custom value'; diff --git a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts index 82409af3dae..6b212ad2432 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts @@ -114,6 +114,14 @@ describe('TaskRunnerProcess', () => { 'NODE_PATH', 'GENERIC_TIMEZONE', 'N8N_RUNNERS_INSECURE_MODE', + 'HTTP_PROXY', + 'http_proxy', + 'HTTPS_PROXY', + 'https_proxy', + 'ALL_PROXY', + 'all_proxy', + 'NO_PROXY', + 'no_proxy', ])('should propagate %s from env as is', async (envVar) => { authService.createGrantToken.mockResolvedValue('grantToken'); process.env[envVar] = 'custom value'; diff --git a/packages/cli/src/task-runners/task-runner-process-js.ts b/packages/cli/src/task-runners/task-runner-process-js.ts index 27be55994d6..a8105d904fc 100644 --- a/packages/cli/src/task-runners/task-runner-process-js.ts +++ b/packages/cli/src/task-runners/task-runner-process-js.ts @@ -62,6 +62,16 @@ export class JsTaskRunnerProcess extends TaskRunnerProcessBase { NODE_FUNCTION_ALLOW_BUILTIN: process.env.NODE_FUNCTION_ALLOW_BUILTIN, NODE_FUNCTION_ALLOW_EXTERNAL: process.env.NODE_FUNCTION_ALLOW_EXTERNAL, + // proxy + HTTP_PROXY: process.env.HTTP_PROXY, + http_proxy: process.env.http_proxy, + HTTPS_PROXY: process.env.HTTPS_PROXY, + https_proxy: process.env.https_proxy, + ALL_PROXY: process.env.ALL_PROXY, + all_proxy: process.env.all_proxy, + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy, + // sentry N8N_SENTRY_DSN: process.env.N8N_SENTRY_DSN, N8N_VERSION: process.env.N8N_VERSION, diff --git a/packages/cli/src/task-runners/task-runner-process-py.ts b/packages/cli/src/task-runners/task-runner-process-py.ts index be0276cdf63..8232388a801 100644 --- a/packages/cli/src/task-runners/task-runner-process-py.ts +++ b/packages/cli/src/task-runners/task-runner-process-py.ts @@ -49,6 +49,16 @@ export class PyTaskRunnerProcess extends TaskRunnerProcessBase { N8N_RUNNERS_TASK_TIMEOUT: this.runnerConfig.taskTimeout.toString(), N8N_RUNNERS_HEARTBEAT_INTERVAL: this.runnerConfig.heartbeatInterval.toString(), + // proxy + HTTP_PROXY: process.env.HTTP_PROXY, + http_proxy: process.env.http_proxy, + HTTPS_PROXY: process.env.HTTPS_PROXY, + https_proxy: process.env.https_proxy, + ALL_PROXY: process.env.ALL_PROXY, + all_proxy: process.env.all_proxy, + NO_PROXY: process.env.NO_PROXY, + no_proxy: process.env.no_proxy, + // n8n N8N_RUNNERS_STDLIB_ALLOW: process.env.N8N_RUNNERS_STDLIB_ALLOW, N8N_RUNNERS_EXTERNAL_ALLOW: process.env.N8N_RUNNERS_EXTERNAL_ALLOW, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb7132c9229..a094eca4357 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1128,7 +1128,7 @@ importers: version: 1.0.27(@aws-sdk/credential-provider-node@3.972.80)(@langchain/core@1.2.8(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(@smithy/signature-v4@5.7.2)(cheerio@1.1.0)(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@langchain/community': specifier: 'catalog:' - version: 1.1.27(6cc31e45a82b311205f2bf99fec67d91) + version: 1.1.27(2af036a866615d639e1d6409476fa500) '@langchain/core': specifier: 'catalog:' version: 1.2.8(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.221.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.46.0(@aws-sdk/credential-provider-node@3.972.80)(@smithy/signature-v4@5.7.2)(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76))(ws@8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -3718,6 +3718,9 @@ importers: packages/@n8n/task-runner: dependencies: + '@n8n/backend-network': + specifier: workspace:* + version: link:../backend-network '@n8n/config': specifier: workspace:* version: link:../config @@ -14296,6 +14299,7 @@ packages: '@xmldom/xmldom@0.8.14': resolution: {integrity: sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@yuku-codegen/binding-darwin-arm64@0.6.1': resolution: {integrity: sha512-LDJtpOKtcv9f3V0eDUwFmmy47t2VC+DAuN+gq80R1IA+fa0d408i6sHsVtt6n+g5rf8f86ySoPSAe94lHt6Ixw==} @@ -28512,7 +28516,7 @@ snapshots: - aws-crt - encoding - '@langchain/community@1.1.27(6cc31e45a82b311205f2bf99fec67d91)': + '@langchain/community@1.1.27(2af036a866615d639e1d6409476fa500)': dependencies: '@browserbasehq/stagehand': empty-npm-package@1.0.0 '@ibm-cloud/watsonx-ai': 1.1.2(supports-color@8.1.1)