From 16199c6e5648d79ad7c78d79debac546bb9500a2 Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:34:02 +0000 Subject: [PATCH] chore(core): Deprecate internal task runner mode and warn about SSRF default block list changes (no-changelog) (backport to release-candidate/2.38.x) (#38141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mike Repeć Co-authored-by: Claude Fable 5.1 --- .../__tests__/deprecation.service.test.ts | 29 +++++++++ .../src/deprecation/deprecation.service.ts | 23 +++++++ .../modules/breaking-changes/rules/index.ts | 2 + .../ssrf-default-blocked-ranges.rule.test.ts | 54 ++++++++++++++++ .../task-runner-internal-mode.rule.test.ts | 42 ++++++++++++ .../v3/ssrf-default-blocked-ranges.rule.ts | 64 +++++++++++++++++++ .../v3/task-runner-internal-mode.rule.ts | 51 +++++++++++++++ 7 files changed, 265 insertions(+) create mode 100644 packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ssrf-default-blocked-ranges.rule.test.ts create mode 100644 packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-internal-mode.rule.test.ts create mode 100644 packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts create mode 100644 packages/cli/src/modules/breaking-changes/rules/v3/task-runner-internal-mode.rule.ts diff --git a/packages/cli/src/deprecation/__tests__/deprecation.service.test.ts b/packages/cli/src/deprecation/__tests__/deprecation.service.test.ts index aa9c0834d19..41c52f6e74f 100644 --- a/packages/cli/src/deprecation/__tests__/deprecation.service.test.ts +++ b/packages/cli/src/deprecation/__tests__/deprecation.service.test.ts @@ -76,6 +76,13 @@ describe('DeprecationService', () => { ['N8N_OUTBOUND_PROXY_MODE', 'main-only', true], ['N8N_OUTBOUND_PROXY_MODE', 'all', false], ['N8N_OUTBOUND_PROXY_MODE', undefined, false], + ['N8N_RUNNERS_MODE', 'internal', true], + ['N8N_RUNNERS_MODE', 'external', false], + ['N8N_RUNNERS_MODE', undefined, false], + ['N8N_SSRF_PROTECTION_ENABLED', 'true', true], + ['N8N_SSRF_PROTECTION_ENABLED', '1', true], + ['N8N_SSRF_PROTECTION_ENABLED', 'false', false], + ['N8N_SSRF_PROTECTION_ENABLED', undefined, false], ])('should detect when %s is `%s`', (envVar, value, mustWarn) => { toTest(envVar, value, mustWarn); }); @@ -199,6 +206,28 @@ describe('DeprecationService', () => { }); }); + describe('N8N_SSRF_PROTECTION_ENABLED', () => { + beforeEach(() => { + process.env.N8N_SSRF_PROTECTION_ENABLED = 'true'; + }); + + test.each([undefined, 'default', 'DEFAULT , 100.64.0.0/10'])( + 'should warn when N8N_SSRF_BLOCKED_IP_RANGES is `%s`', + (ranges) => { + if (ranges === undefined) delete process.env.N8N_SSRF_BLOCKED_IP_RANGES; + else process.env.N8N_SSRF_BLOCKED_IP_RANGES = ranges; + deprecationService.warn(); + expect(logger.warn.mock.lastCall?.[0] ?? '').toContain('N8N_SSRF_PROTECTION_ENABLED'); + }, + ); + + test('should not warn when N8N_SSRF_BLOCKED_IP_RANGES lists literal ranges only', () => { + process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,192.168.0.0/16'; + deprecationService.warn(); + expect(logger.warn.mock.lastCall?.[0] ?? '').not.toContain('N8N_SSRF_PROTECTION_ENABLED'); + }); + }); + describe('running outside a container', () => { const message = 'Running n8n outside a container is deprecated'; diff --git a/packages/cli/src/deprecation/deprecation.service.ts b/packages/cli/src/deprecation/deprecation.service.ts index a3ba203c1c8..c36212cd5fa 100644 --- a/packages/cli/src/deprecation/deprecation.service.ts +++ b/packages/cli/src/deprecation/deprecation.service.ts @@ -73,6 +73,29 @@ export class DeprecationService { 'The default for this variable will change to `false` in a future version. Set it to `true` explicitly to keep installing unverified community packages.', checkValue: (value?: string) => value === undefined, }, + { + envVar: 'N8N_RUNNERS_MODE', + message: + 'The `internal` mode is deprecated and will be removed in a future version. Run task runners as a separate process and set this variable to `external`.', + checkValue: (value?: string) => value === 'internal', + }, + { + envVar: 'N8N_SSRF_PROTECTION_ENABLED', + message: + "The built-in blocked IP ranges will expand in a future version to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. To keep the current list, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges instead of the `default` keyword, which always expands to the running version's built-in list.", + checkValue: (value?: string) => ['true', '1'].includes(value?.toLowerCase() ?? ''), + // Literal block lists without the `default` keyword do not pick up the expanded built-in list. + disableIf: () => { + const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES; + return ( + ranges !== undefined && + !ranges + .toLowerCase() + .split(',') + .some((r) => r.trim() === 'default') + ); + }, + }, { envVar: 'N8N_RUNNERS_TASK_TIMEOUT', message: diff --git a/packages/cli/src/modules/breaking-changes/rules/index.ts b/packages/cli/src/modules/breaking-changes/rules/index.ts index c75e30ad709..1042ac6e9a3 100644 --- a/packages/cli/src/modules/breaking-changes/rules/index.ts +++ b/packages/cli/src/modules/breaking-changes/rules/index.ts @@ -37,6 +37,8 @@ import './v3/in-memory-binary-data.rule'; import './v3/offload-manual-executions.rule'; import './v3/removed-nodes.rule'; import './v3/removed-nodes-with-replacements.rule'; +import './v3/ssrf-default-blocked-ranges.rule'; +import './v3/task-runner-internal-mode.rule'; import './v3/task-runner-task-timeout.rule'; import './v3/unverified-packages.rule'; import './v3/workflow-import-url-removed.rule'; diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ssrf-default-blocked-ranges.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ssrf-default-blocked-ranges.rule.test.ts new file mode 100644 index 00000000000..42b9fb80617 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/ssrf-default-blocked-ranges.rule.test.ts @@ -0,0 +1,54 @@ +import type { GlobalConfig } from '@n8n/config'; +import { mock } from 'vitest-mock-extended'; + +import { SsrfDefaultBlockedRangesRule } from '../ssrf-default-blocked-ranges.rule'; + +describe('SsrfDefaultBlockedRangesRule', () => { + const createRule = (enabled: boolean) => + new SsrfDefaultBlockedRangesRule(mock({ ssrfProtection: { enabled } })); + + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.N8N_SSRF_BLOCKED_IP_RANGES; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('detect()', () => { + it('should be affected when SSRF protection is enabled', async () => { + const result = await createRule(true).detect(); + + expect(result.isAffected).toBe(true); + expect(result.instanceIssues).toHaveLength(1); + expect(result.instanceIssues[0].level).toBe('warning'); + expect(result.recommendations).toHaveLength(1); + }); + + it('should be affected when the block list keeps the default keyword', async () => { + process.env.N8N_SSRF_BLOCKED_IP_RANGES = 'Default, 100.64.0.0/10'; + + const result = await createRule(true).detect(); + + expect(result.isAffected).toBe(true); + }); + + it('should not be affected when the block list is literal ranges only', async () => { + process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,127.0.0.0/8'; + + const result = await createRule(true).detect(); + + expect(result.isAffected).toBe(false); + }); + + it('should not be affected when SSRF protection is disabled', async () => { + const result = await createRule(false).detect(); + + expect(result.isAffected).toBe(false); + expect(result.instanceIssues).toHaveLength(0); + }); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-internal-mode.rule.test.ts b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-internal-mode.rule.test.ts new file mode 100644 index 00000000000..be32f9d50ae --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/__tests__/task-runner-internal-mode.rule.test.ts @@ -0,0 +1,42 @@ +import { TaskRunnerInternalModeRule } from '../task-runner-internal-mode.rule'; + +describe('TaskRunnerInternalModeRule', () => { + const rule = new TaskRunnerInternalModeRule(); + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.N8N_RUNNERS_MODE; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + describe('detect()', () => { + it('should be affected when the mode is explicitly internal', async () => { + process.env.N8N_RUNNERS_MODE = 'internal'; + + const result = await rule.detect(); + + expect(result.isAffected).toBe(true); + expect(result.instanceIssues).toHaveLength(1); + expect(result.instanceIssues[0].level).toBe('warning'); + expect(result.recommendations).toHaveLength(1); + }); + + it('should not be affected when the mode is external', async () => { + process.env.N8N_RUNNERS_MODE = 'external'; + + const result = await rule.detect(); + + expect(result.isAffected).toBe(false); + }); + + it('should not be affected when the mode is not set', async () => { + const result = await rule.detect(); + + expect(result.isAffected).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts new file mode 100644 index 00000000000..65b17d1f8d6 --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/ssrf-default-blocked-ranges.rule.ts @@ -0,0 +1,64 @@ +import { GlobalConfig } from '@n8n/config'; +import { BreakingChangeRule } from '@n8n/decorators'; + +import type { + BreakingChangeRuleMetadata, + IBreakingChangeInstanceRule, + InstanceDetectionReport, +} from '../../types'; +import { BreakingChangeCategory } from '../../types'; + +@BreakingChangeRule({ version: 'v3' }) +export class SsrfDefaultBlockedRangesRule implements IBreakingChangeInstanceRule { + constructor(private readonly globalConfig: GlobalConfig) {} + + id: string = 'ssrf-default-blocked-ranges-v3'; + + getMetadata(): BreakingChangeRuleMetadata { + return { + version: 'v3', + title: 'SSRF protection blocks more IP ranges by default', + description: + 'The built-in blocked IP ranges expand to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. Requests to hosts in these ranges start failing on instances with SSRF protection enabled.', + category: BreakingChangeCategory.environment, + severity: 'medium', + documentationUrl: + 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security', + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async detect(): Promise { + // Instances that enumerate literal ranges do not pick up the expanded built-in list. + const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES; + const usesDefaultList = + ranges === undefined || + ranges + .toLowerCase() + .split(',') + .some((r) => r.trim() === 'default'); + + if (!this.globalConfig.ssrfProtection.enabled || !usesDefaultList) { + return { isAffected: false, instanceIssues: [], recommendations: [] }; + } + + return { + isAffected: true, + instanceIssues: [ + { + title: 'SSRF protection uses the built-in blocked IP ranges', + description: + 'N8N_SSRF_PROTECTION_ENABLED is true and N8N_SSRF_BLOCKED_IP_RANGES relies on the built-in list, either because it is not set or because it contains the `default` keyword. After the update, that list also covers 100.64.0.0/10 and IPv6 transition ranges, so workflows calling hosts in these ranges fail.', + level: 'warning', + }, + ], + recommendations: [ + { + action: 'Review internal hosts in the newly blocked ranges', + description: + "Check if any workflow calls hosts in 100.64.0.0/10 or IPv6 transition ranges. Add the ones you still need to N8N_SSRF_ALLOWED_IP_RANGES or N8N_SSRF_ALLOWED_HOSTNAMES. To keep the current list exactly, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges; the `default` keyword always expands to the running version's built-in list.", + }, + ], + }; + } +} diff --git a/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-internal-mode.rule.ts b/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-internal-mode.rule.ts new file mode 100644 index 00000000000..2ca21fa4d3a --- /dev/null +++ b/packages/cli/src/modules/breaking-changes/rules/v3/task-runner-internal-mode.rule.ts @@ -0,0 +1,51 @@ +import { BreakingChangeRule } from '@n8n/decorators'; + +import type { + BreakingChangeRuleMetadata, + IBreakingChangeInstanceRule, + InstanceDetectionReport, +} from '../../types'; +import { BreakingChangeCategory } from '../../types'; + +@BreakingChangeRule({ version: 'v3' }) +export class TaskRunnerInternalModeRule implements IBreakingChangeInstanceRule { + id: string = 'task-runner-internal-mode-v3'; + + getMetadata(): BreakingChangeRuleMetadata { + return { + version: 'v3', + title: 'Internal task runner mode is removed', + description: + 'Task runners can no longer run as a child process of n8n. The new version only supports N8N_RUNNERS_MODE=external and fails to start Code node executions when no external task runner connects.', + category: BreakingChangeCategory.infrastructure, + severity: 'medium', + documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners', + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async detect(): Promise { + if (process.env.N8N_RUNNERS_MODE !== 'internal') { + return { isAffected: false, instanceIssues: [], recommendations: [] }; + } + + return { + isAffected: true, + instanceIssues: [ + { + title: 'N8N_RUNNERS_MODE is set to "internal"', + description: + 'This instance runs task runners inside the n8n process. The new version rejects this mode, so Code nodes stop executing until an external task runner is connected.', + level: 'warning', + }, + ], + recommendations: [ + { + action: 'Switch to external task runners before upgrading', + description: + 'Run the task runner launcher as a separate process or container, set N8N_RUNNERS_MODE=external and share N8N_RUNNERS_AUTH_TOKEN between n8n and the launcher.', + }, + ], + }; + } +}