mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-06 01:23:08 +00:00
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:
committed by
GitHub
parent
ca20586eda
commit
2d4115530c
@@ -33,7 +33,7 @@ function getValidator(repository: NodeRepository): WorkflowValidator {
|
||||
|
||||
// Operation types that identify nodes by nodeId/nodeName
|
||||
const NODE_TARGETING_OPERATIONS = new Set([
|
||||
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode'
|
||||
'updateNode', 'removeNode', 'moveNode', 'enableNode', 'disableNode', 'patchNodeField'
|
||||
]);
|
||||
|
||||
// Zod schema for the diff request
|
||||
@@ -47,6 +47,8 @@ const workflowDiffSchema = z.object({
|
||||
nodeId: z.string().optional(),
|
||||
nodeName: z.string().optional(),
|
||||
updates: z.any().optional(),
|
||||
fieldPath: z.string().optional(),
|
||||
patches: z.any().optional(),
|
||||
position: z.tuple([z.number(), z.number()]).optional(),
|
||||
// Connection operations
|
||||
source: z.string().optional(),
|
||||
@@ -569,6 +571,8 @@ function inferIntentFromOperations(operations: any[]): string {
|
||||
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':
|
||||
@@ -604,6 +608,10 @@ function inferIntentFromOperations(operations: any[]): string {
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
||||
name: 'n8n_update_partial_workflow',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
||||
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow, transferWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
||||
keyParameters: ['id', 'operations', 'continueOnError'],
|
||||
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
|
||||
performance: 'Fast (50-200ms)',
|
||||
@@ -27,14 +27,15 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
||||
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 18 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
||||
|
||||
## Available Operations:
|
||||
|
||||
### Node Operations (6 types):
|
||||
### Node Operations (7 types):
|
||||
- **addNode**: Add a new node with name, type, and position (required)
|
||||
- **removeNode**: Remove a node by ID or name
|
||||
- **updateNode**: Update node properties using dot notation (e.g., 'parameters.url')
|
||||
- **patchNodeField**: Surgically edit string fields using find/replace patches. Strict mode: errors if find string not found, errors if multiple matches (ambiguity) unless replaceAll is set. Supports replaceAll and regex flags.
|
||||
- **moveNode**: Change node position [x, y]
|
||||
- **enableNode**: Enable a disabled node
|
||||
- **disableNode**: Disable an active node
|
||||
@@ -335,6 +336,11 @@ n8n_update_partial_workflow({
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
|
||||
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
|
||||
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
|
||||
'\n// ============ PATCHNODEFIELD EXAMPLES (strict find/replace) ============',
|
||||
'// Surgical code edit with patchNodeField (errors if not found)\nn8n_update_partial_workflow({id: "pnf1", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const limit = 10;", replace: "const limit = 50;"}]}]})',
|
||||
'// Replace all occurrences of a string\nn8n_update_partial_workflow({id: "pnf2", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "api.old.com", replace: "api.new.com", replaceAll: true}]}]})',
|
||||
'// Multiple sequential patches\nn8n_update_partial_workflow({id: "pnf3", operations: [{type: "patchNodeField", nodeName: "Set Email", fieldPath: "parameters.assignments.assignments.6.value", patches: [{find: "© 2025 n8n-mcp", replace: "© 2026 n8n-mcp"}, {find: "<p>Unsubscribe</p>", replace: ""}]}]})',
|
||||
'// Regex-based replacement\nn8n_update_partial_workflow({id: "pnf4", operations: [{type: "patchNodeField", nodeName: "Code", fieldPath: "parameters.jsCode", patches: [{find: "const\\\\s+limit\\\\s*=\\\\s*\\\\d+", replace: "const limit = 100", regex: true}]}]})',
|
||||
'\n// ============ AI CONNECTION EXAMPLES ============',
|
||||
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
|
||||
@@ -373,7 +379,10 @@ n8n_update_partial_workflow({
|
||||
'Configure Vector Store retrieval systems',
|
||||
'Swap language models in existing AI workflows',
|
||||
'Batch-update AI tool connections',
|
||||
'Transfer workflows between team projects (enterprise)'
|
||||
'Transfer workflows between team projects (enterprise)',
|
||||
'Surgical string edits in email templates, code, or JSON bodies (patchNodeField)',
|
||||
'Fix typos or update URLs in large HTML content without re-transmitting the full string',
|
||||
'Bulk find/replace across node field content (replaceAll flag)'
|
||||
],
|
||||
performance: 'Very fast - typically 50-200ms. Much faster than full updates as only changes are processed.',
|
||||
bestPractices: [
|
||||
@@ -396,7 +405,10 @@ n8n_update_partial_workflow({
|
||||
'To remove properties, set them to null in the updates object',
|
||||
'When migrating from deprecated properties, remove the old property and add the new one in the same operation',
|
||||
'Use null to resolve mutual exclusivity validation errors between properties',
|
||||
'Batch multiple property removals in a single updateNode operation for efficiency'
|
||||
'Batch multiple property removals in a single updateNode operation for efficiency',
|
||||
'Prefer patchNodeField over __patch_find_replace for strict error handling — patchNodeField errors on not-found and detects ambiguous matches',
|
||||
'Use replaceAll: true in patchNodeField when you want to replace all occurrences of a string',
|
||||
'Use regex: true in patchNodeField for pattern-based replacements (e.g., whitespace-insensitive matching)'
|
||||
],
|
||||
pitfalls: [
|
||||
'**REQUIRES N8N_API_URL and N8N_API_KEY environment variables** - will not work without n8n API access',
|
||||
@@ -419,6 +431,9 @@ n8n_update_partial_workflow({
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
|
||||
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
|
||||
'**patchNodeField is strict**: it ERRORS if the find string is not found (unlike __patch_find_replace which only warns)',
|
||||
'**patchNodeField detects ambiguity**: if find matches multiple times, it ERRORS unless replaceAll: true is set',
|
||||
'When using regex: true in patchNodeField, escape special regex characters (., *, +, etc.) if you want literal matching',
|
||||
'To remove a property, set it to null in the updates object',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
|
||||
@@ -147,7 +147,7 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'n8n_update_partial_workflow',
|
||||
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
|
||||
description: `Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, patchNodeField, moveNode, enable/disableNode, addConnection, removeConnection, updateSettings, updateName, add/removeTag, activate/deactivateWorkflow, transferWorkflow. See tools_documentation("n8n_update_partial_workflow", "full") for details.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: true, // Allow any extra properties Claude Desktop might add
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
DeactivateWorkflowOperation,
|
||||
CleanStaleConnectionsOperation,
|
||||
ReplaceConnectionsOperation,
|
||||
TransferWorkflowOperation
|
||||
TransferWorkflowOperation,
|
||||
PatchNodeFieldOperation
|
||||
} from '../types/workflow-diff';
|
||||
import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
|
||||
import { Logger } from '../utils/logger';
|
||||
@@ -39,6 +40,55 @@ import { isActivatableTrigger } from '../utils/node-type-utils';
|
||||
|
||||
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
|
||||
|
||||
// Safety limits for patchNodeField operations
|
||||
const PATCH_LIMITS = {
|
||||
MAX_PATCHES: 50, // Max patches per operation
|
||||
MAX_REGEX_LENGTH: 500, // Max regex pattern length (chars)
|
||||
MAX_FIELD_SIZE_REGEX: 512 * 1024, // Max field size for regex operations (512KB)
|
||||
};
|
||||
|
||||
// Keys that must never appear in property paths (prototype pollution prevention)
|
||||
const DANGEROUS_PATH_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/**
|
||||
* Check if a regex pattern contains constructs known to cause catastrophic backtracking.
|
||||
* Detects nested quantifiers like (a+)+, (a*)+, (a+)*, (a|b+)+ etc.
|
||||
*/
|
||||
function isUnsafeRegex(pattern: string): boolean {
|
||||
// Detect nested quantifiers: a quantifier applied to a group that itself contains a quantifier
|
||||
// Examples: (a+)+, (a+)*, (.*)+, (\w+)*, (a|b+)+
|
||||
// This catches the most common ReDoS patterns
|
||||
const nestedQuantifier = /\([^)]*[+*][^)]*\)[+*{]/;
|
||||
if (nestedQuantifier.test(pattern)) return true;
|
||||
|
||||
// Detect overlapping alternations with quantifiers: (a|a)+, (\w|\d)+
|
||||
const overlappingAlternation = /\([^)]*\|[^)]*\)[+*{]/;
|
||||
// Only flag if alternation branches share characters (heuristic: both contain \w, ., or same literal)
|
||||
if (overlappingAlternation.test(pattern)) {
|
||||
const match = pattern.match(/\(([^)]*)\|([^)]*)\)[+*{]/);
|
||||
if (match) {
|
||||
const [, left, right] = match;
|
||||
// Flag if both branches use broad character classes
|
||||
const broadClasses = ['.', '\\w', '\\d', '\\s', '\\S', '\\W', '\\D', '[^'];
|
||||
const leftHasBroad = broadClasses.some(c => left.includes(c));
|
||||
const rightHasBroad = broadClasses.some(c => right.includes(c));
|
||||
if (leftHasBroad && rightHasBroad) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function countOccurrences(str: string, search: string): number {
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
while ((pos = str.indexOf(search, pos)) !== -1) {
|
||||
count++;
|
||||
pos += search.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Not safe for concurrent use — create a new instance per request.
|
||||
* Instance state is reset at the start of each applyDiff() call.
|
||||
@@ -79,7 +129,7 @@ export class WorkflowDiffEngine {
|
||||
const workflowCopy = JSON.parse(JSON.stringify(workflow));
|
||||
|
||||
// Group operations by type for two-pass processing
|
||||
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'];
|
||||
const nodeOperationTypes = ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'];
|
||||
const nodeOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
|
||||
const otherOperations: Array<{ operation: WorkflowDiffOperation; index: number }> = [];
|
||||
|
||||
@@ -296,6 +346,8 @@ export class WorkflowDiffEngine {
|
||||
return this.validateRemoveNode(workflow, operation);
|
||||
case 'updateNode':
|
||||
return this.validateUpdateNode(workflow, operation);
|
||||
case 'patchNodeField':
|
||||
return this.validatePatchNodeField(workflow, operation as PatchNodeFieldOperation);
|
||||
case 'moveNode':
|
||||
return this.validateMoveNode(workflow, operation);
|
||||
case 'enableNode':
|
||||
@@ -341,6 +393,9 @@ export class WorkflowDiffEngine {
|
||||
case 'updateNode':
|
||||
this.applyUpdateNode(workflow, operation);
|
||||
break;
|
||||
case 'patchNodeField':
|
||||
this.applyPatchNodeField(workflow, operation as PatchNodeFieldOperation);
|
||||
break;
|
||||
case 'moveNode':
|
||||
this.applyMoveNode(workflow, operation);
|
||||
break;
|
||||
@@ -498,6 +553,77 @@ export class WorkflowDiffEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
private validatePatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): string | null {
|
||||
if (!operation.nodeId && !operation.nodeName) {
|
||||
return `patchNodeField requires either "nodeId" or "nodeName"`;
|
||||
}
|
||||
|
||||
if (!operation.fieldPath || typeof operation.fieldPath !== 'string') {
|
||||
return `patchNodeField requires a "fieldPath" string (e.g., "parameters.jsCode")`;
|
||||
}
|
||||
|
||||
// Prototype pollution protection
|
||||
const pathSegments = operation.fieldPath.split('.');
|
||||
if (pathSegments.some(k => DANGEROUS_PATH_KEYS.has(k))) {
|
||||
return `patchNodeField: fieldPath "${operation.fieldPath}" contains a forbidden key (__proto__, constructor, or prototype)`;
|
||||
}
|
||||
|
||||
if (!Array.isArray(operation.patches) || operation.patches.length === 0) {
|
||||
return `patchNodeField requires a non-empty "patches" array of {find, replace} objects`;
|
||||
}
|
||||
|
||||
// Resource limit: max patches per operation
|
||||
if (operation.patches.length > PATCH_LIMITS.MAX_PATCHES) {
|
||||
return `patchNodeField: too many patches (${operation.patches.length}). Maximum is ${PATCH_LIMITS.MAX_PATCHES} per operation. Split into multiple operations if needed.`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < operation.patches.length; i++) {
|
||||
const patch = operation.patches[i];
|
||||
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
|
||||
return `Invalid patch entry at index ${i}: each entry must have "find" (string) and "replace" (string)`;
|
||||
}
|
||||
if (patch.find.length === 0) {
|
||||
return `Invalid patch entry at index ${i}: "find" must not be empty`;
|
||||
}
|
||||
if (patch.regex) {
|
||||
// Resource limit: max regex pattern length
|
||||
if (patch.find.length > PATCH_LIMITS.MAX_REGEX_LENGTH) {
|
||||
return `Regex pattern at patch index ${i} is too long (${patch.find.length} chars). Maximum is ${PATCH_LIMITS.MAX_REGEX_LENGTH} characters.`;
|
||||
}
|
||||
try {
|
||||
new RegExp(patch.find);
|
||||
} catch (e) {
|
||||
return `Invalid regex pattern at patch index ${i}: ${e instanceof Error ? e.message : 'invalid regex'}`;
|
||||
}
|
||||
// ReDoS protection: reject patterns with nested quantifiers
|
||||
if (isUnsafeRegex(patch.find)) {
|
||||
return `Potentially unsafe regex pattern at patch index ${i}: nested quantifiers or overlapping alternations can cause excessive backtracking. Simplify the pattern or use literal matching (regex: false).`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) {
|
||||
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'patchNodeField');
|
||||
}
|
||||
|
||||
const currentValue = this.getNestedProperty(node, operation.fieldPath);
|
||||
if (currentValue === undefined) {
|
||||
return `Cannot apply patchNodeField to "${operation.fieldPath}": property does not exist on node "${node.name}"`;
|
||||
}
|
||||
if (typeof currentValue !== 'string') {
|
||||
return `Cannot apply patchNodeField to "${operation.fieldPath}": current value is ${typeof currentValue}, expected string`;
|
||||
}
|
||||
|
||||
// Resource limit: cap field size for regex operations
|
||||
const hasRegex = operation.patches.some(p => p.regex);
|
||||
if (hasRegex && typeof currentValue === 'string' && currentValue.length > PATCH_LIMITS.MAX_FIELD_SIZE_REGEX) {
|
||||
return `Field "${operation.fieldPath}" is too large for regex operations (${Math.round(currentValue.length / 1024)}KB). Maximum is ${PATCH_LIMITS.MAX_FIELD_SIZE_REGEX / 1024}KB. Use literal matching (regex: false) for large fields.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) {
|
||||
@@ -775,10 +901,74 @@ export class WorkflowDiffEngine {
|
||||
Object.assign(node, sanitized);
|
||||
}
|
||||
|
||||
private applyPatchNodeField(workflow: Workflow, operation: PatchNodeFieldOperation): void {
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) return;
|
||||
|
||||
this.modifiedNodeIds.add(node.id);
|
||||
|
||||
let current = this.getNestedProperty(node, operation.fieldPath) as string;
|
||||
|
||||
for (let i = 0; i < operation.patches.length; i++) {
|
||||
const patch = operation.patches[i];
|
||||
|
||||
if (patch.regex) {
|
||||
const globalRegex = new RegExp(patch.find, 'g');
|
||||
const matches = current.match(globalRegex);
|
||||
|
||||
if (!matches || matches.length === 0) {
|
||||
throw new Error(
|
||||
`patchNodeField: regex pattern "${patch.find}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
|
||||
`Use n8n_get_workflow to inspect the current value.`
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.length > 1 && !patch.replaceAll) {
|
||||
throw new Error(
|
||||
`patchNodeField: regex pattern "${patch.find}" matches ${matches.length} times in "${operation.fieldPath}" (patch index ${i}). ` +
|
||||
`Set "replaceAll": true to replace all occurrences, or refine the pattern to match exactly once.`
|
||||
);
|
||||
}
|
||||
|
||||
const regex = patch.replaceAll ? globalRegex : new RegExp(patch.find);
|
||||
current = current.replace(regex, patch.replace);
|
||||
} else {
|
||||
const occurrences = countOccurrences(current, patch.find);
|
||||
|
||||
if (occurrences === 0) {
|
||||
throw new Error(
|
||||
`patchNodeField: "${patch.find.substring(0, 80)}" not found in "${operation.fieldPath}" (patch index ${i}). ` +
|
||||
`Ensure the find string exactly matches the current content (including whitespace and newlines). ` +
|
||||
`Use n8n_get_workflow to inspect the current value.`
|
||||
);
|
||||
}
|
||||
|
||||
if (occurrences > 1 && !patch.replaceAll) {
|
||||
throw new Error(
|
||||
`patchNodeField: "${patch.find.substring(0, 80)}" found ${occurrences} times in "${operation.fieldPath}" (patch index ${i}). ` +
|
||||
`Set "replaceAll": true to replace all occurrences, or use a more specific find string that matches exactly once.`
|
||||
);
|
||||
}
|
||||
|
||||
if (patch.replaceAll) {
|
||||
current = current.split(patch.find).join(patch.replace);
|
||||
} else {
|
||||
current = current.replace(patch.find, patch.replace);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.setNestedProperty(node, operation.fieldPath, current);
|
||||
|
||||
// Sanitize node after updates
|
||||
const sanitized = sanitizeNode(node);
|
||||
Object.assign(node, sanitized);
|
||||
}
|
||||
|
||||
private applyMoveNode(workflow: Workflow, operation: MoveNodeOperation): void {
|
||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||
if (!node) return;
|
||||
|
||||
|
||||
node.position = operation.position;
|
||||
}
|
||||
|
||||
@@ -1320,6 +1510,7 @@ export class WorkflowDiffEngine {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (DANGEROUS_PATH_KEYS.has(key)) return undefined;
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
@@ -1330,6 +1521,11 @@ export class WorkflowDiffEngine {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
// Prototype pollution protection
|
||||
if (keys.some(k => DANGEROUS_PATH_KEYS.has(k))) {
|
||||
throw new Error(`Invalid property path: "${path}" contains a forbidden key`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
|
||||
|
||||
@@ -55,6 +55,19 @@ export interface DisableNodeOperation extends DiffOperation {
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface PatchNodeFieldOperation extends DiffOperation {
|
||||
type: 'patchNodeField';
|
||||
nodeId?: string;
|
||||
nodeName?: string;
|
||||
fieldPath: string; // Dot-notation path, e.g. "parameters.jsCode"
|
||||
patches: Array<{
|
||||
find: string;
|
||||
replace: string;
|
||||
replaceAll?: boolean; // Default: false. Replace all occurrences.
|
||||
regex?: boolean; // Default: false. Treat find as a regex pattern.
|
||||
}>;
|
||||
}
|
||||
|
||||
// Connection Operations
|
||||
export interface AddConnectionOperation extends DiffOperation {
|
||||
type: 'addConnection';
|
||||
@@ -153,6 +166,7 @@ export type WorkflowDiffOperation =
|
||||
| AddNodeOperation
|
||||
| RemoveNodeOperation
|
||||
| UpdateNodeOperation
|
||||
| PatchNodeFieldOperation
|
||||
| MoveNodeOperation
|
||||
| EnableNodeOperation
|
||||
| DisableNodeOperation
|
||||
@@ -208,10 +222,10 @@ export interface NodeReference {
|
||||
}
|
||||
|
||||
// Utility functions type guards
|
||||
export function isNodeOperation(op: WorkflowDiffOperation): op is
|
||||
AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation |
|
||||
export function isNodeOperation(op: WorkflowDiffOperation): op is
|
||||
AddNodeOperation | RemoveNodeOperation | UpdateNodeOperation | PatchNodeFieldOperation |
|
||||
MoveNodeOperation | EnableNodeOperation | DisableNodeOperation {
|
||||
return ['addNode', 'removeNode', 'updateNode', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
|
||||
return ['addNode', 'removeNode', 'updateNode', 'patchNodeField', 'moveNode', 'enableNode', 'disableNode'].includes(op.type);
|
||||
}
|
||||
|
||||
export function isConnectionOperation(op: WorkflowDiffOperation): op is
|
||||
|
||||
Reference in New Issue
Block a user