mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
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:
@@ -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 },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user