diff --git a/change/@fluentui-react-charts-103891dd-6fd7-4836-8976-65db96c42acb.json b/change/@fluentui-react-charts-103891dd-6fd7-4836-8976-65db96c42acb.json new file mode 100644 index 0000000000000..3c417198486e4 --- /dev/null +++ b/change/@fluentui-react-charts-103891dd-6fd7-4836-8976-65db96c42acb.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Replace unsafe expression evaluation with safe recursive-descent parser", + "packageName": "@fluentui/react-charts", + "email": "atisjai@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx index 8683ae0fa2f27..8f2e200812b61 100644 --- a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.test.tsx @@ -1244,3 +1244,106 @@ describe('VegaDeclarativeChart - More Heatmap Charts', () => { expect(legendTexts).toContain('B'); }); }); + +describe('VegaDeclarativeChart - Security', () => { + it('blocks malicious calculate expression (MSRC PoC: globalThis assignment)', () => { + // Exact payload from the MSRC vulnerability report + const maliciousSpec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { a: 'A', b: 28 }, + { a: 'B', b: 55 }, + ], + }, + transform: [ + { + calculate: "((globalThis.__msrcFluentUiPoc = 'owned-from-calculate'), 1337)", + as: 'injected', + }, + ], + encoding: { + x: { field: 'a', type: 'nominal' }, + y: { field: 'b', type: 'quantitative' }, + }, + }; + + render(); + + // The malicious payload must NOT have executed + expect((globalThis as Record).__msrcFluentUiPoc).toBeUndefined(); + }); + + it('blocks malicious filter expression', () => { + const maliciousSpec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { a: 'A', b: 28 }, + { a: 'B', b: 55 }, + ], + }, + transform: [ + { + filter: "((globalThis.__msrcFilterPoc = 'owned-from-filter'), true)", + }, + ], + encoding: { + x: { field: 'a', type: 'nominal' }, + y: { field: 'b', type: 'quantitative' }, + }, + }; + + render(); + + expect((globalThis as Record).__msrcFilterPoc).toBeUndefined(); + }); + + it('blocks malicious condition test expression', () => { + const maliciousSpec: VegaLiteSpec = { + mark: 'bar', + data: { + values: [ + { a: 'A', b: 28 }, + { a: 'B', b: 55 }, + ], + }, + encoding: { + x: { field: 'a', type: 'nominal' }, + y: { field: 'b', type: 'quantitative' }, + color: { + condition: { + test: "((globalThis.__msrcConditionPoc = 'owned-from-condition'), true)", + value: 'red', + }, + value: 'blue', + }, + }, + }; + + render(); + + expect((globalThis as Record).__msrcConditionPoc).toBeUndefined(); + }); + + it('rejects excessively deep JSON specs (DoS prevention)', () => { + // Build a spec nested deeper than MAX_DEPTH (15) + let nested: Record = { values: [{ x: 1, y: 1 }] }; + for (let i = 0; i < 20; i++) { + nested = { wrapper: nested }; + } + + const deepSpec: VegaLiteSpec = { + mark: 'bar', + data: nested as VegaLiteSpec['data'], + encoding: { + x: { field: 'x', type: 'nominal' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + + expect(() => render()).toThrow( + 'Maximum JSON depth exceeded', + ); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx index 66c56c0b0c2a5..3cb113f41cf5b 100644 --- a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaDeclarativeChart.tsx @@ -36,6 +36,27 @@ import type { Chart } from '../../types/index'; // Re-export the typed VegaLiteSpec for public API export type { VegaLiteSpec } from './VegaLiteTypes'; +/** + * Maximum allowed nesting depth for incoming JSON specs. + * Matches the Plotly adapter's MAX_DEPTH to prevent stack overflow / memory exhaustion. + */ +const MAX_DEPTH = 15; + +/** + * Validates that a JSON value does not exceed the maximum nesting depth. + * Throws if the depth limit is exceeded (same behavior as Plotly's sanitizeJson). + */ +function validateJsonDepth(value: unknown, depth: number = 0): void { + if (depth > MAX_DEPTH) { + throw new Error('VegaDeclarativeChart: Maximum JSON depth exceeded'); + } + if (value !== null && typeof value === 'object') { + for (const key of Object.keys(value as Record)) { + validateJsonDepth((value as Record)[key], depth + 1); + } + } +} + /** * Schema for VegaDeclarativeChart component */ @@ -373,6 +394,9 @@ export const VegaDeclarativeChart = React.forwardRef(null); diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.test.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.test.ts new file mode 100644 index 0000000000000..6fc6181c06b69 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.test.ts @@ -0,0 +1,217 @@ +import { safeEvaluateExpression } from './VegaLiteExpressionEvaluator'; + +describe('VegaLiteExpressionEvaluator', () => { + // ------------------------------------------------------------------- + // Safe expressions that SHOULD work + // ------------------------------------------------------------------- + describe('safe expressions', () => { + it('evaluates simple property access', () => { + expect(safeEvaluateExpression('datum.x', { x: 42 })).toBe(42); + }); + + it('evaluates bracket property access', () => { + expect(safeEvaluateExpression("datum['field name']", { 'field name': 'hello' })).toBe('hello'); + }); + + it('evaluates nested property access', () => { + expect(safeEvaluateExpression('datum.a.b', { a: { b: 99 } })).toBe(99); + }); + + it('evaluates arithmetic', () => { + expect(safeEvaluateExpression('datum.a + datum.b', { a: 3, b: 7 })).toBe(10); + expect(safeEvaluateExpression('datum.a * 2', { a: 5 })).toBe(10); + expect(safeEvaluateExpression('datum.a - datum.b', { a: 10, b: 3 })).toBe(7); + expect(safeEvaluateExpression('datum.a / datum.b', { a: 10, b: 2 })).toBe(5); + expect(safeEvaluateExpression('datum.a % 3', { a: 7 })).toBe(1); + }); + + it('evaluates comparison operators', () => { + expect(safeEvaluateExpression('datum.x > 100', { x: 150 })).toBe(true); + expect(safeEvaluateExpression('datum.x > 100', { x: 50 })).toBe(false); + expect(safeEvaluateExpression('datum.x < 100', { x: 50 })).toBe(true); + expect(safeEvaluateExpression('datum.x >= 100', { x: 100 })).toBe(true); + expect(safeEvaluateExpression('datum.x <= 100', { x: 100 })).toBe(true); + }); + + it('evaluates equality operators', () => { + expect(safeEvaluateExpression("datum.x === 'A'", { x: 'A' })).toBe(true); + expect(safeEvaluateExpression("datum.x === 'B'", { x: 'A' })).toBe(false); + expect(safeEvaluateExpression("datum.x !== 'A'", { x: 'B' })).toBe(true); + expect(safeEvaluateExpression('datum.x == null', { x: null })).toBe(true); + expect(safeEvaluateExpression('datum.x != null', { x: 42 })).toBe(true); + }); + + it('evaluates logical operators', () => { + expect(safeEvaluateExpression('datum.a > 0 && datum.b > 0', { a: 1, b: 2 })).toBe(true); + expect(safeEvaluateExpression('datum.a > 0 && datum.b > 0', { a: 1, b: -1 })).toBe(false); + expect(safeEvaluateExpression('datum.a > 0 || datum.b > 0', { a: -1, b: 2 })).toBe(true); + expect(safeEvaluateExpression('!datum.flag', { flag: false })).toBe(true); + }); + + it('evaluates ternary expressions', () => { + expect(safeEvaluateExpression("datum.x > 0 ? 'positive' : 'non-positive'", { x: 5 })).toBe('positive'); + expect(safeEvaluateExpression("datum.x > 0 ? 'positive' : 'non-positive'", { x: -1 })).toBe('non-positive'); + }); + + it('evaluates string concatenation', () => { + expect(safeEvaluateExpression("datum.first + ' ' + datum.last", { first: 'John', last: 'Doe' })).toBe('John Doe'); + }); + + it('evaluates numeric literals', () => { + expect(safeEvaluateExpression('42', {})).toBe(42); + expect(safeEvaluateExpression('3.14', {})).toBe(3.14); + expect(safeEvaluateExpression('-1', {})).toBe(-1); + }); + + it('evaluates string literals', () => { + expect(safeEvaluateExpression("'hello'", {})).toBe('hello'); + expect(safeEvaluateExpression('"world"', {})).toBe('world'); + }); + + it('evaluates boolean and null literals', () => { + expect(safeEvaluateExpression('true', {})).toBe(true); + expect(safeEvaluateExpression('false', {})).toBe(false); + expect(safeEvaluateExpression('null', {})).toBe(null); + }); + + it('evaluates safe built-in functions', () => { + expect(safeEvaluateExpression('isValid(datum.x)', { x: 42 })).toBe(true); + expect(safeEvaluateExpression('isValid(datum.x)', { x: null })).toBe(false); + expect(safeEvaluateExpression('isNumber(datum.x)', { x: 42 })).toBe(true); + expect(safeEvaluateExpression('isString(datum.x)', { x: 'hello' })).toBe(true); + expect(safeEvaluateExpression('abs(datum.x)', { x: -5 })).toBe(5); + expect(safeEvaluateExpression('floor(datum.x)', { x: 3.7 })).toBe(3); + expect(safeEvaluateExpression('ceil(datum.x)', { x: 3.2 })).toBe(4); + expect(safeEvaluateExpression('round(datum.x)', { x: 3.5 })).toBe(4); + expect(safeEvaluateExpression('pow(datum.x, 2)', { x: 3 })).toBe(9); + expect(safeEvaluateExpression('min(datum.a, datum.b)', { a: 3, b: 7 })).toBe(3); + expect(safeEvaluateExpression('max(datum.a, datum.b)', { a: 3, b: 7 })).toBe(7); + }); + + it('evaluates safe constants', () => { + expect(safeEvaluateExpression('PI', {})).toBe(Math.PI); + expect(safeEvaluateExpression('E', {})).toBe(Math.E); + }); + + it('evaluates parenthesized expressions', () => { + expect(safeEvaluateExpression('(datum.a + datum.b) * 2', { a: 3, b: 4 })).toBe(14); + }); + + it('handles unary operators', () => { + expect(safeEvaluateExpression('+datum.x', { x: '42' })).toBe(42); + expect(safeEvaluateExpression('-datum.x', { x: 5 })).toBe(-5); + }); + + it('handles undefined property access gracefully', () => { + expect(safeEvaluateExpression('datum.missing', {})).toBe(undefined); + }); + + it('handles typical Vega-Lite filter expressions', () => { + const datum = { Horsepower: 150, Year: 2005, Origin: 'USA' }; + expect(safeEvaluateExpression('datum.Horsepower > 100', datum)).toBe(true); + expect(safeEvaluateExpression('datum.Year > 2000', datum)).toBe(true); + expect(safeEvaluateExpression("datum.Origin === 'USA'", datum)).toBe(true); + }); + + it('handles typical Vega-Lite calculate expressions', () => { + const datum = { a: 10, b: 20 }; + expect(safeEvaluateExpression('2 * datum.b', datum)).toBe(40); + expect(safeEvaluateExpression('datum.a + datum.b', datum)).toBe(30); + }); + }); + + // ------------------------------------------------------------------- + // Malicious expressions that MUST be rejected + // ------------------------------------------------------------------- + describe('security — rejects malicious expressions', () => { + it('rejects assignment via globalThis (MSRC PoC)', () => { + expect(() => + safeEvaluateExpression("((globalThis.__msrcFluentUiPoc = 'owned-from-calculate'), 1337)", {}), + ).toThrow(); + }); + + it('rejects plain assignment', () => { + expect(() => safeEvaluateExpression("x = 'injected'", {})).toThrow(); + }); + + it('blocks constructor access — returns undefined, not the real constructor', () => { + // constructor is inherited (not own property), so access returns undefined + expect(safeEvaluateExpression('datum.constructor', {})).toBeUndefined(); + // Attempting to call it as a function still throws + expect(() => safeEvaluateExpression("datum.constructor('return this')()", {})).toThrow(); + }); + + it('blocks __proto__ access — returns undefined', () => { + expect(safeEvaluateExpression('datum.__proto__', {})).toBeUndefined(); + }); + + it('blocks prototype access — returns undefined', () => { + expect(safeEvaluateExpression('datum.prototype', {})).toBeUndefined(); + }); + + it('blocks constructor via bracket notation — returns undefined', () => { + expect(safeEvaluateExpression("datum['constructor']", {})).toBeUndefined(); + }); + + it('allows own property named constructor if explicitly in data', () => { + // If data explicitly has a "constructor" field, it IS accessible (it's just a string, not Function) + expect(safeEvaluateExpression('datum.constructor', { constructor: 'safe-value' })).toBe('safe-value'); + }); + + it('rejects access to window', () => { + expect(() => safeEvaluateExpression('window.location', {})).toThrow(); + }); + + it('rejects access to document', () => { + expect(() => safeEvaluateExpression('document.cookie', {})).toThrow(); + }); + + it('rejects access to globalThis', () => { + expect(() => safeEvaluateExpression('globalThis', {})).toThrow(); + }); + + it('rejects eval', () => { + expect(() => safeEvaluateExpression("eval('alert(1)')", {})).toThrow(); + }); + + it('rejects Function constructor', () => { + expect(() => safeEvaluateExpression("Function('return this')()", {})).toThrow(); + }); + + it('rejects require', () => { + expect(() => safeEvaluateExpression("require('child_process')", {})).toThrow(); + }); + + it('rejects import', () => { + expect(() => safeEvaluateExpression("import('fs')", {})).toThrow(); + }); + + it('rejects process access', () => { + expect(() => safeEvaluateExpression('process.env', {})).toThrow(); + }); + + it('rejects fetch', () => { + expect(() => safeEvaluateExpression("fetch('https://evil.com')", {})).toThrow(); + }); + + it('rejects prototype chain traversal', () => { + expect(() => safeEvaluateExpression("(0)['constructor']['constructor']('return globalThis')()", {})).toThrow(); + }); + + it('rejects calling non-function values', () => { + expect(() => safeEvaluateExpression('datum.x()', { x: 42 })).toThrow(); + }); + + it('rejects template literals (backticks)', () => { + expect(() => safeEvaluateExpression('`injected`', {})).toThrow(); + }); + + it('rejects semicolons (statement separator)', () => { + expect(() => safeEvaluateExpression("datum.x; alert('xss')", { x: 1 })).toThrow(); + }); + + it('rejects curly braces (block statements)', () => { + expect(() => safeEvaluateExpression("{ alert('xss') }", {})).toThrow(); + }); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.ts new file mode 100644 index 0000000000000..1998eb65dd98c --- /dev/null +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteExpressionEvaluator.ts @@ -0,0 +1,524 @@ +/** + * Safe expression evaluator for Vega-Lite expressions. + * + * Recursive-descent parser that only supports the safe subset of the + * Vega-Lite expression language: + * - Property access: datum.field, datum['field'] (own properties only) + * - Arithmetic: +, -, *, /, % + * - Comparison: ==, ===, !=, !==, <, >, <=, >= + * - Logical: &&, ||, ! + * - Ternary: condition ? trueVal : falseVal + * - Literals: numbers, strings, booleans, null + * - Safe built-in functions: isValid, isDate, isNumber, isNaN, isFinite, + * abs, ceil, floor, round, sqrt, log, exp, pow, min, max, length, + * toNumber, toString, toBoolean + * - Constants: PI, E, SQRT2, LN2, LN10, NaN, Infinity + * + * Everything else (assignment, arbitrary function calls, global access) is rejected. + * + * @see https://vega.github.io/vega/docs/expressions/ + */ + +// --------------------------------------------------------------------------- +// Token types +// --------------------------------------------------------------------------- +interface Token { + type: string; + value: unknown; + start: number; +} + +// --------------------------------------------------------------------------- +// Own-property check — only allow access to properties that exist directly +// on the object (not inherited from the prototype chain). This is an allowlist +// approach: constructor, __proto__, toString, valueOf, etc. are all inherited +// and thus blocked automatically without maintaining a blocklist. +// --------------------------------------------------------------------------- +const hasOwn = (obj: unknown, prop: string): boolean => + obj !== null && obj !== undefined && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, prop); + +// --------------------------------------------------------------------------- +// Whitelisted built-in functions (mirrors the Vega expression function set) +// --------------------------------------------------------------------------- +const SAFE_FUNCTIONS: Record unknown> = { + isValid: (x: unknown) => x !== null && x !== undefined && (typeof x !== 'number' || !isNaN(x as number)), + isDate: (x: unknown) => x instanceof Date, + isNumber: (x: unknown) => typeof x === 'number' && !isNaN(x as number), + isString: (x: unknown) => typeof x === 'string', + isBoolean: (x: unknown) => typeof x === 'boolean', + isArray: (x: unknown) => Array.isArray(x), + isNaN: (x: unknown) => isNaN(x as number), + isFinite: (x: unknown) => Number.isFinite(x), + abs: (x: unknown) => Math.abs(x as number), + ceil: (x: unknown) => Math.ceil(x as number), + floor: (x: unknown) => Math.floor(x as number), + round: (x: unknown) => Math.round(x as number), + sqrt: (x: unknown) => Math.sqrt(x as number), + log: (x: unknown) => Math.log(x as number), + exp: (x: unknown) => Math.exp(x as number), + pow: (x: unknown, y: unknown) => (x as number) ** (y as number), + min: (...args: unknown[]) => Math.min(...(args as number[])), + max: (...args: unknown[]) => Math.max(...(args as number[])), + length: (x: unknown) => (typeof x === 'string' || Array.isArray(x) ? (x as string | unknown[]).length : 0), + toNumber: (x: unknown) => Number(x), + toString: (x: unknown) => String(x), + toBoolean: (x: unknown) => Boolean(x), +}; + +// --------------------------------------------------------------------------- +// Whitelisted constants +// --------------------------------------------------------------------------- +const SAFE_CONSTANTS: Record = { + PI: Math.PI, + E: Math.E, + SQRT2: Math.SQRT2, + LN2: Math.LN2, + LN10: Math.LN10, + NaN, + Infinity, +}; + +// Set of all allowed top-level identifiers +const ALLOWED_IDENTIFIERS = new Set(['datum', ...Object.keys(SAFE_FUNCTIONS), ...Object.keys(SAFE_CONSTANTS)]); + +// --------------------------------------------------------------------------- +// Tokenizer +// --------------------------------------------------------------------------- +function tokenize(expr: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < expr.length) { + // Skip whitespace + if (/\s/.test(expr[i])) { + i++; + continue; + } + + // Numbers (including decimal and scientific notation) + if (/[0-9]/.test(expr[i]) || (expr[i] === '.' && i + 1 < expr.length && /[0-9]/.test(expr[i + 1]))) { + const start = i; + while (i < expr.length && /[0-9.]/.test(expr[i])) { + i++; + } + if (i < expr.length && (expr[i] === 'e' || expr[i] === 'E')) { + i++; + if (i < expr.length && (expr[i] === '+' || expr[i] === '-')) { + i++; + } + while (i < expr.length && /[0-9]/.test(expr[i])) { + i++; + } + } + tokens.push({ type: 'number', value: parseFloat(expr.slice(start, i)), start }); + continue; + } + + // Strings (single or double quoted) + if (expr[i] === '"' || expr[i] === "'") { + const quote = expr[i]; + const start = i; + i++; + let str = ''; + while (i < expr.length && expr[i] !== quote) { + if (expr[i] === '\\') { + i++; + if (i < expr.length) { + switch (expr[i]) { + case 'n': + str += '\n'; + break; + case 't': + str += '\t'; + break; + case '\\': + str += '\\'; + break; + case "'": + str += "'"; + break; + case '"': + str += '"'; + break; + default: + str += expr[i]; + } + } + } else { + str += expr[i]; + } + i++; + } + if (i < expr.length) { + i++; // Skip closing quote + } + tokens.push({ type: 'string', value: str, start }); + continue; + } + + // Identifiers and keywords + if (/[a-zA-Z_$]/.test(expr[i])) { + const start = i; + while (i < expr.length && /[a-zA-Z0-9_$]/.test(expr[i])) { + i++; + } + const word = expr.slice(start, i); + switch (word) { + case 'true': + tokens.push({ type: 'boolean', value: true, start }); + break; + case 'false': + tokens.push({ type: 'boolean', value: false, start }); + break; + case 'null': + tokens.push({ type: 'null', value: null, start }); + break; + case 'undefined': + tokens.push({ type: 'null', value: undefined, start }); + break; + default: + tokens.push({ type: 'ident', value: word, start }); + } + continue; + } + + // Multi-character operators + const start = i; + const three = expr.slice(i, i + 3); + const two = expr.slice(i, i + 2); + + if (three === '===' || three === '!==') { + tokens.push({ type: three, value: three, start }); + i += 3; + continue; + } + if (two === '==' || two === '!=' || two === '<=' || two === '>=' || two === '&&' || two === '||') { + tokens.push({ type: two, value: two, start }); + i += 2; + continue; + } + + // Single-character operators and punctuation + const ch = expr[i]; + if ('+-*/%<>!.()[],?:'.includes(ch)) { + tokens.push({ type: ch, value: ch, start }); + i++; + continue; + } + + // Any other character is rejected + throw new Error(`Safe expression evaluator: unexpected character '${ch}' at position ${i}`); + } + + tokens.push({ type: 'eof', value: null, start: i }); + return tokens; +} + +// --------------------------------------------------------------------------- +// Recursive-descent parser & evaluator +// +// Grammar (precedence low→high): +// expr → ternary +// ternary → logicalOr ('?' expr ':' expr)? +// logicalOr → logicalAnd ('||' logicalAnd)* +// logicalAnd → equality ('&&' equality)* +// equality → comparison (('=='|'==='|'!='|'!==') comparison)* +// comparison → additive (('<'|'>'|'<='|'>=') additive)* +// additive → multiplicative (('+'|'-') multiplicative)* +// multiplicative → unary (('*'|'/'|'%') unary)* +// unary → ('!'|'-'|'+') unary | postfix +// postfix → primary ('.' ident | '[' expr ']' | '(' args ')')* +// primary → number | string | boolean | null | ident | '(' expr ')' +// --------------------------------------------------------------------------- +class ExpressionParser { + private tokens: Token[]; + private pos: number; + private context: Record; + + constructor(tokens: Token[], context: Record) { + this.tokens = tokens; + this.pos = 0; + this.context = context; + } + + public parse(): unknown { + const result = this._parseExpression(); + if (this._peek().type !== 'eof') { + throw new Error( + `Safe expression evaluator: unexpected token '${this._peek().type}' at position ${this._peek().start}`, + ); + } + return result; + } + + private _peek(): Token { + return this.tokens[this.pos]; + } + + private _advance(): Token { + const token = this.tokens[this.pos]; + this.pos++; + return token; + } + + private _expect(type: string): Token { + const token = this._peek(); + if (token.type !== type) { + throw new Error( + `Safe expression evaluator: expected '${type}' but got '${token.type}' at position ${token.start}`, + ); + } + return this._advance(); + } + + private _parseExpression(): unknown { + return this._parseTernary(); + } + + private _parseTernary(): unknown { + const condition = this._parseOr(); + if (this._peek().type === '?') { + this._advance(); + const trueVal = this._parseExpression(); + this._expect(':'); + const falseVal = this._parseExpression(); + return condition ? trueVal : falseVal; + } + return condition; + } + + private _parseOr(): unknown { + let left = this._parseAnd(); + while (this._peek().type === '||') { + this._advance(); + const right = this._parseAnd(); + left = left || right; + } + return left; + } + + private _parseAnd(): unknown { + let left = this._parseEquality(); + while (this._peek().type === '&&') { + this._advance(); + const right = this._parseEquality(); + left = left && right; + } + return left; + } + + private _parseEquality(): unknown { + let left = this._parseComparison(); + while (['==', '===', '!=', '!=='].includes(this._peek().type)) { + const op = this._advance().type; + const right = this._parseComparison(); + switch (op) { + case '==': + // eslint-disable-next-line eqeqeq + left = left == right; + break; + case '===': + left = left === right; + break; + case '!=': + // eslint-disable-next-line eqeqeq + left = left != right; + break; + case '!==': + left = left !== right; + break; + } + } + return left; + } + + private _parseComparison(): unknown { + let left = this._parseAdditive(); + while (['<', '>', '<=', '>='].includes(this._peek().type)) { + const op = this._advance().type; + const right = this._parseAdditive(); + switch (op) { + case '<': + left = (left as number) < (right as number); + break; + case '>': + left = (left as number) > (right as number); + break; + case '<=': + left = (left as number) <= (right as number); + break; + case '>=': + left = (left as number) >= (right as number); + break; + } + } + return left; + } + + private _parseAdditive(): unknown { + let left = this._parseMultiplicative(); + while (['+', '-'].includes(this._peek().type)) { + const op = this._advance().type; + const right = this._parseMultiplicative(); + if (op === '+') { + left = + typeof left === 'string' || typeof right === 'string' + ? String(left) + String(right) + : (left as number) + (right as number); + } else { + left = (left as number) - (right as number); + } + } + return left; + } + + private _parseMultiplicative(): unknown { + let left = this._parseUnary(); + while (['*', '/', '%'].includes(this._peek().type)) { + const op = this._advance().type; + const right = this._parseUnary(); + switch (op) { + case '*': + left = (left as number) * (right as number); + break; + case '/': + left = (left as number) / (right as number); + break; + case '%': + left = (left as number) % (right as number); + break; + } + } + return left; + } + + private _parseUnary(): unknown { + if (this._peek().type === '!') { + this._advance(); + return !this._parseUnary(); + } + if (this._peek().type === '-') { + this._advance(); + return -(this._parseUnary() as number); + } + if (this._peek().type === '+') { + this._advance(); + return +(this._parseUnary() as number); + } + return this._parsePostfix(); + } + + private _parsePostfix(): unknown { + let value = this._parsePrimary(); + + while (true) { + if (this._peek().type === '.') { + // Property access: obj.prop — only own properties allowed + this._advance(); + const prop = this._expect('ident'); + const propName = prop.value as string; + + if (hasOwn(value, propName)) { + value = (value as Record)[propName]; + } else { + value = undefined; + } + } else if (this._peek().type === '[') { + // Bracket access: obj['prop'] or obj[expr] — only own properties allowed + this._advance(); + const index = this._parseExpression(); + this._expect(']'); + + const key = String(index); + if (hasOwn(value, key)) { + value = (value as Record)[key]; + } else { + value = undefined; + } + } else if (this._peek().type === '(') { + // Function call — only safe built-in functions are callable + if (typeof value !== 'function') { + throw new Error('Safe expression evaluator: function calls are only allowed for built-in functions'); + } + this._advance(); + const args: unknown[] = []; + if (this._peek().type !== ')') { + args.push(this._parseExpression()); + while (this._peek().type === ',') { + this._advance(); + args.push(this._parseExpression()); + } + } + this._expect(')'); + value = (value as (...a: unknown[]) => unknown)(...args); + } else { + break; + } + } + + return value; + } + + private _parsePrimary(): unknown { + const token = this._peek(); + + switch (token.type) { + case 'number': + case 'string': + case 'boolean': + case 'null': + this._advance(); + return token.value; + + case 'ident': { + const name = token.value as string; + if (!ALLOWED_IDENTIFIERS.has(name)) { + throw new Error(`Safe expression evaluator: unknown identifier '${name}'`); + } + this._advance(); + if (name === 'datum') { + return this.context.datum; + } + if (name in SAFE_FUNCTIONS) { + return SAFE_FUNCTIONS[name]; + } + if (name in SAFE_CONSTANTS) { + return SAFE_CONSTANTS[name]; + } + return undefined; + } + + case '(': { + this._advance(); + const result = this._parseExpression(); + this._expect(')'); + return result; + } + + default: + throw new Error(`Safe expression evaluator: unexpected token '${token.type}' at position ${token.start}`); + } + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Safely evaluates a Vega-Lite expression string against a datum object. + * + * Only allows property access, arithmetic, comparison, logical operators, + * ternary expressions, literals, and a whitelist of built-in functions. + * Rejects assignment, arbitrary function calls, and global object access. + * + * @param expr - The Vega-Lite expression string to evaluate + * @param datum - The data row object (accessible as `datum` in the expression) + * @returns The result of evaluating the expression + * @throws Error if the expression contains disallowed constructs + */ +export function safeEvaluateExpression(expr: string, datum: Record): unknown { + const tokens = tokenize(expr); + const parser = new ExpressionParser(tokens, { datum }); + return parser.parse(); +} diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts index 61143eea2efa9..ca2bdc89c04af 100644 --- a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteSchemaAdapter.ts @@ -45,6 +45,7 @@ import type { ColorFillBarsProps } from '../LineChart/index'; import type { Legend, LegendsProps } from '../Legends/index'; import type { TitleStyles } from '../../utilities/Common.styles'; import { getVegaColorFromMap, getVegaColor, getSequentialSchemeColors } from './VegaLiteColorAdapter'; +import { safeEvaluateExpression } from './VegaLiteExpressionEvaluator'; import type { ColorMapRef } from './VegaLiteColorAdapter'; import { bin as d3Bin, extent as d3Extent, sum as d3Sum, min as d3Min, max as d3Max, mean as d3Mean } from 'd3-array'; import type { Bin } from 'd3-array'; @@ -234,9 +235,7 @@ function applyTransforms( if (typeof filterExpr === 'string') { result = result.filter(row => { try { - const datum = row; - // eslint-disable-next-line no-new-func - return new Function('datum', `return ${filterExpr}`)(datum); + return safeEvaluateExpression(filterExpr, row); } catch { return true; } @@ -250,9 +249,7 @@ function applyTransforms( const asField = transform.as as string; result = result.map(row => { try { - const datum = row; - // eslint-disable-next-line no-new-func - const value = new Function('datum', `return ${expr}`)(datum); + const value = safeEvaluateExpression(expr, row); return { ...row, [asField]: value }; } catch { return row; @@ -1185,10 +1182,8 @@ function initializeTransformContext(spec: VegaLiteSpec) { dataValues.forEach(row => { try { - const datum = row; - // eslint-disable-next-line no-new-func - const result = new Function('datum', `return ${condition.test}`)(datum); - row[colorField] = result ? condition.value : elseValue; + const testResult = safeEvaluateExpression(condition.test, row); + row[colorField] = testResult ? condition.value : elseValue; } catch { row[colorField] = elseValue; } diff --git a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts index 2bd7697f12a63..4023301a944c3 100644 --- a/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts +++ b/packages/charts/react-charts/library/src/components/VegaDeclarativeChart/VegaLiteTypes.ts @@ -286,6 +286,14 @@ export interface VegaLiteColorEncoding { * Fixed color value */ value?: string; + + /** + * Conditional color encoding with a test expression + */ + condition?: { + test: string; + value: string; + }; } /**