openmrs-esm-core
openmrs-esm-core copied to clipboard
(feat) O3-3708: Add a safer expression runner
Requirements
- [x] This PR has a title that briefly describes the work done including the ticket number. Ensure your PR title includes a conventional commit label (such as
feat,fix, orchore, among others). See existing PR titles for inspiration.
For changes to apps
- [x] My work conforms to the O3 Styleguide and design documentation.
If applicable
- [x] My work includes tests or is validated by existing tests.
- [x] I have updated the esm-framework mock to reflect any API changes I have made.
Summary
There are many places in the application where it would be helpful to allow implementers, form designers, etc. to use Javascript expressions to control various behaviour, e.g., hiding or showing widgets and extensions based on logic that we don't want to hard-code in the application. Currently, the supported way of doing this is something like this (from the form engine):
Function(...Object.keys(expressionContext), `"use strict"; return (${expression})`).call(
undefined,
...Object.values(expressionContext),
);
Essentially creating a single-use function that evaluates an arbitrary JS expression. This technique is substantially safer than just calling eval(); however it still suffers from some risk of security issues, specifically in that any CSP for OpenMRS must allow these type of dynamic function executions, which opens a door that third-party scripts could exploit to define an execute arbitrary code. Additionally, it allows expressions to be written and executed which may have harmful side-effects, e.g., by accessing the window object or other globals.
This PR introduces a new approach to the problem by implementing a simple Javascript expression evaluator based on the jsep parser with several plugins. It supports the bulk of Javascript that we should need for such use-cases via a (hopefully) simple API:
evaluate('myValue * 2', { myValue: 10 }); // produces 20
Although, it also supports more realistic scenarios like (modified from the form engine tests):
evaluateAsBoolean(
"includes(referredToPreventionServices, '88cdde2b-753b-48ac-a51a-ae5e1ab24846') && !includes(referredToPreventionServices, '1691AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')",
{
referredToPreventionServices: ['88cdde2b-753b-48ac-a51a-ae5e1ab24846'],
includes: <T>(collection: Array<T>, value: T) => Array.prototype.includes.call(collection, value),
},
),
The test suite should give an idea of the features supported.
Finally for use-cases where an expression needs to be evaluated multiple times (e.g., on each render), there is a compile() function which takes an expression, runs it through jsep and returns the resulting AST. This AST can be re-used for multiple function calls and should eliminate whatever overhead there is to parsing (though its pretty minimal). E.g.,
const expr = compile('myValue * 2');
evaluate(expr, { myValue: 10 }); // produces 20
evaluate(expr, { myValue:5 }); // produces 10
In addition to adding the engine, this PR also adds an expression property to the Display conditions configuration for extensions. If there is an expression defined, it will be evaluated with access to a parameter called session that holds the current Session object. This can be used to, e.g., hide extensions based on the user's currently logged-in location or their user name, whether or not they have a current provider account, etc.
Screenshots
Related Issue
https://openmrs.atlassian.net/browse/O3-3708
Other
Known limitations:
- jsep was initially written in 2013 and does not have full support for more modern versions of ECMAScript. So far, the only limitation I've come across is an inability to support BigInt literals.
- The expression evaluator only evaluates Javascript expressions, so it does not support assignment operators or blocks of code.
- The evaluator intentionally does not implement all possible features including:
- Only
DateandRegExpobjects can be constructed withnewin an expression - None of the bit-shifting or bit-wise operations are implemented
- The available
Objectglobal only implements a subset of functions (assign,fromEntries,hasOwn,keys,valuesandis). - The
Functionglobal is not available. Arrow functions can be used, though. - Object expressions are not supported (e.g.
{ a: 1, b:2 }[b]; you can access properties of objects supplied as variables however) - Expression results can only be strings, numbers, booleans, Dates, and null or undefined by default. It is possible to limit this further, and theoretically to support additional types, but not all types can be created due to the above restrictiions.
- Only
- Providing access to libraries, e.g., dayjs, likely requires importing the whole object, i.e.,
import * as dayjs from 'dayjs';then passingdayjsinto the variables object.