Authenticated users can bypass the Expression sandbox mechanism to achieve full remote code execution on n8n’s main node.
Description
n8n contains a critical Remote Code Execution (RCE) vulnerability in its workflow Expression evaluation system. Expressions supplied by authenticated users during workflow configuration may be evaluated in an execution context that is not sufficiently isolated from the underlying runtime.
An authenticated attacker could abuse this behavior to execute arbitrary code with the privileges of the n8n process. Successful exploitation may lead to full compromise of the affected instance, including unauthorized access to sensitive data, modification of workflows, and execution of system-level operations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
n8nnpm | < 1.123.17 | 1.123.17 |
n8nnpm | >= 2.0.0, < 2.4.5 | 2.4.5 |
n8nnpm | >= 2.5.0, < 2.5.1 | 2.5.1 |
Affected products
1Patches
330383d86139frefactor(core): Improve expressions handling (#24688)
6 files changed · +128 −0
packages/workflow/src/errors/expression-reserved-variable.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionReservedVariableError extends ExpressionError { + constructor(variableName: string) { + super(`Cannot use "${variableName}" due to security concerns`); + } +}
packages/workflow/src/errors/expression-with-statement.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionWithStatementError extends ExpressionError { + constructor() { + super('Cannot use "with" statements due to security concerns'); + } +}
packages/workflow/src/errors/index.ts+2 −0 modified@@ -28,5 +28,7 @@ export { ExpressionExtensionError } from './expression-extension.error'; export { ExpressionDestructuringError } from './expression-destructuring.error'; export { ExpressionComputedDestructuringError } from './expression-computed-destructuring.error'; export { ExpressionClassExtensionError } from './expression-class-extension.error'; +export { ExpressionReservedVariableError } from './expression-reserved-variable.error'; +export { ExpressionWithStatementError } from './expression-with-statement.error'; export { DbConnectionTimeoutError } from './db-connection-timeout-error'; export { ensureError } from './ensure-error';
packages/workflow/src/expression-sandboxing.ts+39 −0 modified@@ -5,12 +5,18 @@ import { ExpressionComputedDestructuringError, ExpressionDestructuringError, ExpressionError, + ExpressionReservedVariableError, + ExpressionWithStatementError, } from './errors'; import { isSafeObjectProperty } from './utils'; export const sanitizerName = '__sanitize'; const sanitizerIdentifier = b.identifier(sanitizerName); +const DATA_NODE_NAME = '___n8n_data'; + +const RESERVED_VARIABLE_NAMES = new Set([DATA_NODE_NAME, sanitizerName]); + export const DOLLAR_SIGN_ERROR = 'Cannot access "$" without calling it as a function'; const EMPTY_CONTEXT = b.objectExpression([ @@ -243,6 +249,35 @@ const blockedBaseClasses = new Set([ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { astVisit(ast, { + visitVariableDeclarator(path) { + this.traverse(path); + const node = path.node; + + if (node.id.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.id.name)) { + throw new ExpressionReservedVariableError(node.id.name); + } + }, + + visitFunction(path) { + this.traverse(path); + const node = path.node; + + for (const param of node.params) { + if (param.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(param.name)) { + throw new ExpressionReservedVariableError(param.name); + } + } + }, + + visitCatchClause(path) { + this.traverse(path); + const node = path.node; + + if (node.param?.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.param.name)) { + throw new ExpressionReservedVariableError(node.param.name); + } + }, + visitClassDeclaration(path) { this.traverse(path); const node = path.node; @@ -324,6 +359,10 @@ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { } } }, + + visitWithStatement() { + throw new ExpressionWithStatementError(); + }, }); };
packages/workflow/test/expression-sandboxing.test.ts+46 −0 modified@@ -11,6 +11,7 @@ import { ExpressionClassExtensionError, ExpressionComputedDestructuringError, ExpressionDestructuringError, + ExpressionWithStatementError, } from '../src/errors'; const tournament = new Tournament( @@ -472,6 +473,51 @@ describe('PrototypeSanitizer', () => { }).toThrowError(ExpressionComputedDestructuringError); }); }); + + describe('`with` statement', () => { + it('should not allow `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({}) { return 1; } })() }}', { __sanitize: sanitizer }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow constructor access via `with` statement', () => { + expect(() => { + tournament.execute( + '{{ (function(){ var constructor = 123; with(function(){}){ return constructor("return 1")() } })() }}', + { __sanitize: sanitizer }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow RCE via with statement', () => { + expect(() => { + tournament.execute( + "{{ (function(){ var constructor = 123; with(function(){}){ return constructor(\"return process.mainModule.require('child_process').execSync('env').toString().trim()\")() } })() }}", + { + __sanitize: sanitizer, + }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow nested `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({a:1}) { with({b:2}) { return a + b; } } })() }}', { + __sanitize: sanitizer, + }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow `with` statement accessing prototype chain', () => { + expect(() => { + tournament.execute('{{ (() => { with(Object) { return getPrototypeOf({}); } })() }}', { + __sanitize: sanitizer, + Object, + }); + }).toThrowError(ExpressionWithStatementError); + }); + }); }); describe('ThisSanitizer', () => {
packages/workflow/test/expression.test.ts+27 −0 modified@@ -6,6 +6,7 @@ import { workflow } from './ExpressionExtensions/helpers'; import { baseFixtures } from './ExpressionFixtures/base'; import type { ExpressionTestEvaluation, ExpressionTestTransform } from './ExpressionFixtures/base'; import * as Helpers from './helpers'; +import { ExpressionReservedVariableError } from '../src/errors/expression-reserved-variable.error'; import { ExpressionError } from '../src/errors/expression.error'; import { extendSyntax } from '../src/extensions/expression-extension'; import type { INodeExecutionData } from '../src/interfaces'; @@ -423,6 +424,32 @@ describe('Expression', () => { expect(() => evaluate(payload)).toThrow(); }); + + it('should block `___n8n_data` shadowing attempt', () => { + const payload = `={{(() => { + const ___n8n_data = {__sanitize: a => a}; + return ({})['const'+'ructor']['const'+'ructor']('return 1')(); + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `__sanitize` variable declaration', () => { + const payload = `={{(() => { + const __sanitize = a => a; + return 1; + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `___n8n_data` as function parameter', () => { + const payload = `={{((___n8n_data) => { + return 1; + })({})}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); }); });
6 files changed · +128 −0
packages/workflow/src/errors/expression-reserved-variable.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionReservedVariableError extends ExpressionError { + constructor(variableName: string) { + super(`Cannot use "${variableName}" due to security concerns`); + } +}
packages/workflow/src/errors/expression-with-statement.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionWithStatementError extends ExpressionError { + constructor() { + super('Cannot use "with" statements due to security concerns'); + } +}
packages/workflow/src/errors/index.ts+2 −0 modified@@ -29,5 +29,7 @@ export { ExpressionExtensionError } from './expression-extension.error'; export { ExpressionDestructuringError } from './expression-destructuring.error'; export { ExpressionComputedDestructuringError } from './expression-computed-destructuring.error'; export { ExpressionClassExtensionError } from './expression-class-extension.error'; +export { ExpressionReservedVariableError } from './expression-reserved-variable.error'; +export { ExpressionWithStatementError } from './expression-with-statement.error'; export { DbConnectionTimeoutError } from './db-connection-timeout-error'; export { ensureError } from './ensure-error';
packages/workflow/src/expression-sandboxing.ts+39 −0 modified@@ -5,12 +5,18 @@ import { ExpressionComputedDestructuringError, ExpressionDestructuringError, ExpressionError, + ExpressionReservedVariableError, + ExpressionWithStatementError, } from './errors'; import { isSafeObjectProperty } from './utils'; export const sanitizerName = '__sanitize'; const sanitizerIdentifier = b.identifier(sanitizerName); +const DATA_NODE_NAME = '___n8n_data'; + +const RESERVED_VARIABLE_NAMES = new Set([DATA_NODE_NAME, sanitizerName]); + export const DOLLAR_SIGN_ERROR = 'Cannot access "$" without calling it as a function'; const EMPTY_CONTEXT = b.objectExpression([ @@ -243,6 +249,35 @@ const blockedBaseClasses = new Set([ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { astVisit(ast, { + visitVariableDeclarator(path) { + this.traverse(path); + const node = path.node; + + if (node.id.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.id.name)) { + throw new ExpressionReservedVariableError(node.id.name); + } + }, + + visitFunction(path) { + this.traverse(path); + const node = path.node; + + for (const param of node.params) { + if (param.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(param.name)) { + throw new ExpressionReservedVariableError(param.name); + } + } + }, + + visitCatchClause(path) { + this.traverse(path); + const node = path.node; + + if (node.param?.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.param.name)) { + throw new ExpressionReservedVariableError(node.param.name); + } + }, + visitClassDeclaration(path) { this.traverse(path); const node = path.node; @@ -324,6 +359,10 @@ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { } } }, + + visitWithStatement() { + throw new ExpressionWithStatementError(); + }, }); };
packages/workflow/test/expression-sandboxing.test.ts+46 −0 modified@@ -11,6 +11,7 @@ import { ExpressionClassExtensionError, ExpressionComputedDestructuringError, ExpressionDestructuringError, + ExpressionWithStatementError, } from '../src/errors'; const tournament = new Tournament( @@ -472,6 +473,51 @@ describe('PrototypeSanitizer', () => { }).toThrowError(ExpressionComputedDestructuringError); }); }); + + describe('`with` statement', () => { + it('should not allow `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({}) { return 1; } })() }}', { __sanitize: sanitizer }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow constructor access via `with` statement', () => { + expect(() => { + tournament.execute( + '{{ (function(){ var constructor = 123; with(function(){}){ return constructor("return 1")() } })() }}', + { __sanitize: sanitizer }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow RCE via with statement', () => { + expect(() => { + tournament.execute( + "{{ (function(){ var constructor = 123; with(function(){}){ return constructor(\"return process.mainModule.require('child_process').execSync('env').toString().trim()\")() } })() }}", + { + __sanitize: sanitizer, + }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow nested `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({a:1}) { with({b:2}) { return a + b; } } })() }}', { + __sanitize: sanitizer, + }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow `with` statement accessing prototype chain', () => { + expect(() => { + tournament.execute('{{ (() => { with(Object) { return getPrototypeOf({}); } })() }}', { + __sanitize: sanitizer, + Object, + }); + }).toThrowError(ExpressionWithStatementError); + }); + }); }); describe('ThisSanitizer', () => {
packages/workflow/test/expression.test.ts+27 −0 modified@@ -6,6 +6,7 @@ import { workflow } from './ExpressionExtensions/helpers'; import { baseFixtures } from './ExpressionFixtures/base'; import type { ExpressionTestEvaluation, ExpressionTestTransform } from './ExpressionFixtures/base'; import * as Helpers from './helpers'; +import { ExpressionReservedVariableError } from '../src/errors/expression-reserved-variable.error'; import { ExpressionError } from '../src/errors/expression.error'; import { extendSyntax } from '../src/extensions/expression-extension'; import type { INodeExecutionData } from '../src/interfaces'; @@ -423,6 +424,32 @@ describe('Expression', () => { expect(() => evaluate(payload)).toThrow(); }); + + it('should block `___n8n_data` shadowing attempt', () => { + const payload = `={{(() => { + const ___n8n_data = {__sanitize: a => a}; + return ({})['const'+'ructor']['const'+'ructor']('return 1')(); + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `__sanitize` variable declaration', () => { + const payload = `={{(() => { + const __sanitize = a => a; + return 1; + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `___n8n_data` as function parameter', () => { + const payload = `={{((___n8n_data) => { + return 1; + })({})}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); }); });
6 files changed · +128 −0
packages/workflow/src/errors/expression-reserved-variable.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionReservedVariableError extends ExpressionError { + constructor(variableName: string) { + super(`Cannot use "${variableName}" due to security concerns`); + } +}
packages/workflow/src/errors/expression-with-statement.error.ts+7 −0 added@@ -0,0 +1,7 @@ +import { ExpressionError } from './expression.error'; + +export class ExpressionWithStatementError extends ExpressionError { + constructor() { + super('Cannot use "with" statements due to security concerns'); + } +}
packages/workflow/src/errors/index.ts+2 −0 modified@@ -29,5 +29,7 @@ export { ExpressionExtensionError } from './expression-extension.error'; export { ExpressionDestructuringError } from './expression-destructuring.error'; export { ExpressionComputedDestructuringError } from './expression-computed-destructuring.error'; export { ExpressionClassExtensionError } from './expression-class-extension.error'; +export { ExpressionReservedVariableError } from './expression-reserved-variable.error'; +export { ExpressionWithStatementError } from './expression-with-statement.error'; export { DbConnectionTimeoutError } from './db-connection-timeout-error'; export { ensureError } from './ensure-error';
packages/workflow/src/expression-sandboxing.ts+39 −0 modified@@ -5,12 +5,18 @@ import { ExpressionComputedDestructuringError, ExpressionDestructuringError, ExpressionError, + ExpressionReservedVariableError, + ExpressionWithStatementError, } from './errors'; import { isSafeObjectProperty } from './utils'; export const sanitizerName = '__sanitize'; const sanitizerIdentifier = b.identifier(sanitizerName); +const DATA_NODE_NAME = '___n8n_data'; + +const RESERVED_VARIABLE_NAMES = new Set([DATA_NODE_NAME, sanitizerName]); + export const DOLLAR_SIGN_ERROR = 'Cannot access "$" without calling it as a function'; const EMPTY_CONTEXT = b.objectExpression([ @@ -243,6 +249,35 @@ const blockedBaseClasses = new Set([ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { astVisit(ast, { + visitVariableDeclarator(path) { + this.traverse(path); + const node = path.node; + + if (node.id.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.id.name)) { + throw new ExpressionReservedVariableError(node.id.name); + } + }, + + visitFunction(path) { + this.traverse(path); + const node = path.node; + + for (const param of node.params) { + if (param.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(param.name)) { + throw new ExpressionReservedVariableError(param.name); + } + } + }, + + visitCatchClause(path) { + this.traverse(path); + const node = path.node; + + if (node.param?.type === 'Identifier' && RESERVED_VARIABLE_NAMES.has(node.param.name)) { + throw new ExpressionReservedVariableError(node.param.name); + } + }, + visitClassDeclaration(path) { this.traverse(path); const node = path.node; @@ -324,6 +359,10 @@ export const PrototypeSanitizer: ASTAfterHook = (ast, dataNode) => { } } }, + + visitWithStatement() { + throw new ExpressionWithStatementError(); + }, }); };
packages/workflow/test/expression-sandboxing.test.ts+46 −0 modified@@ -11,6 +11,7 @@ import { ExpressionClassExtensionError, ExpressionComputedDestructuringError, ExpressionDestructuringError, + ExpressionWithStatementError, } from '../src/errors'; const tournament = new Tournament( @@ -472,6 +473,51 @@ describe('PrototypeSanitizer', () => { }).toThrowError(ExpressionComputedDestructuringError); }); }); + + describe('`with` statement', () => { + it('should not allow `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({}) { return 1; } })() }}', { __sanitize: sanitizer }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow constructor access via `with` statement', () => { + expect(() => { + tournament.execute( + '{{ (function(){ var constructor = 123; with(function(){}){ return constructor("return 1")() } })() }}', + { __sanitize: sanitizer }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow RCE via with statement', () => { + expect(() => { + tournament.execute( + "{{ (function(){ var constructor = 123; with(function(){}){ return constructor(\"return process.mainModule.require('child_process').execSync('env').toString().trim()\")() } })() }}", + { + __sanitize: sanitizer, + }, + ); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow nested `with` statements', () => { + expect(() => { + tournament.execute('{{ (() => { with({a:1}) { with({b:2}) { return a + b; } } })() }}', { + __sanitize: sanitizer, + }); + }).toThrowError(ExpressionWithStatementError); + }); + + it('should not allow `with` statement accessing prototype chain', () => { + expect(() => { + tournament.execute('{{ (() => { with(Object) { return getPrototypeOf({}); } })() }}', { + __sanitize: sanitizer, + Object, + }); + }).toThrowError(ExpressionWithStatementError); + }); + }); }); describe('ThisSanitizer', () => {
packages/workflow/test/expression.test.ts+27 −0 modified@@ -6,6 +6,7 @@ import { workflow } from './ExpressionExtensions/helpers'; import { baseFixtures } from './ExpressionFixtures/base'; import type { ExpressionTestEvaluation, ExpressionTestTransform } from './ExpressionFixtures/base'; import * as Helpers from './helpers'; +import { ExpressionReservedVariableError } from '../src/errors/expression-reserved-variable.error'; import { ExpressionError } from '../src/errors/expression.error'; import { extendSyntax } from '../src/extensions/expression-extension'; import type { INodeExecutionData } from '../src/interfaces'; @@ -423,6 +424,32 @@ describe('Expression', () => { expect(() => evaluate(payload)).toThrow(); }); + + it('should block `___n8n_data` shadowing attempt', () => { + const payload = `={{(() => { + const ___n8n_data = {__sanitize: a => a}; + return ({})['const'+'ructor']['const'+'ructor']('return 1')(); + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `__sanitize` variable declaration', () => { + const payload = `={{(() => { + const __sanitize = a => a; + return 1; + })()}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); + + it('should block `___n8n_data` as function parameter', () => { + const payload = `={{((___n8n_data) => { + return 1; + })({})}}`; + + expect(() => evaluate(payload)).toThrow(ExpressionReservedVariableError); + }); }); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/n8n-io/n8n/commit/aa4d1e5825829182afa0ad5b81f602638f55fa04ghsapatchWEB
- github.com/advisories/GHSA-5xrp-6693-jjx9ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-1470ghsaADVISORY
- research.jfrog.com/vulnerabilities/n8n-expression-node-rce/mitrethird-party-advisory
- github.com/n8n-io/n8n/commit/25c4b9605b420a98d0185a4f01115122a5134d8fghsaWEB
- github.com/n8n-io/n8n/commit/30383d86139f3279a698df8d229eadfefe8627f4ghsaWEB
- research.jfrog.com/vulnerabilities/n8n-expression-node-rceghsaWEB
News mentions
0No linked articles in our index yet.