Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion src/plugins/comps/CompsMathWorker.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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' });
Expand Down
263 changes: 263 additions & 0 deletions src/plugins/comps/securedMath.js
Original file line number Diff line number Diff line change
@@ -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);
}
80 changes: 80 additions & 0 deletions src/plugins/comps/securedMathSpec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});