From 1411df8ba6542cbe17210c7957291e141d216b1f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:49:39 +0000 Subject: [PATCH] Harden Comps SharedWorker mathjs evaluation against untrusted expressions The Comps SharedWorker evaluated the persisted, attacker-controllable comps expression with the full mathjs function set and no bounds, so a crafted expression (e.g. zeros(1e9), ones(1e6,1e6), range(0,1e12), or a recursive function assignment) could hang/OOM the worker shared across all comps consumers in a victim's browser origin. Add a hardened mathjs instance (securedMath.js) that disables the code-exec-adjacent parser functions per mathjs security guidance, enforces a function/constant allow-list, rejects user-defined function assignments, and caps expression length and parse-tree size. Validate the worker message payload shape and the expression before evaluation. Co-Authored-By: Samantha Taylor --- src/plugins/comps/CompsMathWorker.js | 27 ++- src/plugins/comps/securedMath.js | 263 +++++++++++++++++++++++++++ src/plugins/comps/securedMathSpec.js | 80 ++++++++ 3 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/plugins/comps/securedMath.js create mode 100644 src/plugins/comps/securedMathSpec.js diff --git a/src/plugins/comps/CompsMathWorker.js b/src/plugins/comps/CompsMathWorker.js index a0097c00ef..5314616b7e 100644 --- a/src/plugins/comps/CompsMathWorker.js +++ b/src/plugins/comps/CompsMathWorker.js @@ -1,4 +1,27 @@ -import { evaluate } from 'mathjs'; +import { evaluate, validateExpression } from './securedMath.js'; + +function assertValidCalculationPayload(telemetryForComps, parameters, expression) { + if (typeof expression !== 'string') { + throw new Error('Invalid payload: expression must be a string'); + } + if (!Array.isArray(parameters)) { + throw new Error('Invalid payload: parameters must be an array'); + } + if (typeof telemetryForComps !== 'object' || telemetryForComps === null) { + throw new Error('Invalid payload: telemetryForComps must be an object'); + } + parameters.forEach((parameter) => { + if (typeof parameter?.name !== 'string' || typeof parameter?.keyString !== 'string') { + throw new Error('Invalid payload: malformed parameter'); + } + }); + // Enforce the allow-list, length, and complexity bounds up front, before any + // untrusted expression reaches the mathjs engine. + validateExpression( + expression, + parameters.map((parameter) => parameter.name) + ); +} // eslint-disable-next-line no-undef onconnect = function (e) { @@ -14,9 +37,11 @@ onconnect = function (e) { if (type === 'calculateRequest') { responseType = 'calculationRequestResult'; console.debug(`📫 Received new calculation request with callback ID ${callbackID}`); + assertValidCalculationPayload(telemetryForComps, parameters, expression); result = calculateRequest(telemetryForComps, parameters, expression); } else if (type === 'calculateSubscription') { responseType = 'calculationSubscriptionResult'; + assertValidCalculationPayload(telemetryForComps, parameters, expression); result = calculateSubscription(telemetryForComps, newTelemetry, parameters, expression); } else if (type === 'init') { port.postMessage({ type: 'ready' }); diff --git a/src/plugins/comps/securedMath.js b/src/plugins/comps/securedMath.js new file mode 100644 index 0000000000..cdb373bfa5 --- /dev/null +++ b/src/plugins/comps/securedMath.js @@ -0,0 +1,263 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { all, create } from 'mathjs'; + +/** + * A hardened mathjs instance and helpers for safely evaluating the untrusted + * expressions stored on `comps` (Derived Telemetry) domain objects. + * + * Because a comps expression is attacker-controllable (it is persisted on a + * shared domain object and forwarded verbatim to the Comps SharedWorker), we + * must not hand it to the full mathjs function set. This module: + * - disables the code-exec-adjacent parser functions per mathjs security + * guidance (evaluate/parse/simplify/derivative/resolve/import/createUnit), + * - enforces an allow-list of function/constant names so allocation-scaling + * constructors (zeros/ones/range/matrix/identity/...) cannot be reached, + * - rejects user-defined (potentially recursive) function assignments, + * - caps the expression length and parse-tree size to bound work. + * + * @see https://mathjs.org/docs/expressions/security.html + */ + +const math = create(all, {}); + +// Capture the trusted internals before we override the insecure ones, so our +// own code can still parse/evaluate while expressions cannot call them. +const secureParse = math.parse.bind(math); +const secureEvaluate = math.evaluate.bind(math); + +function disabled(name) { + return function () { + throw new Error(`Function "${name}" is disabled`); + }; +} + +math.import( + { + import: disabled('import'), + createUnit: disabled('createUnit'), + reviver: disabled('reviver'), + evaluate: disabled('evaluate'), + parse: disabled('parse'), + simplify: disabled('simplify'), + derivative: disabled('derivative'), + resolve: disabled('resolve') + }, + { override: true } +); + +/** Maximum number of characters permitted in a comps expression. */ +export const MAX_EXPRESSION_LENGTH = 512; + +/** Maximum number of nodes permitted in a parsed comps expression. */ +export const MAX_EXPRESSION_NODES = 100; + +// Allow-list of mathjs function names that are safe for scalar/array telemetry +// math. Anything that allocates or computes proportionally to a caller-supplied +// integer (zeros, ones, range, matrix, identity, resize, reshape, factorial, +// combinations, ...) is intentionally excluded. +const ALLOWED_FUNCTIONS = new Set([ + 'abs', + 'add', + 'subtract', + 'multiply', + 'divide', + 'dotDivide', + 'dotMultiply', + 'mod', + 'unaryMinus', + 'unaryPlus', + 'pow', + 'sqrt', + 'cbrt', + 'nthRoot', + 'square', + 'cube', + 'exp', + 'expm1', + 'log', + 'log2', + 'log10', + 'log1p', + 'sin', + 'cos', + 'tan', + 'asin', + 'acos', + 'atan', + 'atan2', + 'sec', + 'csc', + 'cot', + 'sinh', + 'cosh', + 'tanh', + 'asinh', + 'acosh', + 'atanh', + 'ceil', + 'floor', + 'round', + 'fix', + 'sign', + 'min', + 'max', + 'mean', + 'median', + 'mode', + 'std', + 'variance', + 'sum', + 'prod', + 'gcd', + 'lcm', + 'hypot', + 'norm', + 'and', + 'or', + 'not', + 'xor', + 'equal', + 'unequal', + 'smaller', + 'smallerEq', + 'larger', + 'largerEq', + 'compare', + 'bitAnd', + 'bitOr', + 'bitXor', + 'bitNot', + 'leftShift', + 'rightArithShift', + 'rightLogShift', + 'isNaN', + 'isPositive', + 'isNegative', + 'isZero', + 'isInteger', + 'number' +]); + +// Allow-list of mathjs constant names. +const ALLOWED_CONSTANTS = new Set([ + 'pi', + 'tau', + 'e', + 'phi', + 'PI', + 'E', + 'LN2', + 'LN10', + 'LOG2E', + 'LOG10E', + 'SQRT1_2', + 'SQRT2', + 'Infinity', + 'NaN', + 'true', + 'false', + 'null' +]); + +/** + * Validate an untrusted comps expression before evaluation. + * + * @param {string} expression the raw, attacker-controllable expression + * @param {string[]} [scopeNames] names defined in the evaluation scope + * (parameter names). These are always permitted as symbols. + * @throws {Error} if the expression is malformed, too large, or references a + * disallowed mathjs function/constant or a function definition. + */ +export function validateExpression(expression, scopeNames = []) { + if (typeof expression !== 'string') { + throw new Error('Expression must be a string'); + } + if (expression.length > MAX_EXPRESSION_LENGTH) { + throw new Error(`Expression exceeds maximum length of ${MAX_EXPRESSION_LENGTH} characters`); + } + + const allowedSymbols = new Set(scopeNames); + const parsed = secureParse(expression); + + let nodeCount = 0; + parsed.traverse((node) => { + nodeCount += 1; + if (nodeCount > MAX_EXPRESSION_NODES) { + throw new Error(`Expression exceeds maximum complexity of ${MAX_EXPRESSION_NODES} nodes`); + } + + // Disallow user-defined functions, which enable unbounded recursion. + if (node.isFunctionAssignmentNode) { + throw new Error('Function definitions are not allowed in expressions'); + } + + if (node.isSymbolNode || node.isFunctionNode) { + const name = node.name; + // Names provided by the evaluation scope (parameters) are always allowed. + if (allowedSymbols.has(name)) { + return; + } + // A name that is not a mathjs builtin is treated as a scope reference; + // evaluation will throw if it is genuinely undefined. + if (math[name] === undefined) { + return; + } + if (node.isFunctionNode) { + if (!ALLOWED_FUNCTIONS.has(name)) { + throw new Error(`Function "${name}" is not allowed in expressions`); + } + } else if (!ALLOWED_CONSTANTS.has(name) && !ALLOWED_FUNCTIONS.has(name)) { + throw new Error(`Symbol "${name}" is not allowed in expressions`); + } + } + }); +} + +/** + * Evaluate an expression against a scope using the hardened mathjs instance. + * + * The insecure parser functions are disabled on this instance, but callers are + * still responsible for having validated the expression content (via + * {@link validateExpression}) at least once beforehand. Prefer + * {@link evaluateExpression} for one-shot use. + * + * @param {string} expression + * @param {Object} [scope] mapping of parameter names to values + * @returns {*} the computed value + */ +export function evaluate(expression, scope = {}) { + return secureEvaluate(expression, scope); +} + +/** + * Validate and evaluate an untrusted comps expression against a scope. + * + * @param {string} expression the raw, attacker-controllable expression + * @param {Object} [scope] mapping of parameter names to values + * @returns {*} the computed value + */ +export function evaluateExpression(expression, scope = {}) { + validateExpression(expression, Object.keys(scope)); + return secureEvaluate(expression, scope); +} diff --git a/src/plugins/comps/securedMathSpec.js b/src/plugins/comps/securedMathSpec.js new file mode 100644 index 0000000000..54d813fa62 --- /dev/null +++ b/src/plugins/comps/securedMathSpec.js @@ -0,0 +1,80 @@ +/***************************************************************************** + * Open MCT, Copyright (c) 2014-2024, United States Government + * as represented by the Administrator of the National Aeronautics and Space + * Administration. All rights reserved. + * + * Open MCT is licensed under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + * + * Open MCT includes source code licensed under additional open source + * licenses. See the Open Source Licenses file (LICENSES.md) included with + * this source code distribution or the Licensing information page available + * at runtime from the About dialog for additional information. + *****************************************************************************/ + +import { evaluateExpression, MAX_EXPRESSION_LENGTH, validateExpression } from './securedMath.js'; + +describe('securedMath', () => { + describe('evaluateExpression', () => { + it('evaluates simple arithmetic against a scope', () => { + expect(evaluateExpression('a * 2', { a: 3 })).toBe(6); + }); + + it('supports allow-listed functions and constants', () => { + expect(evaluateExpression('min(a, b) + max(a, b)', { a: 2, b: 5 })).toBe(7); + expect(evaluateExpression('sqrt(a)', { a: 16 })).toBe(4); + expect(evaluateExpression('round(cos(0))', {})).toBe(1); + }); + }); + + describe('validateExpression', () => { + it('rejects non-string expressions', () => { + expect(() => validateExpression(42)).toThrow(); + expect(() => validateExpression(null)).toThrow(); + expect(() => validateExpression({})).toThrow(); + }); + + it('rejects expressions exceeding the length cap', () => { + const tooLong = `${'a'.repeat(MAX_EXPRESSION_LENGTH + 1)}`; + expect(() => validateExpression(tooLong, ['a'])).toThrow(); + }); + + it('rejects allocation-scaling matrix/array constructors', () => { + ['zeros(1e9)', 'ones(1e6, 1e6)', 'range(0, 1e12)', 'matrix([1, 2, 3])'].forEach( + (expression) => { + expect(() => validateExpression(expression)).toThrow(); + } + ); + }); + + it('rejects user-defined (potentially recursive) function assignments', () => { + expect(() => validateExpression('f(x) = f(x)')).toThrow(); + }); + + it('rejects the code-exec-adjacent parser functions', () => { + ['evaluate("2+2")', 'parse("2+2")', 'simplify("x+x")', 'import({})'].forEach((expression) => { + expect(() => validateExpression(expression)).toThrow(); + }); + }); + + it('rejects mathjs builtins that are not allow-listed', () => { + expect(() => validateExpression('factorial(a)', ['a'])).toThrow(); + }); + + it('permits scope parameter names as symbols', () => { + expect(() => validateExpression('a + b', ['a', 'b'])).not.toThrow(); + }); + }); + + it('does not allow disabled functions to be evaluated even when parsed', () => { + expect(() => evaluateExpression('zeros(1e9)')).toThrow(); + }); +});