feat(core): Block create/update of workflows with deprecated nodes (#31360)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Danny Martini <danny@n8n.io>
Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
mfsiega 2026-10-08 14:39:14 +00:00 committed by GitHub
parent 5543023723
commit ea8417e29a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1509 additions and 18 deletions

View File

@ -49,4 +49,14 @@ export class NodesConfig {
/** Memory limit in MB for the Merge node's SQL sandbox. */
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
mergeSqlSandboxMemoryLimitMb: number = 64;
/**
* Refuse deprecated nodes. Saving or importing a workflow fails if it adds or
* changes one. A sub-workflow from inline JSON, a URL or a file, or a single
* node run by n8n Assistant, fails if it contains one. Saved workflows that
* already contain deprecated nodes keep running. Set to `false` to turn this
* off, for example to import older backups.
*/
@Env('N8N_DEPRECATED_NODES_BLOCK')
blockDeprecated: boolean = true;
}

View File

@ -214,6 +214,7 @@ describe('GlobalConfig', () => {
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
pythonEnabled: true,
mergeSqlSandboxMemoryLimitMb: 64,
blockDeprecated: true,
},
publicApi: {
disabled: false,

View File

@ -1,12 +1,18 @@
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
import {
Code,
transformLegacyLangchainImport,
createSandboxLogger,
createProductionConsoleLog,
} from './Code.node';
describe('Code.node', () => {
it('is marked deprecated so the backend refuses new instances', () => {
const node = new Code();
expect(node.description.deprecated).toBe(true);
});
describe('createSandboxLogger', () => {
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');

View File

@ -238,6 +238,7 @@ export class Code implements INodeType {
description: INodeTypeDescription = {
displayName: 'LangChain Code',
name: 'code',
deprecated: true,
icon: 'fa:code',
iconColor: 'black',
group: ['transform'],

View File

@ -1,7 +1,7 @@
import { ModuleRegistry } from '@n8n/backend-common';
import { Logger, ModuleRegistry } from '@n8n/backend-common';
import { EventService, UrlService } from '@n8n/backend-services';
import { mockInstance } from '@n8n/backend-test-utils';
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
import { ExecutionsConfig, GlobalConfig, type NodesConfig, WorkflowsConfig } from '@n8n/config';
import type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db';
import {
ExecutionRepository,
@ -62,6 +62,8 @@ import {
} from '@/workflow-execute-additional-data';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
@ -141,6 +143,20 @@ describe('WorkflowExecuteAdditionalData', () => {
mockInstance(DataTableProxyService);
mockInstance(WorkflowHookContextService);
const workflowPublishedDataService = mockInstance(WorkflowPublishedDataService);
const deprecatedNodeTypes = mock<NodeTypes>();
deprecatedNodeTypes.getByNameAndVersion.mockImplementation((type) =>
type === 'n8n-nodes-base.function'
? mock<INodeType>({ description: { deprecated: true } })
: mock<INodeType>(),
);
Container.set(
DeprecatedNodesValidationService,
new DeprecatedNodesValidationService(
mock<Logger>(),
mock<NodesConfig>({ blockDeprecated: true }),
deprecatedNodeTypes,
),
);
const workflowsConfig = Container.get(WorkflowsConfig);
afterEach(() => {
// Keep the default (flag off) for every test; flag-on tests opt in.
@ -1196,6 +1212,15 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(result.id).toBe('parent-workflow-id');
},
);
it('refuses inline nodes of a deprecated type', async () => {
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
const workflowCode = mock<IWorkflowBase>({ nodes, connections: {} });
await expect(loadWorkflow({ code: workflowCode }, 'parent-workflow-id')).rejects.toThrow(
DeprecatedNodesError,
);
});
});
describe('getPublishedWorkflowData', () => {
@ -1425,6 +1450,17 @@ describe('WorkflowExecuteAdditionalData', () => {
workflowRepository.get.mockClear();
});
it('loads a stored workflow that contains a deprecated node', async () => {
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
workflowRepository.get.mockResolvedValue(
mock<WorkflowEntity>({ id: 'workflow-123', nodes, connections: {} }),
);
const result = await getDraftWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
expect(result.nodes).toEqual(nodes);
});
it('should use draft version', async () => {
const activeVersionNodes: INode[] = [
mock<INode>({

View File

@ -0,0 +1,23 @@
import { ResponseError } from '@n8n/errors';
export type DeprecatedNodeViolation = {
kind: 'added' | 'edited';
nodeName: string;
nodeType: string;
};
/**
* Thrown when a workflow adds or edits a node whose type is marked
* `deprecated: true`, on save or before an unsaved workflow or node runs.
* Lists the offending nodes in `meta.violations` so callers can act on them
* without parsing the error message.
*/
export class DeprecatedNodesError extends ResponseError {
constructor(
message: string,
readonly meta: { violations: DeprecatedNodeViolation[] },
) {
super(message, 400);
this.name = 'DeprecatedNodesError';
}
}

View File

@ -1,4 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import type { NodesConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { EntityManager } from '@n8n/typeorm';
import type { INode, INodeType } from 'n8n-workflow';
@ -6,7 +7,9 @@ import { v4 as uuid } from 'uuid';
import { mock } from 'vitest-mock-extended';
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { ChatHubTool } from '../chat-hub-tool.entity';
import type { ChatHubToolRepository } from '../chat-hub-tool.repository';
@ -73,7 +76,16 @@ describe('ChatHubToolService', () => {
description: { properties: [] },
} as unknown as INodeType);
service = new ChatHubToolService(logger, chatToolRepository, nodeTypes);
service = new ChatHubToolService(
logger,
chatToolRepository,
nodeTypes,
new DeprecatedNodesValidationService(
logger,
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
describe('getToolsByUserId', () => {
@ -124,7 +136,34 @@ describe('ChatHubToolService', () => {
});
});
const deprecatedCodeTool: INode = {
...mockDefinition,
type: '@n8n/n8n-nodes-langchain.code',
parameters: { code: { supplyData: { code: 'return 1;' } } },
};
const markCodeNodeDeprecated = () => {
nodeTypes.getByNameAndVersion.mockImplementation(
(type) =>
({
description: {
properties: [],
deprecated: type === deprecatedCodeTool.type ? true : undefined,
},
}) as unknown as INodeType,
);
};
describe('createTool', () => {
it('rejects a tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
await expect(
service.createTool(mockUser, { definition: deprecatedCodeTool }),
).rejects.toThrow(DeprecatedNodesError);
expect(chatToolRepository.createTool).not.toHaveBeenCalled();
});
it('should create a tool from the definition', async () => {
const created = makeTool();
chatToolRepository.createTool.mockResolvedValue(created);
@ -288,6 +327,40 @@ describe('ChatHubToolService', () => {
});
describe('updateTool', () => {
it('rejects changing a tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
await expect(
service.updateTool('tool-1', mockUser, {
definition: { ...deprecatedCodeTool, name: 'Renamed tool' },
}),
).rejects.toThrow(DeprecatedNodesError);
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
});
it('rejects enabling a stored tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(
makeTool({ definition: deprecatedCodeTool, enabled: false }),
);
await expect(service.updateTool('tool-1', mockUser, { enabled: true })).rejects.toThrow(
DeprecatedNodesError,
);
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
});
it('allows disabling a stored tool of a deprecated node type', async () => {
markCodeNodeDeprecated();
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
chatToolRepository.updateTool.mockResolvedValue(makeTool({ enabled: false }));
await service.updateTool('tool-1', mockUser, { enabled: false });
expect(chatToolRepository.updateTool).toHaveBeenCalled();
});
it('should update tool definition and denormalized fields', async () => {
const existingTool = makeTool();
const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' };

View File

@ -7,7 +7,9 @@ import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workfl
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { ChatHubAgent } from '../chat-hub-agent.entity';
@ -38,6 +40,7 @@ describe('ChatHubWorkflowService', () => {
const mockCipher = mock<Cipher>();
const policyEnforcementService = mock<PolicyEnforcementService>();
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
let chatHubAttachmentService: ChatHubAttachmentService;
let service: ChatHubWorkflowService;
@ -72,6 +75,7 @@ describe('ChatHubWorkflowService', () => {
workflowFinderService,
mockCipher,
policyEnforcementService,
deprecatedNodesValidationService,
);
// Mock repository methods
@ -162,6 +166,32 @@ describe('ChatHubWorkflowService', () => {
expect(workflowRepository.createContent).not.toHaveBeenCalled();
});
it('writes nothing when the workflow contains a deprecated node', async () => {
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
throw new DeprecatedNodesError('deprecated', { violations: [] });
});
await expect(
service.createChatWorkflow(
'user-123',
'session-456',
'project-789',
[],
'Hello',
[],
{},
{ provider: 'openai', model: 'gpt-4' },
undefined,
[],
'UTC',
null,
defaultExecutionMetadata,
),
).rejects.toThrow(DeprecatedNodesError);
expect(workflowRepository.createContent).not.toHaveBeenCalled();
});
it('enforces the save for the title-generation workflow', async () => {
await service.createTitleGenerationWorkflow(
'user-123',
@ -1363,6 +1393,7 @@ describe('ChatHubWorkflowService', () => {
workflowFinderService,
mockCipher,
policyEnforcementService,
deprecatedNodesValidationService,
);
const mockTrx = mock<EntityManager>();

View File

@ -11,6 +11,7 @@ import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8
import { BadRequestError, NotFoundError } from '@n8n/errors';
import { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { ChatHubTool } from './chat-hub-tool.entity';
import { ChatHubToolRepository } from './chat-hub-tool.repository';
@ -21,6 +22,7 @@ export class ChatHubToolService {
private readonly logger: Logger,
private readonly chatToolRepository: ChatHubToolRepository,
private readonly nodeTypes: NodeTypes,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.logger = this.logger.scoped('chat-hub');
}
@ -85,6 +87,7 @@ export class ChatHubToolService {
async createTool(user: User, data: ChatHubCreateToolRequest): Promise<ChatHubTool> {
const definition = data.definition;
this.validateToolExpressions(definition);
this.deprecatedNodesValidationService.validateOnCreate([definition]);
const tool = await this.chatToolRepository.createTool({
id: definition.id,
@ -117,12 +120,21 @@ export class ChatHubToolService {
if (updates.definition !== undefined) {
this.validateToolExpressions(updates.definition);
this.deprecatedNodesValidationService.validateOnUpdate(
[updates.definition],
[existingTool.definition],
);
updateData.definition = updates.definition;
updateData.name = updates.definition.name;
updateData.type = updates.definition.type;
updateData.typeVersion = updates.definition.typeVersion ?? 1;
}
if (updates.enabled !== undefined) {
if (updates.enabled) {
this.deprecatedNodesValidationService.validateOnCreate([
updates.definition ?? existingTool.definition,
]);
}
updateData.enabled = updates.enabled;
}

View File

@ -52,6 +52,7 @@ import { v4 as uuidv4 } from 'uuid';
import { BadRequestError } from '@n8n/errors';
import type { UserLike } from '@/types/user-like.types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
@ -96,6 +97,7 @@ export class ChatHubWorkflowService {
private readonly workflowFinderService: WorkflowFinderService,
private readonly cipher: Cipher,
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.logger = this.logger.scoped('chat-hub');
}
@ -196,6 +198,8 @@ export class ChatHubWorkflowService {
projectId: string,
user: UserLike,
) {
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
return await this.policyEnforcementService.enforceWorkflowSave(
{
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },

View File

@ -36,6 +36,7 @@ import { mock } from 'vitest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import type { ExecutionPersistence } from '@/executions/execution-persistence';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
@ -44,6 +45,7 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi
import type { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service';
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service';
@ -86,6 +88,7 @@ describe('SourceControlImportService', () => {
const dataTableColumnRepository = mock<DataTableColumnRepository>();
const dataTableDDLService = mock<DataTableDDLService>();
const redactionEnforcementService = mock<RedactionEnforcementService>();
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
const policyEnforcementService = mock<PolicyEnforcementService>();
policyEnforcementService.hasChecksFor.mockReturnValue(true);
policyEnforcementService.enforceContentImport.mockResolvedValue(mock());
@ -140,6 +143,7 @@ describe('SourceControlImportService', () => {
workflowPublishGuard,
workflowMutationHooks,
workflowFinderService,
deprecatedNodesValidationService,
);
const globMock = fastGlob.default as unknown as Mock<(...args: string[]) => Promise<string[]>>;
@ -1618,6 +1622,82 @@ describe('SourceControlImportService', () => {
});
});
describe('deprecated node enforcement', () => {
const mockUserId = 'user-id-123';
const deprecatedNode = {
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
const workflowFile = (id: string) =>
JSON.stringify({
id,
name: `Workflow ${id}`,
active: false,
nodes: [deprecatedNode],
connections: {},
versionId: 'v1',
parentFolderId: null,
});
beforeEach(() => {
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
Object.assign(new Project(), {
id: 'personal-project-id-123',
name: 'Personal Project',
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
}),
);
folderRepository.find.mockResolvedValue([]);
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
workflowRepository.upsertImportedContent.mockResolvedValue('1');
});
it('rejects a new workflow that fails the deprecated-node check', async () => {
workflowRepository.findByIds.mockResolvedValue([]);
fsReadFile.mockResolvedValueOnce(workflowFile('2'));
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
throw new DeprecatedNodesError('blocked', { violations: [] });
});
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow2.json', id: '2' })];
await expect(service.importWorkflowFromWorkFolder(candidates, mockUserId)).rejects.toThrow(
DeprecatedNodesError,
);
expect(deprecatedNodesValidationService.validateOnCreate).toHaveBeenCalledWith(
[deprecatedNode],
'2',
);
expect(workflowRepository.upsertImportedContent).not.toHaveBeenCalled();
});
it('checks an existing workflow against its stored nodes', async () => {
workflowRepository.findByIds.mockResolvedValue([
Object.assign(new WorkflowEntity(), { id: '1', nodes: [deprecatedNode] }),
]);
fsReadFile.mockResolvedValueOnce(workflowFile('1'));
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow1.json', id: '1' })];
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
expect(deprecatedNodesValidationService.validateOnUpdate).toHaveBeenCalledWith(
[deprecatedNode],
[deprecatedNode],
'1',
);
expect(deprecatedNodesValidationService.validateOnCreate).not.toHaveBeenCalled();
expect(workflowRepository.upsertImportedContent).toHaveBeenCalled();
});
});
describe('content-import policy enforcement', () => {
const mockUserId = 'user-id-123';
const mockWorkflowFile = '/mock/workflow1.json';

View File

@ -61,6 +61,7 @@ import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
@ -173,6 +174,7 @@ export class SourceControlImportService {
private readonly workflowPublishGuard: WorkflowPublishGuardProxy,
private readonly workflowMutationHooks: WorkflowMutationHooksProxy,
private readonly workflowFinderService: WorkflowFinderService,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
@ -783,7 +785,7 @@ export class SourceControlImportService {
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'],
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived', 'nodes'],
});
const folders = await this.folderRepository.find({ select: ['id'] });
@ -857,6 +859,19 @@ export class SourceControlImportService {
}
const existingWorkflow = existingWorkflows.find((e) => e.id === id);
// Apply the same deprecated-node enforcement as the REST/public API
// endpoints. A re-sync that leaves the deprecated nodes untouched is
// allowed; introducing or in-place editing them through git is not.
if (existingWorkflow) {
this.deprecatedNodesValidationService.validateOnUpdate(
nodes,
existingWorkflow.nodes ?? [],
id,
);
} else {
this.deprecatedNodesValidationService.validateOnCreate(nodes, id);
}
await this.redactionEnforcementService.assertPolicyChangeAllowed(
existingWorkflow?.settings?.redactionPolicy,
importedWorkflow.settings?.redactionPolicy,

View File

@ -641,6 +641,7 @@ describe('auto-close on source-control pull', () => {
mock(), // workflowPublishGuard
Container.get(WorkflowMutationHooksProxy),
Container.get(WorkflowFinderService),
mock(),
);
});

View File

@ -1,5 +1,6 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { NodesConfig } from '@n8n/config';
import { WorkflowRepository } from '@n8n/db';
import type { User } from '@n8n/db';
import { InstanceSettings } from 'n8n-core';
@ -11,7 +12,9 @@ import { CredentialsFinderService } from '@n8n/backend-services';
import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import { NodeTypes } from '@/node-types';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import { WorkflowRunner } from '@/workflow-runner';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { ExecuteNodeService } from '../execute-node.service';
import type { ExecuteNodeRequest } from '../execute-node.service';
@ -54,6 +57,11 @@ describe('ExecuteNodeService', () => {
const activeExecutions = mockInstance(ActiveExecutions);
const executionPersistence = mockInstance(ExecutionPersistence);
const instanceSettings = mockInstance(InstanceSettings);
const deprecatedNodesValidationService = new DeprecatedNodesValidationService(
logger,
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
);
const service = new ExecuteNodeService(
nodeTypes,
@ -64,6 +72,7 @@ describe('ExecuteNodeService', () => {
activeExecutions,
executionPersistence,
instanceSettings,
deprecatedNodesValidationService,
);
const user = mock<User>({ id: 'user-1' });
@ -86,6 +95,17 @@ describe('ExecuteNodeService', () => {
});
describe('pre-flight validation', () => {
it('rejects a deprecated node type before creating any workflow row', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(
mockNodeType({ description: { ...testNodeDescription, deprecated: true } }),
);
await expect(
service.run(user, baseRequest({ type: 'n8n-nodes-base.function', version: 1 })),
).rejects.toThrow(DeprecatedNodesError);
expect(workflowRepository.createWorkflowWithOwner).not.toHaveBeenCalled();
});
it('rejects an unknown node type before creating any workflow row', async () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('unknown');

View File

@ -22,6 +22,7 @@ import { BadRequestError, ForbiddenError } from '@n8n/errors';
import { ExecutionPersistence } from '@/executions/execution-persistence';
import { NodeTypes } from '@/node-types';
import { WorkflowRunner } from '@/workflow-runner';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000;
export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000;
@ -86,6 +87,7 @@ export class ExecuteNodeService {
private readonly activeExecutions: ActiveExecutions,
private readonly executionPersistence: ExecutionPersistence,
private readonly instanceSettings: InstanceSettings,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async run(user: User, request: ExecuteNodeRequest): Promise<ExecuteNodeResult> {
@ -98,6 +100,7 @@ export class ExecuteNodeService {
await this.checkCredentialAccess(user, credentials);
const node = this.buildNode(request, credentials);
this.deprecatedNodesValidationService.validateOnCreate([node]);
const timeoutMs = Math.min(
request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS,
MAX_EXECUTE_NODE_TIMEOUT_MS,

View File

@ -135,6 +135,7 @@ describe('ImportService', () => {
mockPolicyEnforcementService,
mockSharedWorkflowRepository,
mockWorkflowRepository,
mock(),
);
});

View File

@ -40,6 +40,7 @@ import {
validateWorkflowStructure,
sanitizeNodeGroupDescriptions,
} from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowService } from '@/workflows/workflow.service';
import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants';
@ -90,6 +91,7 @@ export class ImportService {
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async initRecords() {
@ -118,20 +120,22 @@ export class ImportService {
const workflowIds = workflows.map((w) => w.id).filter((id) => !!id);
const existingWorkflowIds = new Set<string>();
const activeVersionIdByWorkflow = new Map<string, string>();
const existingNodesByWorkflow = new Map<string, INode[]>();
let existingOwnerProjects = new Map<string, Project>();
if (workflowIds.length > 0) {
const existingWorkflows = await dbManager.find(WorkflowEntity, {
where: { id: In(workflowIds) },
select: ['id', 'activeVersionId'],
select: ['id', 'activeVersionId', 'nodes'],
});
for (const { id, activeVersionId } of existingWorkflows) {
for (const { id, activeVersionId, nodes } of existingWorkflows) {
existingWorkflowIds.add(id);
if (activeVersionId !== null) {
activeVersionIdByWorkflow.set(id, activeVersionId);
}
existingNodesByWorkflow.set(id, nodes ?? []);
}
// An existing workflow's policy scope is its own project, not `projectId` — that
@ -162,6 +166,21 @@ export class ImportService {
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
validateWorkflowStructure(workflow);
// Apply the same deprecated-node enforcement that the REST/public API
// endpoints use: treat unknown workflow IDs as creates, known IDs as
// updates (so a re-import of an existing workflow with the same
// deprecated nodes is allowed, but introducing new ones is not).
const existingNodes = workflow.id ? existingNodesByWorkflow.get(workflow.id) : undefined;
if (existingNodes) {
this.deprecatedNodesValidationService.validateOnUpdate(
workflow.nodes,
existingNodes,
workflow.id,
);
} else {
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
}
for (const warning of sanitizeNodeGroupDescriptions(workflow)) {
this.logger.warn(`Workflow "${workflow.name}": ${warning}`);
}

View File

@ -77,6 +77,7 @@ import { TaskRequester } from '@/task-runners/task-managers/task-requester';
import { findSubworkflowStart } from '@/utils';
import { objectToError } from '@/utils/object-to-error';
import * as WorkflowHelpers from '@/workflow-helpers';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
@ -157,6 +158,11 @@ async function fetchWorkflowData(
} else {
const workflowData = workflowInfo.code;
if (workflowData) {
// The save-time check does not see inline JSON, so it runs here.
Container.get(DeprecatedNodesValidationService).validateOnCreate(
workflowData.nodes ?? [],
parentWorkflowId,
);
// An inline sub-workflow is part of the parent that embeds it, not a
// workflow of its own, so it runs under the parent workflow's id.
workflowData.id = parentWorkflowId;

View File

@ -0,0 +1,322 @@
import type { Logger } from '@n8n/backend-common';
import type { NodesConfig } from '@n8n/config';
import type { INode, INodeType } from 'n8n-workflow';
import { fail } from 'node:assert';
import { mock } from 'vitest-mock-extended';
import {
DeprecatedNodesError,
type DeprecatedNodeViolation,
} from '@/errors/response-errors/deprecated-nodes.error';
import type { NodeTypes } from '@/node-types';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
describe('DeprecatedNodesValidationService', () => {
let validator: DeprecatedNodesValidationService;
let nodesConfig: NodesConfig;
let nodeTypes: ReturnType<typeof mock<NodeTypes>>;
let logger: ReturnType<typeof mock<Logger>>;
const nodeTypeFor = (type: string, deprecated?: boolean): INodeType =>
Object.assign(mock<INodeType>(), {
description: {
name: type,
deprecated: deprecated ? true : undefined,
properties: [
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '// default' },
],
},
});
beforeEach(() => {
nodesConfig = { blockDeprecated: true } as NodesConfig;
nodeTypes = mock<NodeTypes>();
logger = mock<Logger>();
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
if (type === 'n8n-nodes-base.function' || type === 'n8n-nodes-base.functionItem') {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
validator = new DeprecatedNodesValidationService(logger, nodesConfig, nodeTypes);
});
const expectViolations = (run: () => void, violations: DeprecatedNodeViolation[]) => {
try {
run();
fail('expected to throw');
} catch (error) {
expect(error).toBeInstanceOf(DeprecatedNodesError);
expect((error as DeprecatedNodesError).meta.violations).toEqual(violations);
}
};
const makeNode = (overrides: Partial<INode> & Pick<INode, 'id' | 'type'>): INode => ({
name: overrides.id,
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides,
});
describe('validateOnCreate', () => {
it('passes when no deprecated nodes are present', () => {
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
it('throws DeprecatedNodesError when any node type is deprecated', () => {
const nodes = [
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
];
expect(() => validator.validateOnCreate(nodes)).toThrow(DeprecatedNodesError);
expect(() => validator.validateOnCreate(nodes)).toThrow(/deprecated/);
});
it('exposes every deprecated node in error meta and message', () => {
const nodes = [
makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func A' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.functionItem', name: 'Func B' }),
];
try {
validator.validateOnCreate(nodes);
fail('expected to throw');
} catch (error) {
expect(error).toBeInstanceOf(DeprecatedNodesError);
const typed = error as DeprecatedNodesError;
expect(typed.meta.violations).toEqual([
{ kind: 'added', nodeName: 'Func A', nodeType: 'n8n-nodes-base.function' },
{ kind: 'added', nodeName: 'Func B', nodeType: 'n8n-nodes-base.functionItem' },
]);
expect(typed.message).toContain('Func A');
expect(typed.message).toContain('Func B');
}
});
it('does not treat an unknown node type as deprecated', () => {
nodeTypes.getByNameAndVersion.mockImplementation(() => {
throw new Error('Unrecognized node type');
});
const nodes = [makeNode({ id: 'a', type: 'community.unknown' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
it('is a no-op when the config flag is off', () => {
nodesConfig.blockDeprecated = false;
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.function' })];
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
});
});
describe('validateOnUpdate', () => {
it('allows an unchanged deprecated node to pass through', () => {
const node = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
parameters: { functionCode: 'return items;' },
});
expect(() => validator.validateOnUpdate([node], [node])).not.toThrow();
});
it('allows position-only changes to a deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
position: [0, 0],
});
const after = { ...before, position: [200, 100] as [number, number] };
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows a structurally equal copy of a deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
parameters: { functionCode: 'return items;' },
credentials: { someApi: { id: '1', name: 'Some API' } },
});
expect(() => validator.validateOnUpdate([structuredClone(before)], [before])).not.toThrow();
});
it('treats absent and default disabled and credentials as unchanged', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
const after = { ...before, disabled: false, credentials: {} };
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows the editor-saved shape of a deprecated node stored with explicit defaults', () => {
const before: INode = {
...makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }),
parameters: { functionCode: '// default' },
notes: '',
onError: 'stopWorkflow',
continueOnFail: false,
};
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('blocks adding notes to an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, notes: 'changed' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks disabling an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, disabled: true };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks changing the credentials of an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, credentials: { someApi: { id: '2', name: 'Other API' } } };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks changing the error handling of an existing deprecated node', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after: INode = { ...before, onError: 'continueRegularOutput' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('treats a deprecated node sent with a new id as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, id: 'b' };
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
);
});
it('treats a deprecated node sent without an id as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, id: undefined as unknown as string };
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
);
});
it('treats a deprecated node reusing the id of a non-deprecated node as added', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.set', name: 'Set' });
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Set' });
expectViolations(
() => validator.validateOnUpdate([after], [before]),
[{ kind: 'added', nodeName: 'Set', nodeType: 'n8n-nodes-base.function' }],
);
});
it('logs a warning with the workflow id when it rejects a save', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = { ...before, parameters: { functionCode: 'return [];' } };
expect(() => validator.validateOnUpdate([after], [before], 'wf-1')).toThrow(
DeprecatedNodesError,
);
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
workflowId: 'wf-1',
violations: [{ kind: 'edited', nodeType: 'n8n-nodes-base.function' }],
});
});
it('blocks adding a deprecated node that did not exist before', () => {
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
const after = [
...before,
makeNode({ id: 'b', type: 'n8n-nodes-base.function', name: 'New Func' }),
];
expect(() => validator.validateOnUpdate(after, before)).toThrow(/Cannot use.*New Func/);
});
it('blocks editing the parameters of an existing deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
parameters: { functionCode: 'return items;' },
});
const after = { ...before, parameters: { functionCode: 'return [];' } };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
});
it('blocks editing the name of an existing deprecated node', () => {
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.function',
name: 'Func',
});
const after = { ...before, name: 'Renamed' };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify/);
});
it('blocks bumping the typeVersion of a non-deprecated node down to a deprecated version', () => {
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
if (type === 'n8n-nodes-base.myNode' && version === 1) {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.myNode', typeVersion: 2 });
const after = { ...before, typeVersion: 1 };
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot use/);
});
it('allows migrating a deprecated typeVersion forward, including parameter changes', () => {
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
if (type === 'n8n-nodes-base.myNode' && version === 1) {
return nodeTypeFor(type, true);
}
return nodeTypeFor(type, false);
});
const before = makeNode({
id: 'a',
type: 'n8n-nodes-base.myNode',
typeVersion: 1,
parameters: { mode: 'legacy' },
});
const after = {
...before,
typeVersion: 2,
parameters: { mode: 'safe', extra: 'new-field' },
};
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows replacing a deprecated node with a different node type at the same id', () => {
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.code', name: 'Func' });
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
});
it('allows deleting a deprecated node', () => {
const before = [
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
];
const after = [before[0]];
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
});
it('allows edits to non-deprecated nodes alongside a frozen deprecated one', () => {
const deprecated = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
const otherBefore = makeNode({ id: 'b', type: 'n8n-nodes-base.set', parameters: { x: 1 } });
const otherAfter = { ...otherBefore, parameters: { x: 2 } };
expect(() =>
validator.validateOnUpdate([deprecated, otherAfter], [deprecated, otherBefore]),
).not.toThrow();
});
it('is a no-op when the config flag is off', () => {
nodesConfig.blockDeprecated = false;
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
const after = [...before, makeNode({ id: 'b', type: 'n8n-nodes-base.function' })];
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
});
});
});

View File

@ -123,6 +123,7 @@ describe('WorkflowCreationService', () => {
policyEnforcementServiceMock,
workflowRepositoryMock,
nodeGroupRulesFlagGateMock,
mock(),
);
});

View File

@ -144,6 +144,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
restrictedNodeTypesProviderMock, // restrictedNodeTypesProvider
mock(),
);
});
@ -562,6 +563,7 @@ describe('WorkflowService', () => {
nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
vi.clearAllMocks();
@ -1599,6 +1601,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
// Bypass validation internals
@ -2361,6 +2364,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -2504,6 +2508,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -2816,6 +2821,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -2992,6 +2998,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -3181,6 +3188,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -3286,6 +3294,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
mock(), // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
});
@ -3394,6 +3403,7 @@ describe('WorkflowService', () => {
mock(), // nodeGroupRulesFlagGate
errorWorkflowValidationServiceMock, // errorWorkflowValidationService
mock(), // restrictedNodeTypesProvider
mock(),
);
vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings);

View File

@ -0,0 +1,135 @@
import { Logger } from '@n8n/backend-common';
import { NodesConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import isEqual from 'lodash/isEqual';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
import { NodeHelpers, normalizeNodeShape } from 'n8n-workflow';
import {
DeprecatedNodesError,
type DeprecatedNodeViolation,
} from '@/errors/response-errors/deprecated-nodes.error';
import { NodeTypes } from '@/node-types';
/**
* Refuses adding a deprecated node or changing one in place. Removing a
* deprecated node, or moving it to a non-deprecated type or version, is allowed.
* Does nothing when `N8N_DEPRECATED_NODES_BLOCK` is off.
*/
@Service()
export class DeprecatedNodesValidationService {
constructor(
private readonly logger: Logger,
private readonly nodesConfig: NodesConfig,
private readonly nodeTypes: NodeTypes,
) {}
/** Throws if any node is deprecated. Use where there is no stored version to compare with. */
validateOnCreate(nodes: INode[], workflowId?: string): void {
if (!this.nodesConfig.blockDeprecated) return;
const violations: DeprecatedNodeViolation[] = [];
for (const node of nodes) {
if (this.deprecatedDescription(node)) {
violations.push({ kind: 'added', nodeName: node.name, nodeType: node.type });
}
}
this.throwIfViolations(violations, workflowId);
}
/**
* Throws if a deprecated node is new, or differs from the stored node with the
* same id other than in position.
*/
validateOnUpdate(incomingNodes: INode[], existingNodes: INode[], workflowId?: string): void {
if (!this.nodesConfig.blockDeprecated) return;
const existingById = new Map(existingNodes.map((n) => [n.id, n]));
const violations: DeprecatedNodeViolation[] = [];
for (const incoming of incomingNodes) {
const description = this.deprecatedDescription(incoming);
if (!description) continue;
const before = existingById.get(incoming.id);
// A stored node that was not deprecated counts as adding one, so a node cannot be moved back onto a deprecated version.
if (!before || !this.deprecatedDescription(before)) {
violations.push({ kind: 'added', nodeName: incoming.name, nodeType: incoming.type });
continue;
}
if (
!isEqual(this.frozenFields(before, description), this.frozenFields(incoming, description))
) {
violations.push({ kind: 'edited', nodeName: incoming.name, nodeType: incoming.type });
}
}
this.throwIfViolations(violations, workflowId);
}
/** Puts a node in the shape the editor saves, so a re-save of an untouched node compares equal. */
private frozenFields(node: INode, description: INodeTypeDescription) {
const {
position: _position,
notes,
onError,
continueOnFail,
disabled,
credentials,
parameters,
...rest
} = normalizeNodeShape(node);
return {
...rest,
parameters:
NodeHelpers.getNodeParameters(
description.properties,
parameters,
false,
false,
node,
description,
) ?? {},
credentials: credentials ?? {},
disabled: disabled === true,
continueOnFail: continueOnFail === true,
onError: onError ?? 'stopWorkflow',
notes: notes ?? '',
};
}
private throwIfViolations(violations: DeprecatedNodeViolation[], workflowId?: string) {
if (violations.length === 0) return;
this.logger.warn('Rejected deprecated nodes', {
workflowId,
violations: violations.map(({ kind, nodeType }) => ({ kind, nodeType })),
});
throw new DeprecatedNodesError(this.formatMessage(violations), { violations });
}
private deprecatedDescription(node: INode): INodeTypeDescription | undefined {
try {
const { description } = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
return description.deprecated === true ? description : undefined;
} catch {
// Unknown node types can't be deprecated by us; they're handled elsewhere.
return undefined;
}
}
private formatMessage(violations: DeprecatedNodeViolation[]): string {
const lines = violations.map((v) => {
const verb = v.kind === 'added' ? 'use a' : 'modify a';
return `Cannot ${verb} "${v.nodeType}" node ("${v.nodeName}"): this node type is deprecated.`;
});
const suffix = ' Replace the node with a supported alternative or remove it from the workflow.';
return lines.join(' ') + suffix;
}
}

View File

@ -32,6 +32,7 @@ import { TagService } from '@/services/tag.service';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
import { dropRedactionPolicy } from './utils';
import { WorkflowFinderService } from './workflow-finder.service';
@ -74,6 +75,7 @@ export class WorkflowCreationService {
private readonly policyEnforcementService: PolicyEnforcementService,
private readonly workflowRepository: WorkflowRepository,
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
async prepareBatchContext(
@ -222,6 +224,7 @@ export class WorkflowCreationService {
WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes),
rules,
);
this.deprecatedNodesValidationService.validateOnCreate(newWorkflow.nodes, newWorkflow.id);
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
if (!batchContext) {

View File

@ -46,6 +46,7 @@ import {
staticErrorWorkflowId,
type ErrorWorkflowProblem,
} from './error-workflow-validation.service';
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier';
import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service';
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
@ -185,6 +186,7 @@ export class WorkflowService {
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
private readonly errorWorkflowValidationService: ErrorWorkflowValidationService,
private readonly restrictedNodeTypesProvider: RestrictedNodeTypesProviderProxy,
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
) {}
/**
@ -668,6 +670,14 @@ export class WorkflowService {
);
}
if (hasNodesKey && nodesChanged) {
this.deprecatedNodesValidationService.validateOnUpdate(
workflowUpdateData.nodes,
workflow.nodes,
workflow.id,
);
}
// Strip redactionPolicy if instance lacks data-redaction license
if (
workflowUpdateData.settings?.redactionPolicy !== undefined &&

View File

@ -0,0 +1,161 @@
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
import { NodesConfig } from '@n8n/config';
import { ExecutionRepository, type IWorkflowDb } from '@n8n/db';
import { Container } from '@n8n/di';
import { NodeConnectionTypes } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
import { createOwner } from './shared/db/users';
import * as utils from './shared/utils';
import { loadNodesFromDist } from './shared/utils/node-types-data';
describe('inline sub-workflow with a deprecated node', () => {
let owner: Awaited<ReturnType<typeof createOwner>>;
let workflowExecutionService: WorkflowExecutionService;
let executionRepository: ExecutionRepository;
let nodesConfig: NodesConfig;
let previousBlockDeprecated: boolean;
beforeAll(async () => {
await testDb.init();
owner = await createOwner();
const nodeTypes = loadNodesFromDist([
'n8n-nodes-base.manualTrigger',
'n8n-nodes-base.executeWorkflow',
'n8n-nodes-base.executeWorkflowTrigger',
'n8n-nodes-base.function',
]);
await utils.initNodeTypes(nodeTypes);
await utils.initBinaryDataService();
workflowExecutionService = Container.get(WorkflowExecutionService);
executionRepository = Container.get(ExecutionRepository);
nodesConfig = Container.get(NodesConfig);
previousBlockDeprecated = nodesConfig.blockDeprecated;
});
afterEach(async () => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']);
});
afterAll(async () => {
await testDb.terminate();
});
function inlineSubWorkflowWithFunctionNode() {
return {
nodes: [
{
parameters: { inputSource: 'passthrough' },
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
id: uuid(),
name: 'Sub Trigger',
position: [0, 0],
},
{
parameters: { functionCode: "return [{ json: { ran: 'function' } }];" },
type: 'n8n-nodes-base.function',
typeVersion: 1,
id: uuid(),
name: 'Function',
position: [200, 0],
},
],
connections: {
'Sub Trigger': {
main: [[{ node: 'Function', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
};
}
function parentWithInlineSubWorkflow() {
return {
nodes: [
{
parameters: {},
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
id: uuid(),
name: 'Trigger',
position: [0, 0],
},
{
parameters: {
source: 'parameter',
workflowJson: JSON.stringify(inlineSubWorkflowWithFunctionNode()),
options: { waitForSubWorkflow: true },
},
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.2,
id: uuid(),
name: 'Execute Sub-workflow',
position: [200, 0],
},
],
connections: {
Trigger: {
main: [[{ node: 'Execute Sub-workflow', type: NodeConnectionTypes.Main, index: 0 }]],
},
},
};
}
async function runParent() {
const parent = await createWorkflow(
{ name: 'Parent', ...parentWithInlineSubWorkflow() } as unknown as IWorkflowDb,
owner,
);
const result = await workflowExecutionService.executeManually(
parent,
{ triggerToStartFrom: { name: 'Trigger' } },
owner,
);
if (!('executionId' in result)) {
throw new Error(`Expected an executionId, got ${JSON.stringify(result)}`);
}
const start = Date.now();
while (Date.now() - start < 10000) {
const execution = await executionRepository.findSingleExecution(result.executionId, {
includeData: true,
unflattenData: true,
});
if (execution && ['error', 'success'].includes(execution.status)) return execution;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`Execution ${result.executionId} did not finish within 10000ms`);
}
it('fails the parent node before the deprecated node runs when the block is on', async () => {
nodesConfig.blockDeprecated = true;
const execution = await runParent();
expect(execution.status).toBe('error');
const nodeRun = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1);
expect(nodeRun?.executionStatus).toBe('error');
expect(JSON.stringify(nodeRun?.error)).toContain('n8n-nodes-base.function');
expect(await executionRepository.count({ where: { workflowId: execution.workflowId } })).toBe(
1,
);
});
it('runs the deprecated node when the block is off', async () => {
nodesConfig.blockDeprecated = false;
const execution = await runParent();
expect(execution.status).toBe('success');
const output = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1)?.data?.main;
expect(output?.[0]?.[0]?.json).toEqual({ ran: 'function' });
});
});

View File

@ -32,23 +32,28 @@ import {
UserRepository,
WorkflowPublishedVersionRepository,
} from '@n8n/db';
import type { NodesConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import * as fastGlob from 'fast-glob';
import { Cipher } from 'n8n-core';
import type { InstanceSettings } from 'n8n-core';
import * as utils from 'n8n-workflow';
import type { INodeType } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { readFile } from 'node:fs/promises';
import type { Mock, Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { IWorkflowToImport } from '@/interfaces';
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 type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
import type { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
import { createFolder } from '@test-integration/db/folders';
@ -115,6 +120,15 @@ describe('SourceControlImportService', () => {
async (context, actor) =>
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
);
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
Object.assign(mock<INodeType>(), {
description: {
deprecated: type === 'n8n-nodes-base.function' ? true : undefined,
properties: [{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' }],
},
}),
);
service = new SourceControlImportService(
mock(),
mock(),
@ -148,6 +162,11 @@ describe('SourceControlImportService', () => {
mock(), // workflowPublishGuard
mock(), // workflowMutationHooks
Container.get(WorkflowFinderService),
new DeprecatedNodesValidationService(
mock(),
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
@ -1853,6 +1872,89 @@ describe('SourceControlImportService', () => {
});
});
describe('deprecated nodes', () => {
it('rejects pulling a new workflow that contains a deprecated node', async () => {
const importingUser = await getGlobalOwner();
const workflow = makeWorkflowImport({
nodes: [
{
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [250, 300],
parameters: { functionCode: 'return items;' },
},
] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflow.id, workflow);
await expect(
service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflow.id, file })],
importingUser.id,
),
).rejects.toThrow(DeprecatedNodesError);
await expect(workflowRepository.findOneBy({ id: workflow.id })).resolves.toBeNull();
});
const functionNode = {
id: 'node-1',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [250, 300] as [number, number],
parameters: { functionCode: 'return items;' },
};
it('allows pulling an existing workflow whose deprecated node is unchanged', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
await createWorkflowWithHistory(
{ id: workflowId, name: 'Old name', nodes: [functionNode], connections: {} },
importingUser,
);
const workflow = makeWorkflowImport({
id: workflowId,
name: 'New name',
nodes: [functionNode] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflowId, workflow);
await service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
);
const stored = await workflowRepository.findOneBy({ id: workflowId });
expect(stored?.name).toBe('New name');
});
it('rejects pulling an existing workflow whose deprecated node was edited', async () => {
const importingUser = await getGlobalOwner();
const workflowId = nanoid();
await createWorkflowWithHistory(
{ id: workflowId, name: 'Test Workflow', nodes: [functionNode], connections: {} },
importingUser,
);
const workflow = makeWorkflowImport({
id: workflowId,
nodes: [
{ ...functionNode, parameters: { functionCode: 'return [];' } },
] as IWorkflowToImport['nodes'],
});
const file = putWorkflowFile(workflowId, workflow);
await expect(
service.importWorkflowFromWorkFolder(
[mock<SourceControlledFile>({ id: workflowId, file })],
importingUser.id,
),
).rejects.toThrow(DeprecatedNodesError);
});
});
describe('workflow history', () => {
it('should create workflow history for new workflow on import', async () => {
const importingUser = await getGlobalOwner();

View File

@ -19,16 +19,20 @@ import {
UserRepository,
} from '@n8n/db';
import { Container } from '@n8n/di';
import type { NodesConfig } from '@n8n/config';
import type { ContentImportContext, PolicyViolation } from '@n8n/decorators';
import type { INode } from 'n8n-workflow';
import type { INode, INodeType } from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import type { Mocked } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
import type { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { PolicyViolationError } from '@/policy/policy-violation.error';
import { ImportService } from '@/services/import.service';
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
import type { WorkflowService } from '@/workflows/workflow.service';
import { createMember, createOwner } from './shared/db/users';
@ -85,6 +89,7 @@ describe('ImportService', () => {
mockPolicyEnforcementService,
sharedWorkflowRepository,
Container.get(WorkflowRepository),
mock(),
);
});
@ -653,4 +658,98 @@ describe('ImportService', () => {
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
});
});
describe('deprecated nodes', () => {
let blockingImportService: ImportService;
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
beforeAll(() => {
const nodeTypes = mock<NodeTypes>();
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
Object.assign(mock<INodeType>(), {
description: {
deprecated: type === deprecatedNode.type ? true : undefined,
properties: [
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' },
],
},
}),
);
blockingImportService = new ImportService(
mock(),
Container.get(CredentialsRepository),
tagRepository,
mock(),
mock(),
mockWorkflowIndexService,
mock(),
Container.get(UserRepository),
mockWorkflowService,
mockPolicyEnforcementService,
sharedWorkflowRepository,
workflowRepository,
new DeprecatedNodesValidationService(
mock(),
mock<NodesConfig>({ blockDeprecated: true }),
nodeTypes,
),
);
});
test('rejects a new workflow with a deprecated node and leaves active workflows running', async () => {
const active = await createActiveWorkflow({ name: 'Active' });
const withDeprecated = newWorkflow({ id: uuid(), nodes: [deprecatedNode] });
await expect(
blockingImportService.importWorkflows(
[active, withDeprecated],
ownerPersonalProject.id,
owner.id,
{},
),
).rejects.toThrow(DeprecatedNodesError);
await expect(getWorkflowById(withDeprecated.id)).resolves.toBeNull();
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
});
test('rejects re-importing a workflow whose deprecated node was edited', async () => {
const existing = await createWorkflow({ nodes: [deprecatedNode] });
const edited = {
...existing,
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
};
await expect(
blockingImportService.importWorkflows([edited], ownerPersonalProject.id, owner.id, {}),
).rejects.toThrow(DeprecatedNodesError);
const dbWorkflow = await getWorkflowById(existing.id);
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
});
test('allows re-importing a workflow whose deprecated node is unchanged', async () => {
const existing = await createWorkflow({ nodes: [deprecatedNode] });
await blockingImportService.importWorkflows(
[{ ...existing, name: 'Re-imported' }],
ownerPersonalProject.id,
owner.id,
{},
);
const dbWorkflow = await getWorkflowById(existing.id);
expect(dbWorkflow?.name).toBe('Re-imported');
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
});
});
});

View File

@ -10,7 +10,7 @@ import {
linkUserToProject,
shareWorkflowWithUsers,
} from '@n8n/backend-test-utils';
import { GlobalConfig } from '@n8n/config';
import { GlobalConfig, NodesConfig } from '@n8n/config';
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
import {
WorkflowRepository,
@ -4394,3 +4394,92 @@ return [{ json: result }];
expect(retrievedUpdatedNode.parameters.language).toBe('javaScript');
});
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
test('POST /workflows rejects a workflow with a deprecated node', async () => {
const response = await authOwnerAgent.post('/workflows').send({
name: 'has deprecated',
nodes: [deprecatedNode],
connections: {},
settings: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
});
test('PUT /workflows/:id rejects editing a deprecated node', async () => {
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
connections: workflow.connections,
settings: workflow.settings,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
});
test('PUT /workflows/:id allows editing other nodes while a deprecated node stays unchanged', async () => {
const otherNode: INode = {
id: uuid(),
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
};
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [deprecatedNode, { ...otherNode, notes: 'edited' }],
connections: workflow.connections,
settings: workflow.settings,
});
expect(response.statusCode).toBe(200);
});
test('PUT /workflows/:id allows removing a deprecated node', async () => {
const otherNode: INode = {
id: uuid(),
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
};
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
name: workflow.name,
nodes: [otherNode],
connections: {},
settings: workflow.settings,
});
expect(response.statusCode).toBe(200);
});
});

View File

@ -17,6 +17,8 @@ import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.creden
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
import { Function as FunctionNode } from 'n8n-nodes-base/nodes/Function/Function.node';
import { FunctionItem } from 'n8n-nodes-base/nodes/FunctionItem/FunctionItem.node';
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
@ -139,6 +141,14 @@ function buildDefaultNodes(): INodeTypeData {
type: new WebhookNode() as unknown as INodeType,
sourcePath: '',
},
'n8n-nodes-base.function': {
type: new FunctionNode(),
sourcePath: '',
},
'n8n-nodes-base.functionItem': {
type: new FunctionItem(),
sourcePath: '',
},
// Minimal mocks for node types the package-import fixtures reference at typeVersion 1.
'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'),
'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'),

View File

@ -137,6 +137,7 @@ beforeAll(async () => {
Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate
Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService
Container.get(RestrictedNodeTypesProviderProxy), // restrictedNodeTypesProvider
mock(),
);
});

View File

@ -31,6 +31,7 @@ import {
WorkflowRepository,
WorkflowPublishHistoryRepository,
} from '@n8n/db';
import { NodesConfig } from '@n8n/config';
import { Container } from '@n8n/di';
import type { Scope } from '@n8n/permissions';
import { DateTime } from 'luxon';
@ -880,6 +881,43 @@ describe('POST /workflows', () => {
expect(workflowsInDb).toHaveLength(0);
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const deprecatedNode: INode = {
id: uuid(),
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
};
test('blocks creating a workflow that contains a deprecated node', async () => {
const response = await authOwnerAgent.post('/workflows').send({
name: 'has deprecated',
nodes: [deprecatedNode],
connections: {},
settings: {},
active: false,
});
expect(response.statusCode).toBe(400);
expect(response.body.message).toMatch(/deprecated/i);
expect(response.body.meta?.violations).toEqual([
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
});
describe('Security: Mass Assignment Protection', () => {
test.each([
{
@ -4009,6 +4047,132 @@ describe('PATCH /workflows/:workflowId', () => {
expect(response.body.data.name).toBe('Updated name');
});
describe('deprecated nodes', () => {
const nodesConfig = Container.get(NodesConfig);
let previousBlockDeprecated: boolean;
beforeAll(() => {
previousBlockDeprecated = nodesConfig.blockDeprecated;
nodesConfig.blockDeprecated = true;
});
afterAll(() => {
nodesConfig.blockDeprecated = previousBlockDeprecated;
});
const buildDeprecatedNode = (overrides: Partial<INode> = {}): INode => ({
id: 'deprecated-node-id',
name: 'Function',
type: 'n8n-nodes-base.function',
typeVersion: 1,
position: [0, 0],
parameters: { functionCode: 'return items;' },
...overrides,
});
const buildCleanNode = (overrides: Partial<INode> = {}): INode => ({
id: 'clean-node-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [200, 0],
parameters: {},
...overrides,
});
test('blocks adding a deprecated node to an existing workflow', async () => {
const workflow = await createWorkflow({ nodes: [buildCleanNode()] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [buildCleanNode(), buildDeprecatedNode()],
connections: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.meta?.violations).toEqual([
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
test('blocks editing a deprecated node that already exists in the workflow', async () => {
const deprecated = buildDeprecatedNode();
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [{ ...deprecated, parameters: { functionCode: 'return [];' } }],
connections: {},
});
expect(response.statusCode).toBe(400);
expect(response.body.meta?.violations).toEqual([
{ kind: 'edited', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
]);
});
test('allows editing other nodes while a deprecated node stays unchanged', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows the editor-saved shape of a deprecated node stored with explicit defaults', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow(
{
nodes: [
{ ...deprecated, notes: '', onError: 'stopWorkflow', continueOnFail: false },
cleanNode,
],
},
owner,
);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows moving a deprecated node on the canvas', async () => {
const deprecated = buildDeprecatedNode();
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [{ ...deprecated, position: [400, 200] }],
connections: workflow.connections,
});
expect(response.statusCode).toBe(200);
});
test('allows removing a deprecated node from an existing workflow', async () => {
const deprecated = buildDeprecatedNode();
const cleanNode = buildCleanNode();
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
versionId: workflow.versionId,
nodes: [cleanNode],
connections: {},
});
expect(response.statusCode).toBe(200);
});
});
describe('Security: Mass Assignment Protection on Update', () => {
test.each([
{

View File

@ -0,0 +1,8 @@
import { Function } from './Function.node';
describe('Function Node', () => {
it('is marked deprecated so the backend refuses new instances', () => {
const node = new Function();
expect(node.description.deprecated).toBe(true);
});
});

View File

@ -22,6 +22,7 @@ export class Function implements INodeType {
displayName: 'Function',
name: 'function',
hidden: true,
deprecated: true,
icon: 'fa:code',
group: ['transform'],
version: 1,

View File

@ -0,0 +1,8 @@
import { FunctionItem } from './FunctionItem.node';
describe('FunctionItem Node', () => {
it('is marked deprecated so the backend refuses new instances', () => {
const node = new FunctionItem();
expect(node.description.deprecated).toBe(true);
});
});

View File

@ -22,6 +22,7 @@ export class FunctionItem implements INodeType {
displayName: 'Function Item',
name: 'functionItem',
hidden: true,
deprecated: true,
icon: 'fa:code',
group: ['transform'],
version: 1,

View File

@ -24,7 +24,11 @@ const TMP_PDF_DEST_DIR = path.join(BASE_TMP_DIR, 'testData', 'pdfs');
* @param options Options for `child_process.execFile`. `cwd` is typically required.
* @returns A promise that resolves with the stdout of the command, or rejects on error.
*/
async function runN8nCliCommand(command: string, args: string[], options: { cwd: string }) {
async function runN8nCliCommand(
command: string,
args: string[],
options: { cwd: string; env?: NodeJS.ProcessEnv },
) {
const packagesRoot = findPackagesRoot('cli');
const n8nExecutablePath = path.join(packagesRoot, 'cli/bin/n8n');
console.log(`Executing n8n command: n8n ${command} ${args.join(' ')}`);
@ -85,6 +89,7 @@ export async function globalWorkflowSetup(): Promise<void> {
console.log('📥 Importing test workflows...');
await runN8nCliCommand('import:workflow', ['--separate', '--input', WORKFLOWS_DIR_NAME], {
cwd: __dirname,
env: { ...process.env, N8N_DEPRECATED_NODES_BLOCK: 'false' },
});
console.log('📁 Copying test assets...');

View File

@ -1,5 +1,7 @@
import { test } from '../../../fixtures/base';
test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } });
/**
* Regression guard for AI-2505 — PDF embedding via Default Data Loader →
* In-Memory Vector Store insert was throwing

View File

@ -1,6 +1,8 @@
import { test, expect } from '../../../fixtures/base';
test.use({ capability: 'proxy' });
test.use({
capability: { services: ['proxy'], env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } },
});
test.describe(
'Langchain Integration',
{

View File

@ -3,6 +3,8 @@ import { nanoid } from 'nanoid';
import { test, expect } from '../../../fixtures/base';
test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } });
const AGENT = '@n8n/n8n-nodes-langchain.agent';
const PARSER = '@n8n/n8n-nodes-langchain.outputParserAutofixing';
const LANGCHAIN_CODE = '@n8n/n8n-nodes-langchain.code';

View File

@ -164,9 +164,9 @@ test.describe(
await expect(n8n.ndv.getNodeVersion()).toContainText('Latest');
await n8n.ndv.close();
await n8n.canvas.openNode('Function');
await n8n.canvas.openNode('Cron');
await n8n.ndv.openSettings();
await expect(n8n.ndv.getNodeVersion()).toContainText('Function node version 1');
await expect(n8n.ndv.getNodeVersion()).toContainText('Cron node version 1');
await expect(n8n.ndv.getNodeVersion()).toContainText('Deprecated');
await n8n.ndv.close();
});

View File

@ -35,10 +35,10 @@
},
{
"name": "Map each attachment",
"type": "n8n-nodes-base.function",
"type": "n8n-nodes-base.code",
"position": [620, 420],
"parameters": {
"functionCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result"
"jsCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result"
},
"typeVersion": 1
}
@ -72,7 +72,7 @@
"workflowInfo": {
"nodeCount": 3,
"nodeTypes": {
"n8n-nodes-base.function": {
"n8n-nodes-base.code": {
"count": 1
},
"n8n-nodes-base.nextCloud": {
@ -115,7 +115,7 @@
{
"id": 14,
"icon": "fa:code",
"name": "n8n-nodes-base.function",
"name": "n8n-nodes-base.code",
"defaults": {
"name": "Function",
"color": "#FF9922"

View File

@ -10,8 +10,8 @@
},
{
"id": "1ea0a87c-3395-4bd9-84fb-cf8b0f769cc4",
"name": "Function",
"type": "n8n-nodes-base.function",
"name": "Cron",
"type": "n8n-nodes-base.cron",
"typeVersion": 1,
"position": [960, 460]
},

View File

@ -2849,6 +2849,19 @@ export interface INodeTypeBaseDescription {
*/
hidden?: true;
/**
* Marks the node, or one version of it, as deprecated. When `N8N_DEPRECATED_NODES_BLOCK` is enabled, workflows cannot add or
* change a deprecated node, and workflow definitions that run without being
* saved, such as inline sub-workflows, cannot contain one. Saved workflows that
* already contain it keep running. Set this only for nodes that pose an active
* risk, such as legacy nodes with insecure sandboxing; use `hidden` for nodes
* that are only superseded.
*
* For versioned nodes, set this on the per-version description to mark only
* that version as deprecated.
*/
deprecated?: true;
/**
* Whether the node will be wrapped for tool-use by AI Agents,
* optionally replacing provided parts of the description