openmrs-esm-core icon indicating copy to clipboard operation
openmrs-esm-core copied to clipboard

(feat) O3-3708: Add a safer expression runner

Open ibacher opened this issue 1 year ago • 2 comments

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, or chore, among others). See existing PR titles for inspiration.

For changes to apps

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 Date and RegExp objects can be constructed with new in an expression
    • None of the bit-shifting or bit-wise operations are implemented
    • The available Object global only implements a subset of functions (assign, fromEntries, hasOwn, keys, values and is).
    • The Function global 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.
  • Providing access to libraries, e.g., dayjs, likely requires importing the whole object, i.e., import * as dayjs from 'dayjs'; then passing dayjs into the variables object.

ibacher avatar Aug 02 '24 19:08 ibacher