build: Add lint rule to ban eval and child_process calls in community nodes (#32294)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Garrit Franke
2026-06-15 09:20:55 +00:00
committed by GitHub
co-authored by Cursor
parent bb8ac5acef
commit a9310fba37
6 changed files with 284 additions and 0 deletions
@@ -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. | ✅ ☑️ | | | | |
@@ -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`.
<!-- end auto-generated rule header -->
## 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 });
```
@@ -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',
@@ -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,
@@ -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' } }],
},
],
});
@@ -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<string, string>();
// Local names bound to the whole module, e.g. `import * as cp` or `const cp = require(...)`.
const namespaceNames = new Set<string>();
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 },
});
}
},
};
},
});