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;
+ };
}
/**