diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index dc2a3e8e67e..cffbfb584cf 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -60,6 +60,7 @@ export default [ | [n8n-object-validation](docs/rules/n8n-object-validation.md) | Validate the structure of the "n8n" object in community node package.json (required keys, types, and dist/ paths) | ✅ ☑️ | | | | | | [no-builder-hint-leakage](docs/rules/no-builder-hint-leakage.md) | Disallow wire-format expression syntax (={{...}}) and NodeConnectionType string literals in builderHint texts and AI-builder prompts. Use expr() and SDK-canonical references instead. | ✅ ☑️ | | | | | | [no-credential-reuse](docs/rules/no-credential-reuse.md) | Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package | ✅ ☑️ | | | 💡 | | +| [no-dangerous-functions](docs/rules/no-dangerous-functions.md) | Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes. | ✅ ☑️ | | | | | | [no-deprecated-workflow-functions](docs/rules/no-deprecated-workflow-functions.md) | Disallow usage of deprecated functions and types from n8n-workflow package | ✅ ☑️ | | | 💡 | | | [no-forbidden-lifecycle-scripts](docs/rules/no-forbidden-lifecycle-scripts.md) | Ban lifecycle scripts (prepare, preinstall, postinstall, etc.) in community node packages | ✅ ☑️ | | | | | | [no-http-request-with-manual-auth](docs/rules/no-http-request-with-manual-auth.md) | Disallow this.helpers.httpRequest() in functions that call this.getCredentials(). Use this.helpers.httpRequestWithAuthentication() instead. | ✅ ☑️ | | | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-dangerous-functions.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-dangerous-functions.md new file mode 100644 index 00000000000..aedecb34e1d --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/no-dangerous-functions.md @@ -0,0 +1,41 @@ +# Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes (`@n8n/community-nodes/no-dangerous-functions`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + + + +## Rule Details + +Community nodes run inside the n8n runtime, often on shared infrastructure. Functions that execute arbitrary code from strings or spawn operating-system processes are a primary vector for remote code execution and command injection, and have no legitimate use in a community node. This rule bans them outright: + +- **`eval(...)`** — executes arbitrary code from a string. +- **`Function(...)` / `new Function(...)`** — the `Function` constructor is an `eval` equivalent that builds a callable from a string body. +- **`child_process` process spawners** — `exec`, `execSync`, `execFile`, `execFileSync`, `spawn`, `spawnSync`, and `fork`. + +The `child_process` functions are detected only when they originate from the `child_process` / `node:child_process` module (via `import` or `require`), so unrelated methods such as `RegExp.prototype.exec` are not affected. + +This complements [`no-restricted-imports`](no-restricted-imports.md) (which blocks the `child_process` module entirely on n8n Cloud) and [`no-restricted-globals`](no-restricted-globals.md), providing a clear, specific error and defense-in-depth that also applies when the import restrictions are relaxed. + +## Examples + +### ❌ Incorrect + +```typescript +import { exec } from 'child_process'; + +eval(userProvidedCode); + +const compiled = new Function('return ' + expression); + +exec(`rm -rf ${userInput}`); +``` + +### ✅ Correct + +```typescript +// Parse data instead of evaluating it. +const value = JSON.parse(rawJson); + +// Use n8n helpers and well-scoped library APIs instead of spawning processes. +const response = await this.helpers.httpRequest({ url }); +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index 8a545223cd2..ccce4f09b89 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -30,6 +30,7 @@ const configs = { '@n8n/community-nodes/package-name-convention': 'error', '@n8n/community-nodes/credential-test-required': 'error', '@n8n/community-nodes/no-credential-reuse': 'error', + '@n8n/community-nodes/no-dangerous-functions': 'error', '@n8n/community-nodes/no-forbidden-lifecycle-scripts': 'error', '@n8n/community-nodes/no-http-request-with-manual-auth': 'error', '@n8n/community-nodes/no-overrides-field': 'error', @@ -73,6 +74,7 @@ const configs = { '@n8n/community-nodes/package-name-convention': 'error', '@n8n/community-nodes/credential-test-required': 'error', '@n8n/community-nodes/no-credential-reuse': 'error', + '@n8n/community-nodes/no-dangerous-functions': 'error', '@n8n/community-nodes/no-forbidden-lifecycle-scripts': 'error', '@n8n/community-nodes/no-http-request-with-manual-auth': 'error', '@n8n/community-nodes/no-overrides-field': 'error', diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index 8aedce9cd3f..54b17a23cb0 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -14,6 +14,7 @@ import { MissingPairedItemRule } from './missing-paired-item.js'; import { N8nObjectValidationRule } from './n8n-object-validation.js'; import { NoBuilderHintLeakageRule } from './no-builder-hint-leakage.js'; import { NoCredentialReuseRule } from './no-credential-reuse.js'; +import { NoDangerousFunctionsRule } from './no-dangerous-functions.js'; import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js'; import { NoForbiddenLifecycleScriptsRule } from './no-forbidden-lifecycle-scripts.js'; import { NoHttpRequestWithManualAuthRule } from './no-http-request-with-manual-auth.js'; @@ -50,6 +51,7 @@ export const rules = { 'package-name-convention': PackageNameConventionRule, 'credential-test-required': CredentialTestRequiredRule, 'no-credential-reuse': NoCredentialReuseRule, + 'no-dangerous-functions': NoDangerousFunctionsRule, 'no-forbidden-lifecycle-scripts': NoForbiddenLifecycleScriptsRule, 'no-http-request-with-manual-auth': NoHttpRequestWithManualAuthRule, 'no-overrides-field': NoOverridesFieldRule, diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.test.ts new file mode 100644 index 00000000000..1fd7813d388 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.test.ts @@ -0,0 +1,83 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { NoDangerousFunctionsRule } from './no-dangerous-functions.js'; + +const ruleTester = new RuleTester(); + +ruleTester.run('no-dangerous-functions', NoDangerousFunctionsRule, { + valid: [ + // `exec`/`spawn` not originating from `child_process` must not be flagged. + { name: 'regex exec', code: 'const match = /foo/.exec(input);' }, + { name: 'regex exec via variable', code: 'regex.exec(input);' }, + { name: 'unrelated exec member', code: 'db.exec("SELECT 1");' }, + { name: 'unrelated spawn member', code: 'queue.spawn(job);' }, + { name: 'locally declared exec', code: 'function exec() {} exec();' }, + // Importing without calling is fine. + { name: 'import without call', code: "import { exec } from 'child_process';" }, + // Non-dangerous members of the namespace import are fine. + { + name: 'non-dangerous namespace member', + code: "import * as cp from 'child_process'; const p = cp.execPath;", + }, + // `eval`/`Function` as identifier references (not calls) are fine. + { name: 'eval reference only', code: 'const f = eval;' }, + { name: 'Function reference only', code: 'const F = Function;' }, + // Non-child_process module is irrelevant. + { + name: 'spawn from unrelated module', + code: "import { spawn } from 'some-lib'; spawn('x');", + }, + ], + invalid: [ + { + name: 'SECURITY: eval call', + code: "eval('1 + 1');", + errors: [{ messageId: 'noEval' }], + }, + { + name: 'SECURITY: Function constructor with new', + code: "const fn = new Function('return process');", + errors: [{ messageId: 'noFunctionConstructor' }], + }, + { + name: 'SECURITY: Function constructor without new', + code: "const fn = Function('return 1');", + errors: [{ messageId: 'noFunctionConstructor' }], + }, + { + name: 'SECURITY: exec from child_process', + code: "import { exec } from 'child_process'; exec('ls');", + errors: [{ messageId: 'noChildProcess', data: { name: 'exec' } }], + }, + { + name: 'SECURITY: aliased exec from node:child_process', + code: "import { exec as run } from 'node:child_process'; run('ls');", + errors: [{ messageId: 'noChildProcess', data: { name: 'exec' } }], + }, + { + name: 'SECURITY: spawn from child_process', + code: "import { spawn } from 'child_process'; spawn('ls', ['-la']);", + errors: [{ messageId: 'noChildProcess', data: { name: 'spawn' } }], + }, + { + name: 'SECURITY: namespace execSync', + code: "import * as cp from 'child_process'; cp.execSync('ls');", + errors: [{ messageId: 'noChildProcess', data: { name: 'execSync' } }], + }, + { + name: 'SECURITY: default import spawnSync', + code: "import childProcess from 'node:child_process'; childProcess.spawnSync('ls');", + errors: [{ messageId: 'noChildProcess', data: { name: 'spawnSync' } }], + }, + { + name: 'SECURITY: destructured require execFile', + code: "const { execFile } = require('child_process'); execFile('ls');", + errors: [{ messageId: 'noChildProcess', data: { name: 'execFile' } }], + }, + { + name: 'SECURITY: namespace require fork', + code: "const cp = require('node:child_process'); cp.fork('./worker.js');", + errors: [{ messageId: 'noChildProcess', data: { name: 'fork' } }], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.ts new file mode 100644 index 00000000000..edc96caf924 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/no-dangerous-functions.ts @@ -0,0 +1,155 @@ +import { TSESTree } from '@typescript-eslint/utils'; + +import { + createRule, + getModulePath, + isDirectRequireCall, + isRequireMemberCall, +} from '../utils/index.js'; + +const { AST_NODE_TYPES } = TSESTree; + +const CHILD_PROCESS_MODULES = new Set(['child_process', 'node:child_process']); + +/** + * `child_process` functions that spawn OS processes and are therefore + * vulnerable to command injection when fed untrusted input. + */ +const DANGEROUS_CHILD_PROCESS_FUNCTIONS = new Set([ + 'exec', + 'execSync', + 'execFile', + 'execFileSync', + 'spawn', + 'spawnSync', + 'fork', +]); + +const isChildProcessModule = (node: TSESTree.Node | null): boolean => { + const modulePath = getModulePath(node); + return modulePath !== null && CHILD_PROCESS_MODULES.has(modulePath); +}; + +export const NoDangerousFunctionsRule = createRule({ + name: 'no-dangerous-functions', + meta: { + type: 'problem', + docs: { + description: + 'Disallow `eval`, the `Function` constructor, and `child_process` process-spawning functions (`exec`, `spawn`, etc.) in community nodes.', + }, + messages: { + noEval: + 'Use of `eval` is not allowed. It executes arbitrary code and is a common source of remote code execution vulnerabilities.', + noFunctionConstructor: + 'Use of the `Function` constructor is not allowed. Like `eval`, it executes arbitrary code from strings.', + noChildProcess: + 'Use of `{{ name }}` from `child_process` is not allowed. Spawning OS processes is not permitted in community nodes and can lead to command injection.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + // Local names bound to dangerous named imports, e.g. `import { exec as run }` -> `run`. + const dangerousLocalNames = new Map(); + // Local names bound to the whole module, e.g. `import * as cp` or `const cp = require(...)`. + const namespaceNames = new Set(); + + const recordDestructuredModule = (pattern: TSESTree.ObjectPattern) => { + for (const property of pattern.properties) { + if ( + property.type !== AST_NODE_TYPES.Property || + property.key.type !== AST_NODE_TYPES.Identifier || + !DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(property.key.name) + ) { + continue; + } + + if (property.value.type === AST_NODE_TYPES.Identifier) { + dangerousLocalNames.set(property.value.name, property.key.name); + } + } + }; + + return { + ImportDeclaration(node) { + if (!CHILD_PROCESS_MODULES.has(node.source.value)) return; + + for (const specifier of node.specifiers) { + if ( + specifier.type === AST_NODE_TYPES.ImportSpecifier && + specifier.imported.type === AST_NODE_TYPES.Identifier && + DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(specifier.imported.name) + ) { + dangerousLocalNames.set(specifier.local.name, specifier.imported.name); + } else if ( + specifier.type === AST_NODE_TYPES.ImportNamespaceSpecifier || + specifier.type === AST_NODE_TYPES.ImportDefaultSpecifier + ) { + namespaceNames.add(specifier.local.name); + } + } + }, + + VariableDeclarator(node) { + if ( + node.init?.type !== AST_NODE_TYPES.CallExpression || + !(isDirectRequireCall(node.init) || isRequireMemberCall(node.init)) || + !isChildProcessModule(node.init.arguments[0] ?? null) + ) { + return; + } + + if (node.id.type === AST_NODE_TYPES.ObjectPattern) { + recordDestructuredModule(node.id); + } else if (node.id.type === AST_NODE_TYPES.Identifier) { + namespaceNames.add(node.id.name); + } + }, + + NewExpression(node) { + if (node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === 'Function') { + context.report({ node, messageId: 'noFunctionConstructor' }); + } + }, + + CallExpression(node) { + const { callee } = node; + + if (callee.type === AST_NODE_TYPES.Identifier) { + if (callee.name === 'eval') { + context.report({ node, messageId: 'noEval' }); + return; + } + + if (callee.name === 'Function') { + context.report({ node, messageId: 'noFunctionConstructor' }); + return; + } + + const originalName = dangerousLocalNames.get(callee.name); + if (originalName) { + context.report({ node, messageId: 'noChildProcess', data: { name: originalName } }); + } + + return; + } + + if ( + callee.type === AST_NODE_TYPES.MemberExpression && + !callee.computed && + callee.object.type === AST_NODE_TYPES.Identifier && + namespaceNames.has(callee.object.name) && + callee.property.type === AST_NODE_TYPES.Identifier && + DANGEROUS_CHILD_PROCESS_FUNCTIONS.has(callee.property.name) + ) { + context.report({ + node, + messageId: 'noChildProcess', + data: { name: callee.property.name }, + }); + } + }, + }; + }, +});