From f87ea70c510377b31c029c9e212242bd14f93c66 Mon Sep 17 00:00:00 2001 From: shijianzhi Date: Sun, 19 Apr 2026 23:52:38 +0800 Subject: [PATCH] fix: handle nested braces in expression parsing --- packages/@n8n/codemirror-lang/package.json | 3 + .../src/expressions/expressions.grammar | 14 +- .../src/expressions/grammar.terms.ts | 6 +- .../src/expressions/grammar.ts | 13 +- .../codemirror-lang/src/expressions/tokens.ts | 96 ++++++++++++ .../test/expressions/cases.txt | 112 ++++++++++++++ .../src/extensions/expression-extension.ts | 82 ++++++++-- .../src/extensions/expression-parser.ts | 146 +++++++++++++----- .../expression-extension.test.ts | 146 ++++++++++++++++++ .../object-extensions.test.ts | 17 +- packages/workflow/test/expression.test.ts | 55 ++++++- pnpm-lock.yaml | 10 +- 12 files changed, 616 insertions(+), 84 deletions(-) create mode 100644 packages/@n8n/codemirror-lang/src/expressions/tokens.ts diff --git a/packages/@n8n/codemirror-lang/package.json b/packages/@n8n/codemirror-lang/package.json index 3253b0a9d70e1..ada160804679b 100644 --- a/packages/@n8n/codemirror-lang/package.json +++ b/packages/@n8n/codemirror-lang/package.json @@ -38,6 +38,9 @@ "format": "biome format --write src test", "format:check": "biome ci src test" }, + "dependencies": { + "esprima-next": "5.8.4" + }, "peerDependencies": { "@codemirror/language": "catalog:", "@lezer/highlight": "catalog:", diff --git a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar index 29af5d0e91dff..f14322877e601 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar +++ b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar @@ -4,18 +4,10 @@ entity { Plaintext | Resolvable } @tokens { Plaintext { ![{] Plaintext? | "{" (@eof | ![{] Plaintext?) } +} - OpenMarker[closedBy="CloseMarker"] { "{{" } - - CloseMarker[openedBy="OpenMarker"] { "}}" } - - Resolvable { - OpenMarker resolvableChar* CloseMarker - } - - resolvableChar { unicodeChar | "}" ![}] | "\\}}" } - - unicodeChar { $[\u0000-\u007C] | $[\u007E-\u{10FFFF}] } +@external tokens tokens from "./tokens" { + Resolvable } @detectDelim diff --git a/packages/@n8n/codemirror-lang/src/expressions/grammar.terms.ts b/packages/@n8n/codemirror-lang/src/expressions/grammar.terms.ts index f3c6da0ea7be6..07ecd2a53d301 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/grammar.terms.ts +++ b/packages/@n8n/codemirror-lang/src/expressions/grammar.terms.ts @@ -1,4 +1,4 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const Program = 1, - Plaintext = 2, - Resolvable = 3; +export const Resolvable = 1, + Program = 2, + Plaintext = 3; diff --git a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts index 61cf232022ab7..62f06dd95d48a 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts +++ b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts @@ -1,17 +1,18 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. import { LRParser } from '@lezer/lr'; +import { tokens } from './tokens'; export const parser = LRParser.deserialize({ version: 14, - states: "nQQOPOOOOOO'#Cb'#CbOOOO'#C`'#C`QQOPOOOOOO-E6^-E6^", - stateData: 'Y~OQPORPO~O', + states: "nQQOROOOOOQ'#Cb'#CbOOOQ'#C`'#C`QQOROOOOOQ-E6^-E6^", + stateData: 'Y~OPPORPO~O', goto: 'bVPPPPWP^QRORSRTQOR', - nodeNames: '⚠ Program Plaintext Resolvable', + nodeNames: '⚠ Resolvable Program Plaintext', maxTerm: 6, skippedNodes: [0], repeatNodeCount: 1, tokenData: - "%o~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TVO#O#Q#O#P#j#P#q#Q#q#r%Q#r;'S#Q;'S;=`%i<%lO#Q~#mVO#O#Q#O#P#j#P#q#Q#q#r$S#r;'S#Q;'S;=`%i<%lO#Q~$VTO#q#Q#q#r$f#r;'S#Q;'S;=`%i<%lO#Q~$kVR~O#O#Q#O#P#j#P#q#Q#q#r%Q#r;'S#Q;'S;=`%i<%lO#Q~%TTO#q#Q#q#r%d#r;'S#Q;'S;=`%i<%lO#Q~%iOR~~%lP;=`<%l#Q", - tokenizers: [0], - topRules: { Program: [0, 1] }, + "!h~RTO#ob#o#pv#p;'Sb;'S;=`!]<%lOb~gTR~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOR~", + tokenizers: [0, tokens], + topRules: { Program: [0, 2] }, tokenPrec: 0, }); diff --git a/packages/@n8n/codemirror-lang/src/expressions/tokens.ts b/packages/@n8n/codemirror-lang/src/expressions/tokens.ts new file mode 100644 index 0000000000000..af1374506f03e --- /dev/null +++ b/packages/@n8n/codemirror-lang/src/expressions/tokens.ts @@ -0,0 +1,96 @@ +import { ExternalTokenizer } from '@lezer/lr'; +import { parseScript } from 'esprima-next'; + +import { Resolvable } from './grammar.terms'; + +const BACKSLASH = 92; +const BRACE_LEFT = 123; +const BRACE_RIGHT = 125; +const EXPRESSION_STATEMENT = 'ExpressionStatement'; +const EMPTY_STATEMENT = 'EmptyStatement'; + +const parseCandidateProgram = (source: string) => { + try { + return parseScript(source); + } catch { + return null; + } +}; + +const isSentinelExpressionProgram = (source: string) => { + const program = parseCandidateProgram(source); + const lastType = String(program?.body.at(-1)?.type); + return ( + program !== null && + program.body.length >= 2 && + lastType === EXPRESSION_STATEMENT && + program.body + .slice(0, -1) + .every( + (statement) => + String(statement.type) === EMPTY_STATEMENT || + String(statement.type) === EXPRESSION_STATEMENT, + ) + ); +}; + +const isParsableExpressionCandidate = (source: string, cache: Map) => { + const cached = cache.get(source); + if (cached !== undefined) { + return cached; + } + + const result = + isSentinelExpressionProgram(`${source};0`) || isSentinelExpressionProgram(`(${source});0`); + + cache.set(source, result); + return result; +}; + +export const tokens = new ExternalTokenizer((input) => { + if (input.next !== BRACE_LEFT || input.peek(1) !== BRACE_LEFT) { + return; + } + + input.advance(2); + + let code = ''; + let firstFallbackEnd: number | null = null; + const parseableExpressionCache = new Map(); + + for (;;) { + const char = Number(input.next); + + if (char < 0) { + if (firstFallbackEnd !== null) { + input.acceptTokenTo(Resolvable, firstFallbackEnd); + } + return; + } + + const nextChar = Number(input.peek(1)); + + if (char === BACKSLASH && nextChar === BRACE_RIGHT && Number(input.peek(2)) === BRACE_RIGHT) { + code += '\\}}'; + input.advance(3); + continue; + } + + if (char === BRACE_RIGHT && nextChar === BRACE_RIGHT) { + const candidateEnd = input.pos + 2; + firstFallbackEnd ??= candidateEnd; + + if (isParsableExpressionCandidate(code, parseableExpressionCache)) { + input.acceptTokenTo(Resolvable, candidateEnd); + return; + } + + code += '}'; + input.advance(); + continue; + } + + code += String.fromCharCode(char); + input.advance(); + } +}); diff --git a/packages/@n8n/codemirror-lang/test/expressions/cases.txt b/packages/@n8n/codemirror-lang/test/expressions/cases.txt index 573b639194323..58c0ebce64b43 100644 --- a/packages/@n8n/codemirror-lang/test/expressions/cases.txt +++ b/packages/@n8n/codemirror-lang/test/expressions/cases.txt @@ -158,6 +158,110 @@ Program(Resolvable) Program(Resolvable) +# Resolvable containing object literal with nested braces + +{{ {values:{}} }} + +==> + +Program(Resolvable) + +# Resolvable containing bare object literal ending at the closing marker + +{{ {a:1}}} + +==> + +Program(Resolvable) + +# Resolvable containing nested object literals + +{{ {values:{ nested: {} }} }} + +==> + +Program(Resolvable) + +# Resolvable containing nested object literals with multiple internal closing pairs + +{{ {a:{c:{}}, b:{}} }} + +==> + +Program(Resolvable) + +# Resolvable containing regex literal followed by plaintext + +{{ /[/*]/.test(x) }} tail + +==> + +Program(Resolvable,Plaintext) + +# Resolvable containing string literal with left brace followed by another expression + +{{ "{foo" }} and {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + +# Resolvable containing regex literal with left brace followed by another expression + +{{ /{/.test(x) }} and {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + +# Resolvable containing comment with left brace followed by another expression + +{{ /* { */ 1 }} and {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + +# Resolvable containing string literal with closing braces followed by another expression + +{{ "{{foo}}" }} and {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + +# Resolvable containing bare object literal followed by plaintext + +{{ {a:1}}} tail + +==> + +Program(Resolvable,Plaintext) + +# Resolvable containing bare object literal followed by plaintext and another expression + +{{ {a:1}}} tail {{ $json.n }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + +# Resolvable containing bare object literal with classic function body followed by plaintext + +{{ {foo:function(){return 1}}}} tail + +==> + +Program(Resolvable,Plaintext) + +# Resolvable containing bare object literal with method body followed by plaintext and another expression + +{{ {foo(){return 1}}}} tail {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) + # Resolvable containing double-brace-wrapped text with escaping {{ he {{ abc \}} llo }} @@ -324,3 +428,11 @@ Program(Resolvable) ==> Program(Resolvable) + +# Line comments containing closing braces do not swallow following expressions +{{ // }} +1 }} and {{ 2 }} + +==> + +Program(Resolvable,Plaintext,Resolvable) diff --git a/packages/workflow/src/extensions/expression-extension.ts b/packages/workflow/src/extensions/expression-extension.ts index 2acd83ae8990e..5ccc9fc2df30f 100644 --- a/packages/workflow/src/extensions/expression-extension.ts +++ b/packages/workflow/src/extensions/expression-extension.ts @@ -107,6 +107,12 @@ function parseWithEsprimaNext(source: string, options?: any): any { return ast; } +type EsprimaToken = { + range?: [number, number]; + type: string; + value: string; +}; + /** * A function to inject an extender function call into the AST of an expression. * This uses recast to do the transform. @@ -437,6 +443,43 @@ export const extendTransform = (expression: string): { code: string } | undefine } }; +const addStructuralClosingSpacing = (expression: string) => { + const ast = esprimaParse(expression, { + range: true, + tolerant: true, + tokens: true, + }) as { tokens?: EsprimaToken[] }; + const tokens = ast.tokens ?? []; + + let result = expression; + let offset = 0; + + for (let index = 1; index < tokens.length; index++) { + const previousToken = tokens[index - 1]; + const currentToken = tokens[index]; + + if ( + previousToken.type === 'Punctuator' && + previousToken.value === '}' && + currentToken.type === 'Punctuator' && + currentToken.value === '}' && + previousToken.range && + currentToken.range && + previousToken.range[1] === currentToken.range[0] + ) { + const insertIndex = currentToken.range[0] + offset; + result = `${result.slice(0, insertIndex)} ${result.slice(insertIndex)}`; + offset += 1; + } + } + + return result; +}; + +const normalizeBareObjectLiteralOutput = (expression: string) => { + return addStructuralClosingSpacing(expression); +}; + function isDate(input: unknown): boolean { if (typeof input !== 'string' || !input.length) { return false; @@ -575,26 +618,35 @@ export function extendOptional( } const EXTENDED_SYNTAX_CACHE: Record = {}; +const getExtendedSyntaxCacheKey = (bracketedExpression: string, forceExtend: boolean) => + `${forceExtend ? '1' : '0'}:${bracketedExpression}`; export function extendSyntax(bracketedExpression: string, forceExtend = false): string { + const cacheKey = getExtendedSyntaxCacheKey(bracketedExpression, forceExtend); + + if (cacheKey in EXTENDED_SYNTAX_CACHE) { + return EXTENDED_SYNTAX_CACHE[cacheKey]; + } + const chunks = splitExpression(bracketedExpression); + const rawCodeChunks = chunks.filter((c) => c.type === 'code').map((c) => c.text); - const codeChunks = chunks - .filter((c) => c.type === 'code') - .map((c) => c.text.replace(/("|').*?("|')/, '').trim()); + const codeChunks = rawCodeChunks.map((chunk) => chunk.replace(/("|').*?("|')/, '').trim()); + const hasNestedClosingBrackets = rawCodeChunks.some((chunk) => chunk.includes('}}')); + const hasTightBareObjectLiteral = rawCodeChunks.some( + (chunk) => chunk.trim().startsWith('{') && chunk.endsWith('}'), + ); if ( (!codeChunks.some(hasExpressionExtension) || hasNativeMethod(bracketedExpression)) && + !hasNestedClosingBrackets && + !hasTightBareObjectLiteral && !forceExtend ) { + EXTENDED_SYNTAX_CACHE[cacheKey] = bracketedExpression; return bracketedExpression; } - // If we've seen this expression before grab it from the cache - if (bracketedExpression in EXTENDED_SYNTAX_CACHE) { - return EXTENDED_SYNTAX_CACHE[bracketedExpression]; - } - const extendedChunks = chunks.map((chunk): ExpressionChunk => { if (chunk.type === 'code') { let output = extendTransform(chunk.text); @@ -618,6 +670,18 @@ export function extendSyntax(bracketedExpression: string, forceExtend = false): text = text.trim().slice(0, -1); } + if (chunk.text.trim().startsWith('{')) { + text = normalizeBareObjectLiteralOutput(text); + } + + if ( + chunk.hasClosingBrackets && + chunk.text.trim().startsWith('{') && + chunk.text.endsWith('}') + ) { + text += ' '; + } + return { ...chunk, text, @@ -628,6 +692,6 @@ export function extendSyntax(bracketedExpression: string, forceExtend = false): const expression = joinExpression(extendedChunks); // Cache the expression so we don't have to do this transform again - EXTENDED_SYNTAX_CACHE[bracketedExpression] = expression; + EXTENDED_SYNTAX_CACHE[cacheKey] = expression; return expression; } diff --git a/packages/workflow/src/extensions/expression-parser.ts b/packages/workflow/src/extensions/expression-parser.ts index 004211edc356f..d3842cc3933d6 100644 --- a/packages/workflow/src/extensions/expression-parser.ts +++ b/packages/workflow/src/extensions/expression-parser.ts @@ -1,3 +1,5 @@ +import { parseScript } from 'esprima-next'; + export interface ExpressionText { type: 'text'; text: string; @@ -15,68 +17,136 @@ export interface ExpressionCode { export type ExpressionChunk = ExpressionCode | ExpressionText; const OPEN_BRACKET = /(?\\|)(?\{\{)/; -const CLOSE_BRACKET = /(?\\|)(?\}\})/; +const EXPRESSION_STATEMENT = 'ExpressionStatement'; +const EMPTY_STATEMENT = 'EmptyStatement'; export const escapeCode = (text: string): string => { - return text.replace('\\}}', '}}'); + return text.replace(/\\}}/g, '}}'); +}; + +const isEscapedClosingBrackets = (expression: string, index: number) => + expression[index] === '\\' && expression[index + 1] === '}' && expression[index + 2] === '}'; + +const hasClosingBrackets = (char: string, nextChar: string) => char === '}' && nextChar === '}'; + +const parseCandidateProgram = (source: string) => { + try { + return parseScript(source); + } catch { + return null; + } }; +const isSentinelExpressionProgram = (source: string) => { + const program = parseCandidateProgram(source); + const lastType = String(program?.body.at(-1)?.type); + return ( + program !== null && + program.body.length >= 2 && + lastType === EXPRESSION_STATEMENT && + program.body + .slice(0, -1) + .every( + (statement) => + String(statement.type) === EMPTY_STATEMENT || + String(statement.type) === EXPRESSION_STATEMENT, + ) + ); +}; + +const isParsableExpressionCandidate = (source: string, cache: Map) => { + const cached = cache.get(source); + if (cached !== undefined) { + return cached; + } + + const result = + isSentinelExpressionProgram(`${source};0`) || isSentinelExpressionProgram(`(${source});0`); + + cache.set(source, result); + return result; +}; + +function findClosingExpressionIndexByParsing( + expression: string, + startIndex: number, +): number | null { + let code = ''; + const parseableExpressionCache = new Map(); + + for (let index = startIndex; index < expression.length; ) { + if (isEscapedClosingBrackets(expression, index)) { + code += '\\}}'; + index += 3; + continue; + } + + const char = expression[index]; + const nextChar = expression[index + 1]; + + if (hasClosingBrackets(char, nextChar)) { + if (isParsableExpressionCandidate(code, parseableExpressionCache)) { + return index; + } + + code += '}'; + index++; + continue; + } + + code += char; + index++; + } + + return null; +} + export const splitExpression = (expression: string): ExpressionChunk[] => { const chunks: ExpressionChunk[] = []; - let searchingFor: 'open' | 'close' = 'open'; - let activeRegex = OPEN_BRACKET; - let buffer = ''; - let index = 0; while (index < expression.length) { const expr = expression.slice(index); - const res = activeRegex.exec(expr); - // No more brackets. If it's a closing bracket - // this is sort of valid so we accept it but mark - // that it has no closing bracket. + const res = OPEN_BRACKET.exec(expr); if (!res?.groups) { - buffer += expr; - if (searchingFor === 'open') { - chunks.push({ - type: 'text', - text: buffer, - }); - } else { - chunks.push({ - type: 'code', - text: escapeCode(buffer), - hasClosingBrackets: false, - }); - } + chunks.push({ + type: 'text', + text: buffer + expr, + }); break; } + if (res.groups.escape) { buffer += expr.slice(0, res.index + 3); index += res.index + 3; } else { buffer += expr.slice(0, res.index); + chunks.push({ + type: 'text', + text: buffer, + }); + buffer = ''; - if (searchingFor === 'open') { - chunks.push({ - type: 'text', - text: buffer, - }); - searchingFor = 'close'; - activeRegex = CLOSE_BRACKET; - } else { + const expressionStart = index + res.index + 2; + const closingIndex = findClosingExpressionIndexByParsing(expression, expressionStart); + + if (closingIndex === null) { chunks.push({ type: 'code', - text: escapeCode(buffer), - hasClosingBrackets: true, + text: escapeCode(expression.slice(expressionStart)), + hasClosingBrackets: false, }); - searchingFor = 'open'; - activeRegex = OPEN_BRACKET; + break; } - index += res.index + 2; - buffer = ''; + chunks.push({ + type: 'code', + text: escapeCode(expression.slice(expressionStart, closingIndex)), + hasClosingBrackets: true, + }); + + index = closingIndex + 2; } } @@ -85,7 +155,7 @@ export const splitExpression = (expression: string): ExpressionChunk[] => { // Expressions only have closing brackets escaped const escapeTmplExpression = (part: string) => { - return part.replace('}}', '\\}}'); + return part.replace(/}}/g, '\\}}'); }; export const joinExpression = (parts: ExpressionChunk[]): string => { diff --git a/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts b/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts index bf8af3d630180..a147d24ad3d2d 100644 --- a/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts +++ b/packages/workflow/test/ExpressionExtensions/expression-extension.test.ts @@ -5,6 +5,7 @@ import { evaluate } from './helpers'; import { ExpressionExtensionError } from '../../src/errors/expression-extension.error'; import { extendTransform, extend } from '../../src/extensions'; +import { extendSyntax } from '../../src/extensions/expression-extension'; import { joinExpression, splitExpression } from '../../src/extensions/expression-parser'; describe('Expression Extension Transforms', () => { @@ -25,6 +26,31 @@ describe('Expression Extension Transforms', () => { ); }); }); + + describe('extendSyntax()', () => { + test('does not rewrite concise methods to function expressions', () => { + const extended = extendSyntax( + '={{ { __proto__: base, foo(){ return super.x } }.toJsonString() }}', + ); + + expect(extended).not.toMatch(/foo:\s*function/); + expect(extended).toMatch(/foo\(\)\s*\{\s*return super\.x\s*\}/); + }); + + test('keeps forceExtend=false and forceExtend=true cache entries separate after a no-op result', () => { + const expression = '={{ { "data": $json.body.choices } }}'; + + expect(extendSyntax(expression, false)).toBe(expression); + expect(extendSyntax(expression, true)).toBe('={{( { "data": $json.body.choices } )}}'); + }); + + test('keeps forceExtend=true and forceExtend=false cache entries separate after a transform result', () => { + const expression = '={{ { "items": $json.body.choices } }}'; + + expect(extendSyntax(expression, true)).toBe('={{( { "items": $json.body.choices } )}}'); + expect(extendSyntax(expression, false)).toBe(expression); + }); + }); }); describe('Expression Parser', () => { @@ -48,6 +74,120 @@ describe('Expression Parser', () => { ); }); + test('Expression containing object literals', () => { + expect(splitExpression('{{ {values:{}} }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {values:{}} ', hasClosingBrackets: true }, + ]); + }); + + test('Expression containing nested object literals', () => { + expect(splitExpression('{{ {values:{ nested: {} }} }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {values:{ nested: {} }} ', hasClosingBrackets: true }, + ]); + }); + + test('Expression containing nested object literals with multiple internal closing pairs', () => { + expect(splitExpression('{{ {a:{c:{}}, b:{}} }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {a:{c:{}}, b:{}} ', hasClosingBrackets: true }, + ]); + }); + + test('Expression containing bare object literals that end at the closing marker', () => { + expect(splitExpression('{{ {a:1}}}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {a:1}', hasClosingBrackets: true }, + ]); + }); + + test('Expression containing bare object literals with classic function bodies', () => { + expect(splitExpression('{{ {foo:function(){return 1}}}}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {foo:function(){return 1}}', hasClosingBrackets: true }, + ]); + }); + + test('Expression containing bare object literals with method bodies', () => { + expect(splitExpression('{{ {foo(){return 1}}}}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {foo(){return 1}}', hasClosingBrackets: true }, + ]); + }); + + test('Multiple expression regression', () => { + expect(splitExpression('{{ 1 }} text {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' 1 ', hasClosingBrackets: true }, + { type: 'text', text: ' text ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('Regex literals do not swallow following expressions', () => { + expect(splitExpression('{{ /[/*]/.test($json.s) }} and {{ $json.n }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' /[/*]/.test($json.s) ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' $json.n ', hasClosingBrackets: true }, + ]); + }); + + test('String literals containing left braces do not swallow following expressions', () => { + expect(splitExpression('{{ "{foo" }} and {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' "{foo" ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('Regex literals containing left braces do not swallow following expressions', () => { + expect(splitExpression('{{ /{/.test(x) }} and {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' /{/.test(x) ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('Comments containing left braces do not swallow following expressions', () => { + expect(splitExpression('{{ /* { */ 1 }} and {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' /* { */ 1 ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('Line comments containing closing braces do not swallow following expressions', () => { + expect(splitExpression('{{ // }}\n1 }} and {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' // }}\n1 ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('String literals containing closing braces do not swallow following expressions', () => { + expect(splitExpression('{{ "{{foo}}" }} and {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' "{{foo}}" ', hasClosingBrackets: true }, + { type: 'text', text: ' and ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + + test('Bare object literals do not swallow following expressions', () => { + expect(splitExpression('{{ {a:1}}} tail {{ 2 }}')).toEqual([ + { type: 'text', text: '' }, + { type: 'code', text: ' {a:1}', hasClosingBrackets: true }, + { type: 'text', text: ' tail ' }, + { type: 'code', text: ' 2 ', hasClosingBrackets: true }, + ]); + }); + test('Unclosed expression', () => { expect(splitExpression('{{ "test".toSnakeCase() }} you have ${{ (100).format()')).toEqual([ { type: 'text', text: '' }, @@ -104,6 +244,12 @@ describe('Expression Parser', () => { }); describe('Edge cases', () => { + test('Regex literals in mixed templates still evaluate', () => { + expect( + evaluate('={{ /[/*]/.test($json.s) }} and {{ $json.n }}', [{ s: '/*', n: 2 }]), + ).toEqual('true and 2'); + }); + test("Nested member access with name of function inside a function doesn't result in function call", () => { expect(evaluate('={{ Math.floor([1, 2, 3, 4].length + 10) }}')).toEqual(14); diff --git a/packages/workflow/test/ExpressionExtensions/object-extensions.test.ts b/packages/workflow/test/ExpressionExtensions/object-extensions.test.ts index 91710cdc7b307..a561a2ffddc8d 100644 --- a/packages/workflow/test/ExpressionExtensions/object-extensions.test.ts +++ b/packages/workflow/test/ExpressionExtensions/object-extensions.test.ts @@ -1,5 +1,4 @@ import { evaluate } from './helpers'; -import { ApplicationError } from '../../src/errors'; import { objectExtensions } from '../../src/extensions/object-extensions'; describe('Data Transformation Functions', () => { @@ -124,14 +123,16 @@ describe('Data Transformation Functions', () => { }); test('should not allow prototype pollution', () => { - ['{__proto__: {polluted: true}}', '{constructor: {prototype: {polluted: true}}}'].forEach( - (testExpression) => { - expect(() => evaluate(`={{ (${testExpression}).compact() }}`)).toThrow( - ApplicationError, - ); - expect(({} as any).polluted).toBeUndefined(); + [ + { expression: '{__proto__: {polluted: true}}', expected: {} }, + { + expression: '{constructor: {prototype: {polluted: true}}}', + expected: { constructor: { prototype: { polluted: true } } }, }, - ); + ].forEach(({ expression, expected }) => { + expect(evaluate(`={{ (${expression}).compact() }}`)).toEqual(expected); + expect(({} as any).polluted).toBeUndefined(); + }); }); }); diff --git a/packages/workflow/test/expression.test.ts b/packages/workflow/test/expression.test.ts index 55bcea06cceda..8fcaef7837293 100644 --- a/packages/workflow/test/expression.test.ts +++ b/packages/workflow/test/expression.test.ts @@ -11,7 +11,7 @@ import { ExpressionError } from '../src/errors/expression.error'; import { Expression } from '../src/expression'; import { extendSyntax } from '../src/extensions/expression-extension'; import { createRunExecutionData } from '../src'; -import type { INodeExecutionData } from '../src/interfaces'; +import type { IDataObject, INodeExecutionData } from '../src/interfaces'; import { Workflow } from '../src/workflow'; import { WorkflowDataProxy } from '../src/workflow-data-proxy'; @@ -43,8 +43,17 @@ describe('Expression', () => { await expression.releaseIsolate(); }); - const evaluate = (value: string) => - expression.getParameterValue(value, null, 0, 0, 'node', [], 'manual', {}); + const evaluate = (value: string, values?: IDataObject[]) => + expression.getParameterValue( + value, + null, + 0, + 0, + 'node', + values?.map((json) => ({ json })) ?? [], + 'manual', + {}, + ); it('should not be able to use global built-ins from denylist', () => { expect(evaluate('={{document}}')).toEqual({}); @@ -183,6 +192,46 @@ describe('Expression', () => { expect(evaluate('={{Symbol(1).toString()}}')).toEqual(Symbol(1).toString()); }); + it('should evaluate object literal expressions with nested braces', () => { + const expressionWithNestedBraces = evaluate('={{ {values:{}} }}'); + + expect(expressionWithNestedBraces).toEqual({ values: {} }); + expect(expressionWithNestedBraces).toEqual(evaluate('={{ {values:{} } }}')); + }); + + it('should evaluate object literal expressions with multiple internal closing pairs', () => { + expect(evaluate('={{ {a:{c:{}}, b:{}} }}')).toEqual({ a: { c: {} }, b: {} }); + }); + + it('should evaluate bare object literal expressions that end at the closing marker', () => { + const expressionWithBareObjectLiteral = evaluate('={{ {a:1}}}'); + + expect(expressionWithBareObjectLiteral).toEqual({ a: 1 }); + expect(expressionWithBareObjectLiteral).toEqual(evaluate('={{ {a:1} }}')); + }); + + it('should evaluate mixed templates with string literals containing left braces', () => { + expect(evaluate('={{ "{foo" }} and {{ $json.n }}', [{ n: 2 }])).toEqual('{foo and 2'); + }); + + it('should evaluate mixed templates with string literals containing closing braces', () => { + expect(evaluate('={{ "{{foo}}" }} and {{ $json.n }}', [{ n: 2 }])).toEqual('{{foo}} and 2'); + }); + + it('should evaluate mixed templates with regex literals containing left braces', () => { + expect(evaluate('={{ /{/.test($json.s) }} and {{ $json.n }}', [{ s: '{', n: 2 }])).toEqual( + 'true and 2', + ); + }); + + it('should evaluate mixed templates with comments containing left braces', () => { + expect(evaluate('={{ /* { */ 1 }} and {{ $json.n }}', [{ n: 2 }])).toEqual('1 and 2'); + }); + + it('should evaluate mixed templates with line comments containing closing braces', () => { + expect(evaluate('={{ // }}\n1 }} and {{ $json.n }}', [{ n: 2 }])).toEqual('1 and 2'); + }); + it('should expose correct process properties in sandbox', () => { expect(evaluate('={{process.version}}')).toMatch(/^v\d+\.\d+\.\d+/); expect(evaluate('={{typeof process.pid}}')).toBe('number'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7d5c87182111..5b161e630a236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -986,6 +986,9 @@ importers: '@lezer/lr': specifier: 'catalog:' version: 1.4.5 + esprima-next: + specifier: 5.8.4 + version: 5.8.4 devDependencies: '@lezer/generator': specifier: 'catalog:' @@ -25525,7 +25528,7 @@ snapshots: '@currents/commit-info': 1.0.1-beta.0 async-retry: 1.3.3 axios: 1.15.0(debug@4.4.3) - axios-retry: 4.5.0(axios@1.15.0(debug@4.4.3)) + axios-retry: 4.5.0(axios@1.15.0) c12: 1.11.2(magicast@0.3.5) chalk: 4.1.2 commander: 12.1.0 @@ -32929,11 +32932,6 @@ snapshots: axe-core@4.7.2: {} - axios-retry@4.5.0(axios@1.15.0(debug@4.4.3)): - dependencies: - axios: 1.15.0(debug@4.4.3) - is-retry-allowed: 2.2.0 - axios-retry@4.5.0(axios@1.15.0): dependencies: axios: 1.15.0