feat(core)!: Introduce insecure mode in task runner (#16911)

This commit is contained in:
Iván Ovejero
2025-07-04 08:32:49 +02:00
committed by GitHub
parent 60e78a4fec
commit 7317f67797
10 changed files with 107 additions and 40 deletions

View File

@@ -8,13 +8,6 @@ export class JsRunnerConfig {
@Env('NODE_FUNCTION_ALLOW_EXTERNAL')
allowedExternalModules: string = '';
/**
* Whether to allow prototype mutation for external libraries. Set to `true`
* to allow modules that rely on runtime prototype mutation, e.g. `puppeteer`,
* at the cost of security.
*
* @default false
*/
@Env('N8N_RUNNERS_ALLOW_PROTOTYPE_MUTATION')
allowPrototypeMutation: boolean = false;
@Env('N8N_RUNNERS_INSECURE_MODE')
insecureMode: boolean = false;
}

View File

@@ -38,7 +38,7 @@ const defaultConfig = new MainConfig();
defaultConfig.jsRunnerConfig ??= {
allowedBuiltInModules: '',
allowedExternalModules: '',
allowPrototypeMutation: true, // needed for jest
insecureMode: false,
};
describe('JsTaskRunner', () => {
@@ -1455,9 +1455,9 @@ describe('JsTaskRunner', () => {
expect(Duration.fromObject({ hours: 1 }).maliciousKey).toBeUndefined();
});
it('should allow prototype mutation when `allowPrototypeMutation` is true', async () => {
it('should allow prototype mutation when `insecureMode` is true', async () => {
const runner = createRunnerWithOpts({
allowPrototypeMutation: true,
insecureMode: true,
});
const outcome = await executeForAllItems({

View File

@@ -30,15 +30,15 @@ import { type Context, createContext, runInContext } from 'node:vm';
import type { MainConfig } from '@/config/main-config';
import { UnsupportedFunctionError } from '@/js-task-runner/errors/unsupported-function.error';
import { EXPOSED_RPC_METHODS, UNSUPPORTED_HELPER_FUNCTIONS } from '@/runner-types';
import type {
DataRequestResponse,
InputDataChunkDefinition,
PartialAdditionalData,
TaskResultData,
} from '@/runner-types';
import type { TaskParams } from '@/task-runner';
import { EXPOSED_RPC_METHODS, UNSUPPORTED_HELPER_FUNCTIONS } from '@/runner-types';
import { noOp, TaskRunner } from '@/task-runner';
import type { TaskParams } from '@/task-runner';
import { BuiltInsParser } from './built-ins-parser/built-ins-parser';
import { BuiltInsParserState } from './built-ins-parser/built-ins-parser-state';
@@ -95,6 +95,8 @@ export class JsTaskRunner extends TaskRunner {
private readonly taskDataReconstruct = new DataRequestResponseReconstruct();
private readonly mode: 'secure' | 'insecure' = 'secure';
constructor(config: MainConfig, name = 'JS Task Runner') {
super({
taskType: 'javascript',
@@ -117,19 +119,17 @@ export class JsTaskRunner extends TaskRunner {
const allowedExternalModules = parseModuleAllowList(
jsRunnerConfig.allowedExternalModules ?? '',
);
this.mode = jsRunnerConfig.insecureMode ? 'insecure' : 'secure';
this.requireResolver = createRequireResolver({
allowedBuiltInModules,
allowedExternalModules,
});
this.preventPrototypePollution(allowedExternalModules, jsRunnerConfig.allowPrototypeMutation);
if (this.mode === 'secure') this.preventPrototypePollution(allowedExternalModules);
}
private preventPrototypePollution(
allowedExternalModules: Set<string> | '*',
allowPrototypeMutation: boolean,
) {
private preventPrototypePollution(allowedExternalModules: Set<string> | '*') {
if (allowedExternalModules instanceof Set) {
// This is a workaround to enable the allowed external libraries to mutate
// prototypes directly. For example momentjs overrides .toString() directly
@@ -141,11 +141,11 @@ export class JsTaskRunner extends TaskRunner {
}
}
// Freeze globals if needed
if (!allowPrototypeMutation) {
// 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, @typescript-eslint/no-unsafe-member-access
// 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
@@ -256,9 +256,15 @@ export class JsTaskRunner extends TaskRunner {
signal.addEventListener('abort', abortHandler, { once: true });
const taskResult = runInContext(this.createVmExecutableCode(settings.code), context, {
timeout: this.taskTimeout * 1000,
}) as Promise<TaskResultData['result']>;
let taskResult: Promise<TaskResultData['result']>;
if (this.mode === 'secure') {
taskResult = runInContext(this.createVmExecutableCode(settings.code), context, {
timeout: this.taskTimeout * 1000,
}) as Promise<TaskResultData['result']>;
} else {
taskResult = this.runDirectly<TaskResultData['result']>(settings.code, context);
}
void taskResult
.then(resolve)
@@ -319,9 +325,15 @@ export class JsTaskRunner extends TaskRunner {
signal.addEventListener('abort', abortHandler);
const taskResult = runInContext(this.createVmExecutableCode(settings.code), context, {
timeout: this.taskTimeout * 1000,
}) as Promise<INodeExecutionData>;
let taskResult: Promise<INodeExecutionData>;
if (this.mode === 'secure') {
taskResult = runInContext(this.createVmExecutableCode(settings.code), context, {
timeout: this.taskTimeout * 1000,
}) as Promise<INodeExecutionData>;
} else {
taskResult = this.runDirectly<INodeExecutionData>(settings.code, context);
}
void taskResult
.then(resolve)
@@ -551,4 +563,14 @@ export class JsTaskRunner extends TaskRunner {
`module.exports = async function VmCodeWrapper() {${code}\n}()`,
].join('; ');
}
private async runDirectly<T>(code: string, context: Context): Promise<T> {
// eslint-disable-next-line @typescript-eslint/no-implied-eval
const fn = new Function(
'context',
`with(context) { return (async function() {${code}\n})(); }`,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
return await fn(context);
}
}