import { normalizeItems } from 'n8n-core'; import { NodeVM, NodeVMOptions } from 'vm2'; import { ValidationError } from './ValidationError'; import { ExecutionError } from './ExecutionError'; import { CodeNodeMode, isObject, SUPPORTED_ITEM_KEYS } from './utils'; import type { IExecuteFunctions, IWorkflowDataProxyData, WorkflowExecuteMode } from 'n8n-workflow'; export class Sandbox extends NodeVM { private jsCode = ''; private itemIndex: number | undefined = undefined; constructor( context: ReturnType, workflowMode: WorkflowExecuteMode, private nodeMode: CodeNodeMode, ) { super(Sandbox.getSandboxOptions(context, workflowMode)); } static getSandboxOptions( context: ReturnType, workflowMode: WorkflowExecuteMode, ): NodeVMOptions { const { NODE_FUNCTION_ALLOW_BUILTIN: builtIn, NODE_FUNCTION_ALLOW_EXTERNAL: external } = process.env; return { console: workflowMode === 'manual' ? 'redirect' : 'inherit', sandbox: context, require: { builtin: builtIn ? builtIn.split(',') : [], external: external ? { modules: external.split(','), transitive: false } : false, }, }; } async runCode(jsCode: string, itemIndex?: number) { this.jsCode = jsCode; this.itemIndex = itemIndex; return this.nodeMode === 'runOnceForAllItems' ? this.runCodeAllItems() : this.runCodeEachItem(); } private async runCodeAllItems() { const script = `module.exports = async function() {${this.jsCode}\n}()`; const match = script.match( /(?\)\.item(?!Matching)|\$input\.item(?!Matching)|\$json|\$binary|\$itemIndex)/, ); // disallow .item but tolerate .itemMatching if (match?.groups?.disallowedSyntax) { const { disallowedSyntax } = match.groups; const lineNumber = this.jsCode.split('\n').findIndex((line) => { return line.includes(disallowedSyntax) && !line.startsWith('//') && !line.startsWith('*'); }) + 1; const disallowedSyntaxFound = lineNumber !== 0; if (disallowedSyntaxFound) { throw new ValidationError({ message: `Can't use ${disallowedSyntax} here`, description: "This is only available in 'Run Once for Each Item' mode", itemIndex: this.itemIndex, lineNumber, }); } } let executionResult; try { executionResult = await this.run(script, __dirname); } catch (error) { // anticipate user expecting `items` to pre-exist as in Function Item node if (error.message === 'items is not defined' && !/(let|const|var) items =/.test(script)) { const quoted = error.message.replace('items', '`items`'); error.message = quoted + '. Did you mean `$input.all()`?'; } throw new ExecutionError(error); } if (executionResult === null) return []; if (executionResult === undefined || typeof executionResult !== 'object') { throw new ValidationError({ message: "Code doesn't return items properly", description: 'Please return an array of objects, one for each item you would like to output', itemIndex: this.itemIndex, }); } if (Array.isArray(executionResult)) { for (const item of executionResult) { if (item.json !== undefined && !isObject(item.json)) { throw new ValidationError({ message: "A 'json' property isn't an object", description: "In the returned data, every key named 'json' must point to an object", itemIndex: this.itemIndex, }); } // If at least one top-level key is a supported item key (`json`, `binary`, etc.), // then validate all keys to be a supported item key, else allow user keys // to be wrapped in `json` when normalizing items below. if ( executionResult.some((item) => Object.keys(item).find((key) => SUPPORTED_ITEM_KEYS.has(key)), ) ) { Object.keys(item).forEach((key) => { if (SUPPORTED_ITEM_KEYS.has(key)) return; throw new ValidationError({ message: `Unknown top-level item key: ${key}`, description: 'Access the properties of an item under `.json`, e.g. `item.json`', itemIndex: this.itemIndex, }); }); } if (item.binary !== undefined && !isObject(item.binary)) { throw new ValidationError({ message: "A 'binary' property isn't an object", description: "In the returned data, every key named 'binary’ must point to an object.", itemIndex: this.itemIndex, }); } } } else { if (executionResult.json !== undefined && !isObject(executionResult.json)) { throw new ValidationError({ message: "A 'json' property isn't an object", description: "In the returned data, every key named 'json' must point to an object", itemIndex: this.itemIndex, }); } if (executionResult.binary !== undefined && !isObject(executionResult.binary)) { throw new ValidationError({ message: "A 'binary' property isn't an object", description: "In the returned data, every key named 'binary’ must point to an object.", itemIndex: this.itemIndex, }); } } return normalizeItems(executionResult); } private async runCodeEachItem() { const script = `module.exports = async function() {${this.jsCode}\n}()`; const match = this.jsCode.match(/\$input\.(?first|last|all|itemMatching)/); if (match?.groups?.disallowedMethod) { const { disallowedMethod } = match.groups; const lineNumber = this.jsCode.split('\n').findIndex((line) => { return line.includes(disallowedMethod) && !line.startsWith('//') && !line.startsWith('*'); }) + 1; const disallowedMethodFound = lineNumber !== 0; if (disallowedMethodFound) { throw new ValidationError({ message: `Can't use .${disallowedMethod}() here`, description: "This is only available in 'Run Once for All Items' mode", itemIndex: this.itemIndex, lineNumber, }); } } let executionResult; try { executionResult = await this.run(script, __dirname); } catch (error) { // anticipate user expecting `item` to pre-exist as in Function Item node if (error.message === 'item is not defined' && !/(let|const|var) item =/.test(script)) { const quoted = error.message.replace('item', '`item`'); error.message = quoted + '. Did you mean `$input.item.json`?'; } throw new ExecutionError(error, this.itemIndex); } if (executionResult === null) return; if (executionResult === undefined || typeof executionResult !== 'object') { throw new ValidationError({ message: "Code doesn't return an object", description: `Please return an object representing the output item. ('${executionResult}' was returned instead.)`, itemIndex: this.itemIndex, }); } if (executionResult.json !== undefined && !isObject(executionResult.json)) { throw new ValidationError({ message: "A 'json' property isn't an object", description: "In the returned data, every key named 'json' must point to an object", itemIndex: this.itemIndex, }); } if (executionResult.binary !== undefined && !isObject(executionResult.binary)) { throw new ValidationError({ message: "A 'binary' property isn't an object", description: "In the returned data, every key named 'binary’ must point to an object.", itemIndex: this.itemIndex, }); } // If at least one top-level key is a supported item key (`json`, `binary`, etc.), // and another top-level key is unrecognized, then the user mis-added a property // directly on the item, when they intended to add it on the `json` property Object.keys(executionResult).forEach((key) => { if (SUPPORTED_ITEM_KEYS.has(key)) return; throw new ValidationError({ message: `Unknown top-level item key: ${key}`, description: 'Access the properties of an item under `.json`, e.g. `item.json`', itemIndex: this.itemIndex, }); }); if (Array.isArray(executionResult)) { const firstSentence = executionResult.length > 0 ? `An array of ${typeof executionResult[0]}s was returned.` : 'An empty array was returned.'; throw new ValidationError({ message: "Code doesn't return a single object", description: `${firstSentence} If you need to output multiple items, please use the 'Run Once for All Items' mode instead`, itemIndex: this.itemIndex, }); } return executionResult.json ? executionResult : { json: executionResult }; } } export function getSandboxContext(this: IExecuteFunctions, index?: number) { const sandboxContext: Record & { $item: (i: number) => IWorkflowDataProxyData; $input: any; // tslint:disable-line: no-any } = { // from NodeExecuteFunctions $getNodeParameter: this.getNodeParameter, $getWorkflowStaticData: this.getWorkflowStaticData, helpers: this.helpers, // to bring in all $-prefixed vars and methods from WorkflowDataProxy $item: this.getWorkflowDataProxy, $input: null, }; // $node, $items(), $parameter, $json, $env, etc. Object.assign(sandboxContext, sandboxContext.$item(index ?? 0)); return sandboxContext; }