feat: add patchNodeField operation for surgical string edits (v2.46.0) (#698)

Add dedicated `patchNodeField` operation to `n8n_update_partial_workflow`
for surgical find/replace edits in node string fields. Strict alternative
to the existing `__patch_find_replace` in updateNode — errors on not-found,
detects ambiguous matches, supports replaceAll and regex flags.

Security hardening:
- Prototype pollution protection in setNestedProperty/getNestedProperty
- ReDoS protection rejecting unsafe regex patterns (nested quantifiers)
- Resource limits: max 50 patches, 500-char regex, 512KB field size

Fixes #696

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Romuald Członkowski
2026-04-03 09:53:44 +02:00
committed by GitHub
parent ca20586eda
commit 2d4115530c
28 changed files with 1019 additions and 37 deletions

View File

@@ -51,7 +51,7 @@ function getValidator(repository) {
return cachedValidator;
}
const NODE_TARGETING_OPERATIONS = new Set([
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode'
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode', 'patchNodeField'
]);
const workflowDiffSchema = zod_1.z.object({
id: zod_1.z.string(),
@@ -62,6 +62,8 @@ const workflowDiffSchema = zod_1.z.object({
nodeId: zod_1.z.string().optional(),
nodeName: zod_1.z.string().optional(),
updates: zod_1.z.any().optional(),
fieldPath: zod_1.z.string().optional(),
patches: zod_1.z.any().optional(),
position: zod_1.z.tuple([zod_1.z.number(), zod_1.z.number()]).optional(),
source: zod_1.z.string().optional(),
target: zod_1.z.string().optional(),
@@ -506,6 +508,8 @@ function inferIntentFromOperations(operations) {
return `Remove node ${op.nodeName || op.nodeId || ''}`.trim();
case 'updateNode':
return `Update node ${op.nodeName || op.nodeId || ''}`.trim();
case 'patchNodeField':
return `Patch field on node ${op.nodeName || op.nodeId || ''}`.trim();
case 'addConnection':
return `Connect ${op.source || 'node'} to ${op.target || 'node'}`;
case 'removeConnection':
@@ -538,6 +542,10 @@ function inferIntentFromOperations(operations) {
const count = opTypes.filter((t) => t === 'updateNode').length;
summary.push(`update ${count} node${count > 1 ? 's' : ''}`);
}
if (typeSet.has('patchNodeField')) {
const count = opTypes.filter((t) => t === 'patchNodeField').length;
summary.push(`patch ${count} field${count > 1 ? 's' : ''}`);
}
if (typeSet.has('addConnection') || typeSet.has('rewireConnection')) {
summary.push('modify connections');
}