feat(core): Add Tournament as the new default expression evaluator (#6964)

Github issue / Community forum post (link here to close automatically):

---------

Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in>
This commit is contained in:
Val
2023-09-21 13:57:45 +01:00
committed by GitHub
parent 67b985fe89
commit bf74f09d69
12 changed files with 434 additions and 223 deletions

View File

@@ -48,6 +48,7 @@
"@types/xml2js": "^0.4.11"
},
"dependencies": {
"@n8n/tournament": "^1.0.2",
"@n8n_io/riot-tmpl": "^4.0.0",
"ast-types": "0.15.2",
"crypto-js": "^4.1.1",

View File

@@ -1,5 +1,5 @@
import * as tmpl from '@n8n_io/riot-tmpl';
import { DateTime, Duration, Interval } from 'luxon';
import * as tmpl from '@n8n_io/riot-tmpl';
import type {
IDataObject,
@@ -22,6 +22,7 @@ import type { Workflow } from './Workflow';
import { extend, extendOptional } from './Extensions';
import { extendedFunctions } from './Extensions/ExtendedFunctions';
import { extendSyntax } from './Extensions/ExpressionExtension';
import { evaluateExpression, setErrorHandler } from './ExpressionEvaluatorProxy';
const IS_FRONTEND_IN_DEV_MODE =
typeof process === 'object' &&
@@ -40,13 +41,10 @@ export const isExpressionError = (error: unknown): error is ExpressionError =>
export const isTypeError = (error: unknown): error is TypeError =>
error instanceof TypeError || (error instanceof Error && error.name === 'TypeError');
// Set it to use double curly brackets instead of single ones
tmpl.brackets.set('{{ }}');
// Make sure that error get forwarded
tmpl.tmpl.errorHandler = (error: Error) => {
setErrorHandler((error: Error) => {
if (isExpressionError(error)) throw error;
};
});
// eslint-disable-next-line @typescript-eslint/naming-convention
const AsyncFunction = (async () => {}).constructor as FunctionConstructor;
@@ -339,7 +337,7 @@ export class Expression {
[Function, AsyncFunction].forEach(({ prototype }) =>
Object.defineProperty(prototype, 'constructor', { value: fnConstructors.mock }),
);
return tmpl.tmpl(expression, data);
return evaluateExpression(expression, data);
} catch (error) {
if (isExpressionError(error)) throw error;

View File

@@ -0,0 +1,149 @@
import * as tmpl from '@n8n_io/riot-tmpl';
import type { ReturnValue, TmplDifference } from '@n8n/tournament';
import { Tournament } from '@n8n/tournament';
import type { ExpressionEvaluatorType } from './Interfaces';
import * as LoggerProxy from './LoggerProxy';
type Evaluator = (expr: string, data: unknown) => tmpl.ReturnValue;
type ErrorHandler = (error: Error) => void;
type DifferenceHandler = (expr: string) => void;
// Set it to use double curly brackets instead of single ones
tmpl.brackets.set('{{ }}');
let errorHandler: ErrorHandler = () => {};
let differenceHandler: DifferenceHandler = () => {};
const differenceChecker = (diff: TmplDifference) => {
try {
if (diff.same) {
return;
}
if (diff.has?.function || diff.has?.templateString) {
return;
}
if (diff.expression === 'UNPARSEABLE') {
differenceHandler(diff.expression);
} else {
differenceHandler(diff.expression.value);
}
} catch {
LoggerProxy.error('Expression evaluator difference checker failed');
}
};
const tournamentEvaluator = new Tournament(errorHandler, undefined);
let evaluator: Evaluator = tmpl.tmpl;
let currentEvaluatorType: ExpressionEvaluatorType = 'tmpl';
let diffExpressions = false;
export const setErrorHandler = (handler: ErrorHandler) => {
errorHandler = handler;
tmpl.tmpl.errorHandler = handler;
tournamentEvaluator.errorHandler = handler;
};
export const setEvaluator = (evalType: ExpressionEvaluatorType) => {
currentEvaluatorType = evalType;
if (evalType === 'tmpl') {
evaluator = tmpl.tmpl;
} else if (evalType === 'tournament') {
evaluator = tournamentEvaluator.execute.bind(tournamentEvaluator);
}
};
export const setDiffReporter = (reporter: (expr: string) => void) => {
differenceHandler = reporter;
};
export const setDifferEnabled = (enabled: boolean) => {
diffExpressions = enabled;
};
const diffCache: Record<string, TmplDifference | null> = {};
export const checkEvaluatorDifferences = (expr: string): TmplDifference | null => {
if (expr in diffCache) {
return diffCache[expr];
}
let diff: TmplDifference | null;
try {
diff = tournamentEvaluator.tmplDiff(expr);
} catch {
// We don't include the expression for privacy reasons
try {
differenceHandler('ERROR');
} catch {}
diff = null;
}
if (diff?.same === false) {
differenceChecker(diff);
}
diffCache[expr] = diff;
return diff;
};
export const getEvaluator = () => {
return evaluator;
};
export const evaluateExpression: Evaluator = (expr, data) => {
if (!diffExpressions) {
return evaluator(expr, data);
}
const diff = checkEvaluatorDifferences(expr);
// We already know that they're different so don't bother
// evaluating with both evaluators
if (!diff?.same) {
return evaluator(expr, data);
}
let tmplValue: tmpl.ReturnValue;
let tournValue: ReturnValue;
let wasTmplError = false;
let tmplError: unknown;
let wasTournError = false;
let tournError: unknown;
try {
tmplValue = tmpl.tmpl(expr, data);
} catch (error) {
tmplError = error;
wasTmplError = true;
}
try {
tournValue = tournamentEvaluator.execute(expr, data);
} catch (error) {
tournError = error;
wasTournError = true;
}
if (
wasTmplError !== wasTournError ||
JSON.stringify(tmplValue!) !== JSON.stringify(tournValue!)
) {
try {
if (diff.expression) {
differenceHandler(diff.expression.value);
} else {
differenceHandler('VALUEDIFF');
}
} catch {
LoggerProxy.error('Failed to report error difference');
}
}
if (currentEvaluatorType === 'tmpl') {
if (wasTmplError) {
throw tmplError;
}
return tmplValue!;
}
if (wasTournError) {
throw tournError;
}
return tournValue!;
};

View File

@@ -2117,6 +2117,8 @@ export interface IPublicApiSettings {
export type ILogLevel = 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
export type ExpressionEvaluatorType = 'tmpl' | 'tournament';
export interface IN8nUISettings {
endpointWebhook: string;
endpointWebhookTest: string;
@@ -2203,6 +2205,9 @@ export interface IN8nUISettings {
variables: {
limit: number;
};
expressions: {
evaluator: ExpressionEvaluatorType;
};
mfa: {
enabled: boolean;
};

View File

@@ -1,5 +1,6 @@
import * as LoggerProxy from './LoggerProxy';
export * as ErrorReporterProxy from './ErrorReporterProxy';
export * as ExpressionEvaluatorProxy from './ExpressionEvaluatorProxy';
import * as NodeHelpers from './NodeHelpers';
import * as ObservableObject from './ObservableObject';
import * as TelemetryHelpers from './TelemetryHelpers';

View File

@@ -11,221 +11,227 @@ import { baseFixtures } from './ExpressionFixtures/base';
import type { INodeExecutionData } from '@/Interfaces';
import { extendSyntax } from '@/Extensions/ExpressionExtension';
import { ExpressionError } from '@/ExpressionError';
import { setDifferEnabled, setEvaluator } from '@/ExpressionEvaluatorProxy';
describe('Expression', () => {
describe('getParameterValue()', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = new Expression(workflow);
setDifferEnabled(true);
const evaluate = (value: string) =>
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', '', {});
it('should not be able to use global built-ins from denylist', () => {
expect(evaluate('={{document}}')).toEqual({});
expect(evaluate('={{window}}')).toEqual({});
expect(evaluate('={{Window}}')).toEqual({});
expect(evaluate('={{globalThis}}')).toEqual({});
expect(evaluate('={{self}}')).toEqual({});
expect(evaluate('={{alert}}')).toEqual({});
expect(evaluate('={{prompt}}')).toEqual({});
expect(evaluate('={{confirm}}')).toEqual({});
expect(evaluate('={{eval}}')).toEqual({});
expect(evaluate('={{uneval}}')).toEqual({});
expect(evaluate('={{setTimeout}}')).toEqual({});
expect(evaluate('={{setInterval}}')).toEqual({});
expect(evaluate('={{Function}}')).toEqual({});
expect(evaluate('={{fetch}}')).toEqual({});
expect(evaluate('={{XMLHttpRequest}}')).toEqual({});
expect(evaluate('={{Promise}}')).toEqual({});
expect(evaluate('={{Generator}}')).toEqual({});
expect(evaluate('={{GeneratorFunction}}')).toEqual({});
expect(evaluate('={{AsyncFunction}}')).toEqual({});
expect(evaluate('={{AsyncGenerator}}')).toEqual({});
expect(evaluate('={{AsyncGeneratorFunction}}')).toEqual({});
expect(evaluate('={{WebAssembly}}')).toEqual({});
expect(evaluate('={{Reflect}}')).toEqual({});
expect(evaluate('={{Proxy}}')).toEqual({});
expect(evaluate('={{constructor}}')).toEqual({});
expect(evaluate('={{escape}}')).toEqual({});
expect(evaluate('={{unescape}}')).toEqual({});
});
it('should be able to use global built-ins from allowlist', () => {
expect(evaluate('={{new Date()}}')).toBeInstanceOf(Date);
expect(evaluate('={{DateTime.now().toLocaleString()}}')).toEqual(
DateTime.now().toLocaleString(),
);
expect(evaluate('={{Interval.after(new Date(), 100)}}')).toEqual(
Interval.after(new Date(), 100),
);
expect(evaluate('={{Duration.fromMillis(100)}}')).toEqual(Duration.fromMillis(100));
expect(evaluate('={{new Object()}}')).toEqual(new Object());
expect(evaluate('={{new Array()}}')).toEqual([]);
expect(evaluate('={{new Int8Array()}}')).toEqual(new Int8Array());
expect(evaluate('={{new Uint8Array()}}')).toEqual(new Uint8Array());
expect(evaluate('={{new Uint8ClampedArray()}}')).toEqual(new Uint8ClampedArray());
expect(evaluate('={{new Int16Array()}}')).toEqual(new Int16Array());
expect(evaluate('={{new Uint16Array()}}')).toEqual(new Uint16Array());
expect(evaluate('={{new Int32Array()}}')).toEqual(new Int32Array());
expect(evaluate('={{new Uint32Array()}}')).toEqual(new Uint32Array());
expect(evaluate('={{new Float32Array()}}')).toEqual(new Float32Array());
expect(evaluate('={{new Float64Array()}}')).toEqual(new Float64Array());
expect(evaluate('={{new BigInt64Array()}}')).toEqual(new BigInt64Array());
expect(evaluate('={{new BigUint64Array()}}')).toEqual(new BigUint64Array());
expect(evaluate('={{new Map()}}')).toEqual(new Map());
expect(evaluate('={{new WeakMap()}}')).toEqual(new WeakMap());
expect(evaluate('={{new Set()}}')).toEqual(new Set());
expect(evaluate('={{new WeakSet()}}')).toEqual(new WeakSet());
expect(evaluate('={{new Error()}}')).toEqual(new Error());
expect(evaluate('={{new TypeError()}}')).toEqual(new TypeError());
expect(evaluate('={{new SyntaxError()}}')).toEqual(new SyntaxError());
expect(evaluate('={{new EvalError()}}')).toEqual(new EvalError());
expect(evaluate('={{new RangeError()}}')).toEqual(new RangeError());
expect(evaluate('={{new ReferenceError()}}')).toEqual(new ReferenceError());
expect(evaluate('={{new URIError()}}')).toEqual(new URIError());
expect(evaluate('={{Intl}}')).toEqual(Intl);
expect(evaluate('={{new String()}}')).toEqual(new String());
expect(evaluate("={{new RegExp('')}}")).toEqual(new RegExp(''));
expect(evaluate('={{Math}}')).toEqual(Math);
expect(evaluate('={{new Number()}}')).toEqual(new Number());
expect(evaluate("={{BigInt('1')}}")).toEqual(BigInt('1'));
expect(evaluate('={{Infinity}}')).toEqual(Infinity);
expect(evaluate('={{NaN}}')).toEqual(NaN);
expect(evaluate('={{isFinite(1)}}')).toEqual(isFinite(1));
expect(evaluate('={{isNaN(1)}}')).toEqual(isNaN(1));
expect(evaluate("={{parseFloat('1')}}")).toEqual(parseFloat('1'));
expect(evaluate("={{parseInt('1', 10)}}")).toEqual(parseInt('1', 10));
expect(evaluate('={{JSON.stringify({})}}')).toEqual(JSON.stringify({}));
expect(evaluate('={{new ArrayBuffer(10)}}')).toEqual(new ArrayBuffer(10));
expect(evaluate('={{new SharedArrayBuffer(10)}}')).toEqual(new SharedArrayBuffer(10));
expect(evaluate('={{Atomics}}')).toEqual(Atomics);
expect(evaluate('={{new DataView(new ArrayBuffer(1))}}')).toEqual(
new DataView(new ArrayBuffer(1)),
);
expect(evaluate("={{encodeURI('https://google.com')}}")).toEqual(
encodeURI('https://google.com'),
);
expect(evaluate("={{encodeURIComponent('https://google.com')}}")).toEqual(
encodeURIComponent('https://google.com'),
);
expect(evaluate("={{decodeURI('https://google.com')}}")).toEqual(
decodeURI('https://google.com'),
);
expect(evaluate("={{decodeURIComponent('https://google.com')}}")).toEqual(
decodeURIComponent('https://google.com'),
);
expect(evaluate('={{Boolean(1)}}')).toEqual(Boolean(1));
expect(evaluate('={{Symbol(1).toString()}}')).toEqual(Symbol(1).toString());
});
it('should not able to do arbitrary code execution', () => {
const testFn = jest.fn();
Object.assign(global, { testFn });
expect(() => evaluate("={{ Date['constructor']('testFn()')()}}")).toThrowError(
new ExpressionError('Arbitrary code execution detected'),
);
expect(testFn).not.toHaveBeenCalled();
});
});
describe('Test all expression value fixtures', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = new Expression(workflow);
const evaluate = (value: string, data: INodeExecutionData[]) => {
const itemIndex = data.length === 0 ? -1 : 0;
return expression.getParameterValue(
value,
null,
0,
itemIndex,
'node',
data,
'manual',
'',
{},
);
};
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'evaluation')) {
continue;
}
test(t.expression, () => {
for (const test of t.tests.filter(
(test) => test.type === 'evaluation',
) as ExpressionTestEvaluation[]) {
expect(evaluate(t.expression, test.input.map((d) => ({ json: d })) as any)).toStrictEqual(
test.output,
);
}
for (const evaluator of ['tmpl', 'tournament'] as const) {
setEvaluator(evaluator);
describe(`Expression (with ${evaluator})`, () => {
describe('getParameterValue()', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
}
});
const expression = new Expression(workflow);
describe('Test all expression transform fixtures', () => {
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'transform')) {
continue;
}
test(t.expression, () => {
for (const test of t.tests.filter(
(test) => test.type === 'transform',
) as ExpressionTestTransform[]) {
const expr = t.expression;
expect(extendSyntax(expr, test.forceTransform)).toEqual(test.result ?? expr);
}
const evaluate = (value: string) =>
expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', '', {});
it('should not be able to use global built-ins from denylist', () => {
expect(evaluate('={{document}}')).toEqual({});
expect(evaluate('={{window}}')).toEqual({});
expect(evaluate('={{Window}}')).toEqual({});
expect(evaluate('={{globalThis}}')).toEqual({});
expect(evaluate('={{self}}')).toEqual({});
expect(evaluate('={{alert}}')).toEqual({});
expect(evaluate('={{prompt}}')).toEqual({});
expect(evaluate('={{confirm}}')).toEqual({});
expect(evaluate('={{eval}}')).toEqual({});
expect(evaluate('={{uneval}}')).toEqual({});
expect(evaluate('={{setTimeout}}')).toEqual({});
expect(evaluate('={{setInterval}}')).toEqual({});
expect(evaluate('={{Function}}')).toEqual({});
expect(evaluate('={{fetch}}')).toEqual({});
expect(evaluate('={{XMLHttpRequest}}')).toEqual({});
expect(evaluate('={{Promise}}')).toEqual({});
expect(evaluate('={{Generator}}')).toEqual({});
expect(evaluate('={{GeneratorFunction}}')).toEqual({});
expect(evaluate('={{AsyncFunction}}')).toEqual({});
expect(evaluate('={{AsyncGenerator}}')).toEqual({});
expect(evaluate('={{AsyncGeneratorFunction}}')).toEqual({});
expect(evaluate('={{WebAssembly}}')).toEqual({});
expect(evaluate('={{Reflect}}')).toEqual({});
expect(evaluate('={{Proxy}}')).toEqual({});
expect(evaluate('={{constructor}}')).toEqual({});
expect(evaluate('={{escape}}')).toEqual({});
expect(evaluate('={{unescape}}')).toEqual({});
});
}
it('should be able to use global built-ins from allowlist', () => {
expect(evaluate('={{new Date()}}')).toBeInstanceOf(Date);
expect(evaluate('={{DateTime.now().toLocaleString()}}')).toEqual(
DateTime.now().toLocaleString(),
);
expect(evaluate('={{Interval.after(new Date(), 100)}}')).toEqual(
Interval.after(new Date(), 100),
);
expect(evaluate('={{Duration.fromMillis(100)}}')).toEqual(Duration.fromMillis(100));
expect(evaluate('={{new Object()}}')).toEqual(new Object());
expect(evaluate('={{new Array()}}')).toEqual([]);
expect(evaluate('={{new Int8Array()}}')).toEqual(new Int8Array());
expect(evaluate('={{new Uint8Array()}}')).toEqual(new Uint8Array());
expect(evaluate('={{new Uint8ClampedArray()}}')).toEqual(new Uint8ClampedArray());
expect(evaluate('={{new Int16Array()}}')).toEqual(new Int16Array());
expect(evaluate('={{new Uint16Array()}}')).toEqual(new Uint16Array());
expect(evaluate('={{new Int32Array()}}')).toEqual(new Int32Array());
expect(evaluate('={{new Uint32Array()}}')).toEqual(new Uint32Array());
expect(evaluate('={{new Float32Array()}}')).toEqual(new Float32Array());
expect(evaluate('={{new Float64Array()}}')).toEqual(new Float64Array());
expect(evaluate('={{new BigInt64Array()}}')).toEqual(new BigInt64Array());
expect(evaluate('={{new BigUint64Array()}}')).toEqual(new BigUint64Array());
expect(evaluate('={{new Map()}}')).toEqual(new Map());
expect(evaluate('={{new WeakMap()}}')).toEqual(new WeakMap());
expect(evaluate('={{new Set()}}')).toEqual(new Set());
expect(evaluate('={{new WeakSet()}}')).toEqual(new WeakSet());
expect(evaluate('={{new Error()}}')).toEqual(new Error());
expect(evaluate('={{new TypeError()}}')).toEqual(new TypeError());
expect(evaluate('={{new SyntaxError()}}')).toEqual(new SyntaxError());
expect(evaluate('={{new EvalError()}}')).toEqual(new EvalError());
expect(evaluate('={{new RangeError()}}')).toEqual(new RangeError());
expect(evaluate('={{new ReferenceError()}}')).toEqual(new ReferenceError());
expect(evaluate('={{new URIError()}}')).toEqual(new URIError());
expect(evaluate('={{Intl}}')).toEqual(Intl);
expect(evaluate('={{new String()}}')).toEqual(new String());
expect(evaluate("={{new RegExp('')}}")).toEqual(new RegExp(''));
expect(evaluate('={{Math}}')).toEqual(Math);
expect(evaluate('={{new Number()}}')).toEqual(new Number());
expect(evaluate("={{BigInt('1')}}")).toEqual(BigInt('1'));
expect(evaluate('={{Infinity}}')).toEqual(Infinity);
expect(evaluate('={{NaN}}')).toEqual(NaN);
expect(evaluate('={{isFinite(1)}}')).toEqual(isFinite(1));
expect(evaluate('={{isNaN(1)}}')).toEqual(isNaN(1));
expect(evaluate("={{parseFloat('1')}}")).toEqual(parseFloat('1'));
expect(evaluate("={{parseInt('1', 10)}}")).toEqual(parseInt('1', 10));
expect(evaluate('={{JSON.stringify({})}}')).toEqual(JSON.stringify({}));
expect(evaluate('={{new ArrayBuffer(10)}}')).toEqual(new ArrayBuffer(10));
expect(evaluate('={{new SharedArrayBuffer(10)}}')).toEqual(new SharedArrayBuffer(10));
expect(evaluate('={{Atomics}}')).toEqual(Atomics);
expect(evaluate('={{new DataView(new ArrayBuffer(1))}}')).toEqual(
new DataView(new ArrayBuffer(1)),
);
expect(evaluate("={{encodeURI('https://google.com')}}")).toEqual(
encodeURI('https://google.com'),
);
expect(evaluate("={{encodeURIComponent('https://google.com')}}")).toEqual(
encodeURIComponent('https://google.com'),
);
expect(evaluate("={{decodeURI('https://google.com')}}")).toEqual(
decodeURI('https://google.com'),
);
expect(evaluate("={{decodeURIComponent('https://google.com')}}")).toEqual(
decodeURIComponent('https://google.com'),
);
expect(evaluate('={{Boolean(1)}}')).toEqual(Boolean(1));
expect(evaluate('={{Symbol(1).toString()}}')).toEqual(Symbol(1).toString());
});
it('should not able to do arbitrary code execution', () => {
const testFn = jest.fn();
Object.assign(global, { testFn });
expect(() => evaluate("={{ Date['constructor']('testFn()')()}}")).toThrowError(
new ExpressionError('Arbitrary code execution detected'),
);
expect(testFn).not.toHaveBeenCalled();
});
});
describe('Test all expression value fixtures', () => {
const nodeTypes = Helpers.NodeTypes();
const workflow = new Workflow({
nodes: [
{
name: 'node',
typeVersion: 1,
type: 'test.set',
id: 'uuid-1234',
position: [0, 0],
parameters: {},
},
],
connections: {},
active: false,
nodeTypes,
});
const expression = new Expression(workflow);
const evaluate = (value: string, data: INodeExecutionData[]) => {
const itemIndex = data.length === 0 ? -1 : 0;
return expression.getParameterValue(
value,
null,
0,
itemIndex,
'node',
data,
'manual',
'',
{},
);
};
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'evaluation')) {
continue;
}
test(t.expression, () => {
for (const test of t.tests.filter(
(test) => test.type === 'evaluation',
) as ExpressionTestEvaluation[]) {
expect(
evaluate(t.expression, test.input.map((d) => ({ json: d })) as any),
).toStrictEqual(test.output);
}
});
}
});
describe('Test all expression transform fixtures', () => {
for (const t of baseFixtures) {
if (!t.tests.some((test) => test.type === 'transform')) {
continue;
}
test(t.expression, () => {
for (const test of t.tests.filter(
(test) => test.type === 'transform',
) as ExpressionTestTransform[]) {
const expr = t.expression;
expect(extendSyntax(expr, test.forceTransform)).toEqual(test.result ?? expr);
}
});
}
});
});
});
}