Root cause
Injection & unsafe execution
chore: Bundle 2026-W7 (#26214)
AI-assisted change: GitHub Copilot
How AI contributed: Flawed AI-written codePublished Aug 5, 2026 in
n8n-io/n8nAll 6 findings in this repository
The JavaScript Code node executes user code in the task-runner Node VM with host objects injected into the sandbox. $input.constructor.constructor.prototype is the host Function.prototype. The freeze loop only froze prototypes with typeof === 'object', but typeof Function.prototype === 'function', so that prototype stayed mutable. An authenticated user who can create and execute workflows can pollute Function.prototype, recover host globalThis, and complete a sandbox escape when allowlisted imports (NODE_FUNCTION_ALLOW_BUILTIN / NODE_FUNCTION_ALLOW_EXTERNAL) supply gadgets. CWE-1321. Patched in 1.123.69, 2.33.4, and 2.34.1 by freezing function-typed prototypes as well (isFreezable = object or function).
Root cause
chore: Bundle 2026-W7 (#26214)
AI-assisted change: GitHub Copilot
Fix
Freezes both object- and function-valued global prototypes and locks key EventEmitter methods against reassignment before sandboxed code runs, closing mutations of Function.prototype and process event methods.
Fix by n8n-assistant[bot] · no AI marker found
Case facts
@@ -94,11 +94,40 @@ export interface JsTaskData { additionalData: PartialAdditionalData; } +type GlobalFunctionWithPrototype = ((...args: unknown[]) => unknown) & {+ prototype?: object;+};+ type CustomConsole = { log: (...args: unknown[]) => void; }; export class JsTaskRunner extends TaskRunner {+ private static readonly CONSOLE_METHODS = [+ 'log',+ 'warn',+ 'error',+ 'info',+ 'debug',+ 'trace',+ 'dir',+ 'time',+ 'timeEnd',+ 'timeLog',+ 'assert',+ 'clear',+ 'count',+ 'countReset',+ 'group',+ 'groupEnd',+ 'groupCollapsed',+ 'table',+ 'dirxml',+ 'profile',+ 'profileEnd',+ 'timeStamp',+ ] as const;+ private readonly requireResolver: RequireResolver; private readonly builtInsParser = new BuiltInsParser();@@ -161,15 +190,22 @@ export class JsTaskRunner extends TaskRunner { } } + // Overwrite unsafe Buffer allocations on the real constructor+ const safeAlloc = Buffer.alloc.bind(Buffer);+ Buffer.allocUnsafe = safeAlloc as typeof Buffer.allocUnsafe;+ Buffer.allocUnsafeSlow = safeAlloc as typeof Buffer.allocUnsafeSlow;+ // Freeze globals, except in tests because Jest needs to be able to mutate prototypes if (process.env.NODE_ENV !== 'test') { Object.getOwnPropertyNames(globalThis)- // @ts-expect-error globalThis does not have string in index signature- // eslint-disable-next-line @typescript-eslint/no-unsafe-return- .map((name) => globalThis[name])- .filter((value) => typeof value === 'function')- // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access- .forEach((fn) => Object.freeze(fn.prototype));+ .map((name) => Reflect.get(globalThis, name) as unknown)+ .filter((value): value is GlobalFunctionWithPrototype => typeof value === 'function')+ .forEach((fn) => {+ if (typeof fn.prototype === 'object') Object.freeze(fn.prototype);+ Object.freeze(fn);+ });++ [Reflect, JSON, Math].forEach(Object.freeze); } // Freeze internal classes@@ -239,19 +275,9 @@ export class JsTaskRunner extends TaskRunner { } private getNativeVariables() {- const { mode } = this; return { // Exposed Node.js globals- Buffer: new Proxy(Buffer, {- get(target, prop) {- if (mode === 'insecure') return target[prop as keyof typeof Buffer];- if (prop === 'allocUnsafe' || prop === 'allocUnsafeSlow') {- // eslint-disable-next-line @typescript-eslint/unbound-method- return Buffer.alloc;- }- return target[prop as keyof typeof Buffer];- },- }),+ Buffer, setTimeout, setInterval,@@ -0,0 +1,66 @@+import { EventEmitter } from 'node:events';++type FunctionWithPrototype = ((...args: unknown[]) => unknown) & {+ prototype?: object;+};++const isFreezable = (value: unknown): value is object =>+ typeof value === 'object' || typeof value === 'function';++/** Freeze a function and its prototype. */+const freezeFunctionAndPrototype = (fn: FunctionWithPrototype) => {+ if (isFreezable(fn.prototype)) {+ Object.freeze(fn.prototype);+ }+ Object.freeze(fn);+};++/**+ * `EventEmitter.prototype` members that user code must not override.+ * `process` inherits these from `EventEmitter.prototype`,+ * which lives in an internal module and never appears on `globalThis`,+ * so the global freeze misses it.+ */+const PROTECTED_EVENT_EMITTER_METHODS = [+ 'emit',+ 'on',+ 'once',+ 'addListener',+ 'prependListener',+ 'prependOnceListener',+] as const;++/**+ * Lock {@link PROTECTED_EVENT_EMITTER_METHODS} against reassignment.+ * Locked individually rather than by freezing the prototype,+ * because Node lazily copies these names onto stream subclass prototypes (`net`, `tls`, `http`, etc)+ * and a wholesale freeze breaks those assignments.+ * Kept enumerable to match Node's default, so hiding them from `for...in`,+ * `Object.keys` and spreads does not alter every emitter in the process.+ */+const lockEventEmitterMethods = () => {+ for (const method of PROTECTED_EVENT_EMITTER_METHODS) {+ Object.defineProperty(EventEmitter.prototype, method, {+ value: EventEmitter.prototype[method],+ writable: false,+ configurable: false,+ enumerable: true,+ });+ }+};++/**+ * Freeze the globals and shared prototypes reachable from sandboxed code so it+ * cannot mutate them to escape secure mode.+ * Skip in tests, where Vitest needs to mutate prototypes.+ */+export const freezeGlobals = () => {+ Object.getOwnPropertyNames(globalThis)+ .map((name) => Reflect.get(globalThis, name) as unknown)+ .filter((value): value is FunctionWithPrototype => typeof value === 'function')+ .forEach(freezeFunctionAndPrototype);++ [Reflect, JSON, Math].forEach(Object.freeze);++ lockEventEmitterMethods();+};@@ -40,6 +40,7 @@ import { isErrorLike } from './errors/error-like'; import { ExecutionError } from './errors/execution-error'; import { makeSerializable } from './errors/serializable-error'; import { TimeoutError } from './errors/timeout-error';+import { freezeGlobals } from './prototype-hardening'; import type { RequireResolver } from './require-resolver'; import { createRequireResolver } from './require-resolver'; import { DataRequestResponseReconstruct } from '../data-request/data-request-response-reconstruct';@@ -88,10 +89,6 @@ export interface JsTaskData { additionalData: PartialAdditionalData; } -type GlobalFunctionWithPrototype = ((...args: unknown[]) => unknown) & {- prototype?: object;-};- type CustomConsole = { log: (...args: unknown[]) => void; };@@ -190,17 +187,8 @@ export class JsTaskRunner extends TaskRunner { Buffer.allocUnsafe = safeAlloc as typeof Buffer.allocUnsafe; Buffer.allocUnsafeSlow = safeAlloc as typeof Buffer.allocUnsafeSlow; - // Freeze globals, except in tests because Vitest needs to be able to mutate prototypes if (process.env.NODE_ENV !== 'test') {- Object.getOwnPropertyNames(globalThis)- .map((name) => Reflect.get(globalThis, name) as unknown)- .filter((value): value is GlobalFunctionWithPrototype => typeof value === 'function')- .forEach((fn) => {- if (typeof fn.prototype === 'object') Object.freeze(fn.prototype);- Object.freeze(fn);- });-- [Reflect, JSON, Math].forEach(Object.freeze);+ freezeGlobals(); } // Freeze internal classesAI-assisted change 6c51f95945652eeb57507c5e018dfe7cc2ccae4af61c72c1e3ebb8c9115f2205 · Fix a4c252793a46f40c63b0ada5e8199dfd9484a372808a87395149b49862a6c00b