@openmrs/esm-expression-evaluator 10.0.1-pre.5426 → 10.0.1-pre.5434
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/README.md +24 -0
- package/dist/evaluator.js +127 -56
- package/dist/extractor.js +5 -0
- package/package.json +1 -1
- package/src/evaluator.test.ts +144 -0
- package/src/evaluator.ts +155 -53
- package/src/extractor.test.ts +6 -0
- package/src/extractor.ts +7 -0
package/.turbo/turbo-build.log
CHANGED
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
1
|
# openmrs-esm-expression-evaluator
|
|
2
2
|
|
|
3
3
|
openmrs-esm-expression-evaluator provides functions to allow evaluating user-defined expressions in a way safer than eval.
|
|
4
|
+
|
|
5
|
+
## Writing null-safe expressions
|
|
6
|
+
|
|
7
|
+
Member access on `null` or `undefined` is an error, as it is in JavaScript, so an expression that runs
|
|
8
|
+
against data which may not have loaded yet needs a guard. `&&`, `||` and `??` short-circuit, and optional
|
|
9
|
+
chaining is supported, so any of these work:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
session?.user ? session.user.privileges.some((p) => p.display === 'Some Privilege') : false;
|
|
13
|
+
session && session.user && session.user.privileges.length > 0;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
A short circuit abandons the rest of the chain rather than a single access, so `a?.b.c.d` is `undefined`
|
|
17
|
+
when `a` is nullish. A property that is genuinely missing is still an error: `a?.b.c` throws when `a` is
|
|
18
|
+
`{}`, because `a.b` is undefined for reasons optional chaining says nothing about.
|
|
19
|
+
|
|
20
|
+
## What the sandbox guarantees
|
|
21
|
+
|
|
22
|
+
Expressions are interpreted from a jsep AST; they are never compiled or run by the JS engine. The
|
|
23
|
+
interpreter supports only the expression language, so there is no assignment, no statements, no object
|
|
24
|
+
literals, and no `this`. Inline arrow functions *are* supported, since callbacks like
|
|
25
|
+
`arr.find((v) => v === needle)` are a large part of what expressions are for; what is prohibited is
|
|
26
|
+
building a function from a string, which is what an escape to the `Function` constructor would give you.
|
|
27
|
+
An expression sees nothing but the `variables` it is handed and the small set of globals in `globals.ts`.
|
package/dist/evaluator.js
CHANGED
|
@@ -305,11 +305,11 @@ function visitExpression(expression, context) {
|
|
|
305
305
|
case 'ConditionalExpression':
|
|
306
306
|
return visitConditionalExpression(expression, context);
|
|
307
307
|
case 'CallExpression':
|
|
308
|
-
return visitCallExpression(expression, context);
|
|
308
|
+
return unwrapShortCircuit(visitCallExpression(expression, context));
|
|
309
309
|
case 'ArrowFunctionExpression':
|
|
310
310
|
return visitArrowFunctionExpression(expression, context);
|
|
311
311
|
case 'MemberExpression':
|
|
312
|
-
return visitMemberExpression(expression, context);
|
|
312
|
+
return unwrapShortCircuit(visitMemberExpression(expression, context));
|
|
313
313
|
case 'ArrayExpression':
|
|
314
314
|
return visitArrayExpression(expression, context);
|
|
315
315
|
case 'SequenceExpression':
|
|
@@ -356,6 +356,15 @@ function visitUnaryExpression(expression, context) {
|
|
|
356
356
|
}
|
|
357
357
|
}
|
|
358
358
|
function visitBinaryExpression(expression, context) {
|
|
359
|
+
// these binary operators are meant to short-circuit
|
|
360
|
+
switch(expression.operator){
|
|
361
|
+
case '&&':
|
|
362
|
+
return visitExpression(expression.left, context) && visitExpression(expression.right, context);
|
|
363
|
+
case '||':
|
|
364
|
+
return visitExpression(expression.left, context) || visitExpression(expression.right, context);
|
|
365
|
+
case '??':
|
|
366
|
+
return visitExpression(expression.left, context) ?? visitExpression(expression.right, context);
|
|
367
|
+
}
|
|
359
368
|
let left = visitExpression(expression.left, context);
|
|
360
369
|
let right = visitExpression(expression.right, context);
|
|
361
370
|
switch(expression.operator){
|
|
@@ -389,12 +398,6 @@ function visitBinaryExpression(expression, context) {
|
|
|
389
398
|
return left <= right;
|
|
390
399
|
case 'in':
|
|
391
400
|
return left in right;
|
|
392
|
-
case '&&':
|
|
393
|
-
return left && right;
|
|
394
|
-
case '||':
|
|
395
|
-
return left || right;
|
|
396
|
-
case '??':
|
|
397
|
-
return left ?? right;
|
|
398
401
|
default:
|
|
399
402
|
throw `Expression evaluator does not support operator '${expression.operator}' operator`;
|
|
400
403
|
}
|
|
@@ -404,8 +407,16 @@ function visitConditionalExpression(expression, context) {
|
|
|
404
407
|
return test ? visitExpression(expression.consequent, context) : visitExpression(expression.alternate, context);
|
|
405
408
|
}
|
|
406
409
|
function visitCallExpression(expression, context) {
|
|
410
|
+
let callee = visitChainOperand(expression.callee, context);
|
|
411
|
+
if (callee === shortCircuit) {
|
|
412
|
+
return shortCircuit;
|
|
413
|
+
}
|
|
414
|
+
// `cb?.()` abandons the chain before its arguments are evaluated, so this has to come first; otherwise
|
|
415
|
+
// an argument's side effects happen for a call that never takes place
|
|
416
|
+
if (expression.optional && (callee === null || callee === undefined)) {
|
|
417
|
+
return shortCircuit;
|
|
418
|
+
}
|
|
407
419
|
let args = expression.arguments?.map(handleNullableExpression(context));
|
|
408
|
-
let callee = visitExpression(expression.callee, context);
|
|
409
420
|
if (!callee) {
|
|
410
421
|
throw `No function named ${getCallTargetName(expression.callee)} is defined in this context`;
|
|
411
422
|
} else if (!(typeof callee === 'function')) {
|
|
@@ -437,10 +448,19 @@ function visitArrowFunctionExpression(expression, context) {
|
|
|
437
448
|
return acc;
|
|
438
449
|
}, []));
|
|
439
450
|
return visitExpression(expression.body, context.addVariables(vars));
|
|
440
|
-
}).bind(
|
|
451
|
+
}).bind(null);
|
|
441
452
|
}
|
|
442
453
|
function visitMemberExpression(expression, context) {
|
|
443
|
-
|
|
454
|
+
const obj = visitChainOperand(expression.object, context);
|
|
455
|
+
if (obj === shortCircuit) {
|
|
456
|
+
return shortCircuit;
|
|
457
|
+
}
|
|
458
|
+
if (expression.optional && (obj === null || obj === undefined)) {
|
|
459
|
+
return shortCircuit;
|
|
460
|
+
}
|
|
461
|
+
if (obj === null) {
|
|
462
|
+
throw TypeError(`TypeError: cannot read properties of null (reading '${describePropertyName(expression.property, context)}')`);
|
|
463
|
+
}
|
|
444
464
|
if (obj === undefined) {
|
|
445
465
|
switch(expression.object.type){
|
|
446
466
|
case 'Identifier':
|
|
@@ -450,43 +470,21 @@ function visitMemberExpression(expression, context) {
|
|
|
450
470
|
}
|
|
451
471
|
case 'MemberExpression':
|
|
452
472
|
{
|
|
453
|
-
|
|
454
|
-
throw TypeError(`TypeError: cannot read properties of undefined (reading '${propertyName}')`);
|
|
473
|
+
throw TypeError(`TypeError: cannot read properties of undefined (reading '${describePropertyName(expression.property, context)}')`);
|
|
455
474
|
}
|
|
456
475
|
default:
|
|
457
476
|
throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
|
|
458
477
|
}
|
|
459
478
|
}
|
|
460
|
-
|
|
461
|
-
if (typeof obj === 'string') {
|
|
462
|
-
newObj = String.prototype;
|
|
463
|
-
} else if (typeof obj === 'number') {
|
|
464
|
-
newObj = Number.prototype;
|
|
465
|
-
} else if (typeof obj === 'function') {
|
|
466
|
-
// no-op
|
|
467
|
-
} else if (typeof obj !== 'object') {
|
|
479
|
+
if (typeof obj !== 'object' && typeof obj !== 'function' && typeof obj !== 'string' && typeof obj !== 'number') {
|
|
468
480
|
throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
|
|
469
481
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
break;
|
|
477
|
-
default:
|
|
478
|
-
{
|
|
479
|
-
const property = visitExpression(expression.property, context);
|
|
480
|
-
if (typeof property === 'undefined') {
|
|
481
|
-
throw {
|
|
482
|
-
type: 'Illegal property access',
|
|
483
|
-
message: 'No property was supplied to the property access'
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
validatePropertyName(property);
|
|
487
|
-
result = obj[property];
|
|
488
|
-
}
|
|
489
|
-
}
|
|
482
|
+
// `a.b` names its property statically; `a[b]` evaluates it, so the result has to be turned into the
|
|
483
|
+
// exact key the indexer will use before it can be validated
|
|
484
|
+
const key = expression.computed ? toPropertyKey(visitExpression(expression.property, context)) : staticPropertyName(expression.property);
|
|
485
|
+
validatePropertyName(key);
|
|
486
|
+
validatePropertyAccess(obj, key);
|
|
487
|
+
const result = obj[key];
|
|
490
488
|
if (typeof result === 'function') {
|
|
491
489
|
return result.bind(obj);
|
|
492
490
|
}
|
|
@@ -533,26 +531,15 @@ function visitTemplateElement(expression, context) {
|
|
|
533
531
|
}
|
|
534
532
|
function visitIdentifier(expression, context) {
|
|
535
533
|
validatePropertyName(expression.name);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const thisObj = context.thisObj;
|
|
540
|
-
if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
|
|
541
|
-
const result = thisObj[expression.name];
|
|
542
|
-
validatePropertyName(result);
|
|
543
|
-
return result;
|
|
544
|
-
} else if (context.variables && expression.name in context.variables) {
|
|
545
|
-
const result = context.variables[expression.name];
|
|
546
|
-
validatePropertyName(result);
|
|
547
|
-
return result;
|
|
548
|
-
} else if (expression.name in context.globals) {
|
|
534
|
+
if (context.variables && Object.hasOwn(context.variables, expression.name)) {
|
|
535
|
+
return context.variables[expression.name];
|
|
536
|
+
} else if (Object.hasOwn(context.globals, expression.name)) {
|
|
549
537
|
return context.globals[expression.name];
|
|
550
538
|
} else {
|
|
551
539
|
return undefined;
|
|
552
540
|
}
|
|
553
541
|
}
|
|
554
542
|
function visitLiteral(expression, context) {
|
|
555
|
-
validatePropertyName(expression.value);
|
|
556
543
|
return expression.value;
|
|
557
544
|
}
|
|
558
545
|
function createSynchronousContext(variables) {
|
|
@@ -563,7 +550,6 @@ function createAsynchronousContext(variables) {
|
|
|
563
550
|
}
|
|
564
551
|
function createContextInternal(variables, globals_) {
|
|
565
552
|
const context = {
|
|
566
|
-
thisObj: undefined,
|
|
567
553
|
variables: {
|
|
568
554
|
...variables
|
|
569
555
|
},
|
|
@@ -581,6 +567,30 @@ function createContextInternal(variables, globals_) {
|
|
|
581
567
|
context.addVariables.bind(context);
|
|
582
568
|
return context;
|
|
583
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Marks that an optional link (`a?.b`) found a nullish value. Optional chaining abandons the rest of the
|
|
572
|
+
* chain rather than just the one access, so this is returned in place of a value and passed along by each
|
|
573
|
+
* member access and call until it reaches the end of the chain, where {@link unwrapShortCircuit} turns it
|
|
574
|
+
* into `undefined`. It has to be distinguishable from a real `undefined`, since `a.b.c` where `a.b` is
|
|
575
|
+
* genuinely undefined is still an error.
|
|
576
|
+
*/ const shortCircuit = Symbol('optional chain short-circuited');
|
|
577
|
+
/**
|
|
578
|
+
* Visits the object of a member access or the callee of a call, i.e. a link in a chain rather than the end
|
|
579
|
+
* of one. Dispatching directly keeps a short circuit intact, where {@link visitExpression} would have
|
|
580
|
+
* already flattened it to `undefined`.
|
|
581
|
+
*/ function visitChainOperand(expression, context) {
|
|
582
|
+
switch(expression.type){
|
|
583
|
+
case 'MemberExpression':
|
|
584
|
+
return visitMemberExpression(expression, context);
|
|
585
|
+
case 'CallExpression':
|
|
586
|
+
return visitCallExpression(expression, context);
|
|
587
|
+
default:
|
|
588
|
+
return visitExpression(expression, context);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function unwrapShortCircuit(result) {
|
|
592
|
+
return result === shortCircuit ? undefined : result;
|
|
593
|
+
}
|
|
584
594
|
// helper useful for handling arrays of expressions, since `null` expressions should not be
|
|
585
595
|
// dispatched to `visitExpression()`
|
|
586
596
|
function handleNullableExpression(context) {
|
|
@@ -599,6 +609,67 @@ function validatePropertyName(name) {
|
|
|
599
609
|
};
|
|
600
610
|
}
|
|
601
611
|
}
|
|
612
|
+
/**
|
|
613
|
+
* Returns the name of a statically-accessed property, i.e. the `b` of `a.b`. jsep only ever parses an
|
|
614
|
+
* identifier here, so anything else means we were handed an AST we did not build.
|
|
615
|
+
*/ function staticPropertyName(expression) {
|
|
616
|
+
if (expression.type !== 'Identifier') {
|
|
617
|
+
throw `VisitMemberExpression does not support a property of type '${expression.type}'`;
|
|
618
|
+
}
|
|
619
|
+
return expression.name;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Narrows the result of a computed property expression to the key that `obj[key]` would actually read.
|
|
623
|
+
* Anything else is refused rather than converted: JS applies ToPropertyKey to whatever it is given, so a
|
|
624
|
+
* value like `['const' + 'ructor']` reads `constructor` even though it compares equal to nothing.
|
|
625
|
+
*/ function toPropertyKey(value) {
|
|
626
|
+
if (typeof value === 'undefined') {
|
|
627
|
+
throw {
|
|
628
|
+
type: 'Illegal property access',
|
|
629
|
+
message: 'No property was supplied to the property access'
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
633
|
+
throw {
|
|
634
|
+
type: 'Illegal property access',
|
|
635
|
+
message: `Cannot use a value of type ${value === null ? 'null' : typeof value} as a property name`
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
return value;
|
|
639
|
+
}
|
|
640
|
+
/** Properties of `Object.prototype` that are harmless enough to leave reachable */ const safeBasePropertyNames = new Set([
|
|
641
|
+
'toString',
|
|
642
|
+
'valueOf',
|
|
643
|
+
'hasOwnProperty'
|
|
644
|
+
]);
|
|
645
|
+
/**
|
|
646
|
+
* Refuses any property that resolves on `Object.prototype` or `Function.prototype`.
|
|
647
|
+
*/ function validatePropertyAccess(obj, key) {
|
|
648
|
+
if (safeBasePropertyNames.has(String(key))) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
// primitives are boxed so that `hasOwn()` sees a string's `length` and indices
|
|
652
|
+
let holder = Object(obj);
|
|
653
|
+
while(holder !== null){
|
|
654
|
+
if (Object.hasOwn(holder, key)) {
|
|
655
|
+
if (holder === Object.prototype || holder === Function.prototype) {
|
|
656
|
+
throw {
|
|
657
|
+
type: 'Illegal property access',
|
|
658
|
+
message: `Cannot access the ${key} property of objects`
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
holder = Object.getPrototypeOf(holder);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** Best-effort name for a property, used only to build error messages */ function describePropertyName(expression, context) {
|
|
667
|
+
try {
|
|
668
|
+
return visitExpressionName(expression, context);
|
|
669
|
+
} catch {
|
|
670
|
+
return '<computed>';
|
|
671
|
+
}
|
|
672
|
+
}
|
|
602
673
|
function isValidVariableType(val) {
|
|
603
674
|
if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean' || typeof val === 'function' || val === null || val instanceof RegExp) {
|
|
604
675
|
return true;
|
package/dist/extractor.js
CHANGED
|
@@ -123,6 +123,11 @@ function visitArrowFunctionExpression(expression, context) {
|
|
|
123
123
|
}
|
|
124
124
|
function visitMemberExpression(expression, context) {
|
|
125
125
|
visitExpression(expression.object, context);
|
|
126
|
+
// the `b` of `a[b]` is a variable the caller has to supply, whereas the `b` of `a.b` is a property name
|
|
127
|
+
if (expression.computed) {
|
|
128
|
+
visitExpression(expression.property, context);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
126
131
|
const newContext = {
|
|
127
132
|
...context
|
|
128
133
|
};
|
package/package.json
CHANGED
package/src/evaluator.test.ts
CHANGED
|
@@ -88,6 +88,150 @@ describe('OpenMRS Expression Evaluator', () => {
|
|
|
88
88
|
expect(() => evaluate('a[b]', { a: {}, b: 'prototype' })).toThrow(/Cannot access the prototype property .*/i);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
+
it('should not support constructor references', () => {
|
|
92
|
+
expect(() => evaluate('a.constructor', { a: {} })).toThrow(/Cannot access the constructor property .*/i);
|
|
93
|
+
expect(() => evaluate('a["constructor"]', { a: {} })).toThrow(/Cannot access the constructor property .*/i);
|
|
94
|
+
expect(() => evaluate("a['const' + 'ructor']", { a: {} })).toThrow(/Cannot access the constructor property .*/i);
|
|
95
|
+
expect(() => evaluate('a[b]', { a: {}, b: 'constructor' })).toThrow(/Cannot access the constructor property .*/i);
|
|
96
|
+
expect(() => evaluate('String.constructor')).toThrow(/Cannot access the constructor property .*/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// A computed property is coerced by ToPropertyKey, so a non-string key whose string conversion is
|
|
100
|
+
// `constructor` used to read `constructor` while comparing equal to nothing the guard knew about.
|
|
101
|
+
it('should not allow a non-string computed property to name a property', () => {
|
|
102
|
+
expect(() => evaluate("String[['const' + 'ructor']]('return 1')()")).toThrow(
|
|
103
|
+
/Cannot use a value of type object as a property name/i,
|
|
104
|
+
);
|
|
105
|
+
expect(() => evaluate("(() => 1)[['const' + 'ructor']]('return 1')()")).toThrow(
|
|
106
|
+
/Cannot use a value of type object as a property name/i,
|
|
107
|
+
);
|
|
108
|
+
expect(() => evaluate("String['con'.concat('structor').split('|')]")).toThrow(
|
|
109
|
+
/Cannot use a value of type object as a property name/i,
|
|
110
|
+
);
|
|
111
|
+
expect(() => evaluate("String[Array.of('const' + 'ructor')]")).toThrow(
|
|
112
|
+
/Cannot use a value of type object as a property name/i,
|
|
113
|
+
);
|
|
114
|
+
expect(() => evaluate("a[[['__pro' + 'to__']]]", { a: {} })).toThrow(
|
|
115
|
+
/Cannot use a value of type object as a property name/i,
|
|
116
|
+
);
|
|
117
|
+
expect(() => evaluate('a[b]', { a: {}, b: true })).toThrow(
|
|
118
|
+
/Cannot use a value of type boolean as a property name/i,
|
|
119
|
+
);
|
|
120
|
+
expect(() => evaluate('a[b]', { a: {}, b: null })).toThrow(/Cannot use a value of type null as a property name/i);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// `Object.prototype` and `Function.prototype` hold every generic route back to the host realm, so no
|
|
124
|
+
// property that resolves on either of them should be readable, however the key is spelled.
|
|
125
|
+
it('should not expose Object.prototype or Function.prototype members', () => {
|
|
126
|
+
const fn = () => 1;
|
|
127
|
+
expect(() => evaluate("f['ca' + 'll']", { f: fn })).toThrow(/Cannot access the call property .*/i);
|
|
128
|
+
expect(() => evaluate('f.apply', { f: fn })).toThrow(/Cannot access the apply property .*/i);
|
|
129
|
+
expect(() => evaluate('f.bind', { f: fn })).toThrow(/Cannot access the bind property .*/i);
|
|
130
|
+
expect(() => evaluate("a['__define' + 'Getter__']", { a: {} })).toThrow(
|
|
131
|
+
/Cannot access the __defineGetter__ property .*/i,
|
|
132
|
+
);
|
|
133
|
+
expect(() => evaluate("a['__lookup' + 'Getter__']", { a: {} })).toThrow(
|
|
134
|
+
/Cannot access the __lookupGetter__ property .*/i,
|
|
135
|
+
);
|
|
136
|
+
expect(() => evaluate('a.isPrototypeOf', { a: {} })).toThrow(/Cannot access the isPrototypeOf property .*/i);
|
|
137
|
+
// identifiers must not reach them through the variables or globals maps either
|
|
138
|
+
expect(evaluate('toString')).toBeUndefined();
|
|
139
|
+
expect(evaluate('hasOwnProperty')).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should still allow harmless base properties and builtins on their own prototypes', () => {
|
|
143
|
+
expect(evaluate('a.toString()', { a: {} })).toBe('[object Object]');
|
|
144
|
+
expect(evaluate('a.valueOf()', { a: 1 })).toBe(1);
|
|
145
|
+
expect(evaluate('"abc".toUpperCase()')).toBe('ABC');
|
|
146
|
+
expect(evaluate('a.length', { a: [1, 2, 3] })).toBe(3);
|
|
147
|
+
expect(evaluate('f.name', { f: function named() {} })).toBe('named');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// `evaluateAsync()` shares the visitor, so every guard has to hold there too
|
|
151
|
+
it('should apply the same guards asynchronously', async () => {
|
|
152
|
+
await expect(evaluateAsync("String[['const' + 'ructor']]('return 1')()")).rejects.toThrow(
|
|
153
|
+
/Cannot use a value of type object as a property name/i,
|
|
154
|
+
);
|
|
155
|
+
await expect(evaluateAsync('a.constructor', { a: {} })).rejects.toThrow(
|
|
156
|
+
/Cannot access the constructor property .*/i,
|
|
157
|
+
);
|
|
158
|
+
await expect(evaluateAsync('a.__proto__', { a: {} })).rejects.toThrow(/Cannot access the __proto__ property .*/i);
|
|
159
|
+
await expect(evaluateAsync("f['ca' + 'll']", { f: () => 1 })).rejects.toThrow(
|
|
160
|
+
/Cannot access the call property .*/i,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should support computed property access', () => {
|
|
165
|
+
expect(evaluate('arr[i]', { arr: [10, 20, 30], i: 1 })).toBe(20);
|
|
166
|
+
expect(evaluate('o[k]', { o: { x: 5 }, k: 'x' })).toBe(5);
|
|
167
|
+
expect(evaluate('o[k.n]', { o: { x: 5 }, k: { n: 'x' } })).toBe(5);
|
|
168
|
+
expect(evaluate('o[k()]', { o: { x: 5 }, k: () => 'x' })).toBe(5);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should not resolve a missing property to a same-named variable', () => {
|
|
172
|
+
expect(evaluate('a.b', { a: {}, b: 5 })).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('should reject member access on null', () => {
|
|
176
|
+
expect(() => evaluate('a.b', { a: null })).toThrow("TypeError: cannot read properties of null (reading 'b')");
|
|
177
|
+
expect(() => evaluate('a.b.c', { a: { b: null } })).toThrow(
|
|
178
|
+
"TypeError: cannot read properties of null (reading 'c')",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should short-circuit logical operators', () => {
|
|
183
|
+
const right = vi.fn(() => true);
|
|
184
|
+
|
|
185
|
+
expect(evaluate('a && right()', { a: false, right })).toBe(false);
|
|
186
|
+
expect(evaluate('a || right()', { a: true, right })).toBe(true);
|
|
187
|
+
expect(evaluate('a ?? right()', { a: 'set', right })).toBe('set');
|
|
188
|
+
expect(right).not.toHaveBeenCalled();
|
|
189
|
+
|
|
190
|
+
expect(evaluate('a && right()', { a: true, right })).toBe(true);
|
|
191
|
+
expect(right).toHaveBeenCalledTimes(1);
|
|
192
|
+
|
|
193
|
+
// the reason it matters: guarding a member access against a nullish value
|
|
194
|
+
expect(evaluate('a && a.b', { a: null })).toBeNull();
|
|
195
|
+
expect(evaluate('a ? a.b : false', { a: null })).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should support optional chaining', () => {
|
|
199
|
+
expect(evaluate('a?.b', { a: null })).toBeUndefined();
|
|
200
|
+
expect(evaluate('a?.b', { a: undefined })).toBeUndefined();
|
|
201
|
+
expect(evaluate('a?.b', { a: { b: 'value' } })).toBe('value');
|
|
202
|
+
expect(evaluate('a?.[k]', { a: null, k: 'b' })).toBeUndefined();
|
|
203
|
+
|
|
204
|
+
// a short circuit abandons the rest of the chain, not just the one access
|
|
205
|
+
expect(evaluate('a?.b.c.d', { a: null })).toBeUndefined();
|
|
206
|
+
expect(evaluate('a?.b()', { a: null })).toBeUndefined();
|
|
207
|
+
expect(evaluate('a?.b.c()', { a: null })).toBeUndefined();
|
|
208
|
+
|
|
209
|
+
// but a genuinely missing property is still an error
|
|
210
|
+
expect(() => evaluate('a?.b.c', { a: {} })).toThrow("TypeError: cannot read properties of undefined (reading 'c')");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should support optional calls', () => {
|
|
214
|
+
const arg = vi.fn(() => 1);
|
|
215
|
+
|
|
216
|
+
expect(evaluate('cb?.(arg())', { cb: null, arg })).toBeUndefined();
|
|
217
|
+
expect(evaluate('o.cb?.(arg())', { o: {}, arg })).toBeUndefined();
|
|
218
|
+
expect(evaluate('cb?.(arg()).b.c', { cb: undefined, arg })).toBeUndefined();
|
|
219
|
+
|
|
220
|
+
// a call that does not happen must not evaluate its arguments
|
|
221
|
+
expect(arg).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
expect(evaluate('cb?.(arg())', { cb: (n: number) => n + 1, arg })).toBe(2);
|
|
224
|
+
expect(arg).toHaveBeenCalledTimes(1);
|
|
225
|
+
|
|
226
|
+
// `?.()` only guards against a nullish callee; a non-callable value is still an error
|
|
227
|
+
expect(() => evaluate('cb?.()', { cb: { notCallable: true } })).toThrow('cb is not a function');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should allow values that share a name with a forbidden property', () => {
|
|
231
|
+
expect(evaluate('a', { a: 'constructor' })).toBe('constructor');
|
|
232
|
+
expect(evaluate("a === 'const' + 'ructor'", { a: 'constructor' })).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
91
235
|
it('should support ternaries', () => {
|
|
92
236
|
expect(evaluate('a ? 1 : 2', { a: true })).toBe(1);
|
|
93
237
|
expect(evaluate('a ? 1 : 2', { a: false })).toBe(2);
|
package/src/evaluator.ts
CHANGED
|
@@ -371,11 +371,11 @@ function visitExpression(expression: jsep.Expression, context: EvaluationContext
|
|
|
371
371
|
case 'ConditionalExpression':
|
|
372
372
|
return visitConditionalExpression(expression as jsep.ConditionalExpression, context);
|
|
373
373
|
case 'CallExpression':
|
|
374
|
-
return visitCallExpression(expression as jsep.CallExpression, context);
|
|
374
|
+
return unwrapShortCircuit(visitCallExpression(expression as jsep.CallExpression, context));
|
|
375
375
|
case 'ArrowFunctionExpression':
|
|
376
376
|
return visitArrowFunctionExpression(expression as ArrowExpression, context);
|
|
377
377
|
case 'MemberExpression':
|
|
378
|
-
return visitMemberExpression(expression as jsep.MemberExpression, context);
|
|
378
|
+
return unwrapShortCircuit(visitMemberExpression(expression as jsep.MemberExpression, context));
|
|
379
379
|
case 'ArrayExpression':
|
|
380
380
|
return visitArrayExpression(expression as jsep.ArrayExpression, context);
|
|
381
381
|
case 'SequenceExpression':
|
|
@@ -426,6 +426,16 @@ function visitUnaryExpression(expression: jsep.UnaryExpression, context: Evaluat
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
function visitBinaryExpression(expression: jsep.BinaryExpression, context: EvaluationContext) {
|
|
429
|
+
// these binary operators are meant to short-circuit
|
|
430
|
+
switch (expression.operator) {
|
|
431
|
+
case '&&':
|
|
432
|
+
return visitExpression(expression.left, context) && visitExpression(expression.right, context);
|
|
433
|
+
case '||':
|
|
434
|
+
return visitExpression(expression.left, context) || visitExpression(expression.right, context);
|
|
435
|
+
case '??':
|
|
436
|
+
return visitExpression(expression.left, context) ?? visitExpression(expression.right, context);
|
|
437
|
+
}
|
|
438
|
+
|
|
429
439
|
let left = visitExpression(expression.left, context);
|
|
430
440
|
let right = visitExpression(expression.right, context);
|
|
431
441
|
|
|
@@ -460,12 +470,6 @@ function visitBinaryExpression(expression: jsep.BinaryExpression, context: Evalu
|
|
|
460
470
|
return left <= right;
|
|
461
471
|
case 'in':
|
|
462
472
|
return left in right;
|
|
463
|
-
case '&&':
|
|
464
|
-
return left && right;
|
|
465
|
-
case '||':
|
|
466
|
-
return left || right;
|
|
467
|
-
case '??':
|
|
468
|
-
return left ?? right;
|
|
469
473
|
default:
|
|
470
474
|
throw `Expression evaluator does not support operator '${expression.operator}' operator`;
|
|
471
475
|
}
|
|
@@ -477,8 +481,19 @@ function visitConditionalExpression(expression: jsep.ConditionalExpression, cont
|
|
|
477
481
|
}
|
|
478
482
|
|
|
479
483
|
function visitCallExpression(expression: jsep.CallExpression, context: EvaluationContext) {
|
|
484
|
+
let callee = visitChainOperand(expression.callee, context);
|
|
485
|
+
|
|
486
|
+
if (callee === shortCircuit) {
|
|
487
|
+
return shortCircuit;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// `cb?.()` abandons the chain before its arguments are evaluated, so this has to come first; otherwise
|
|
491
|
+
// an argument's side effects happen for a call that never takes place
|
|
492
|
+
if (expression.optional && (callee === null || callee === undefined)) {
|
|
493
|
+
return shortCircuit;
|
|
494
|
+
}
|
|
495
|
+
|
|
480
496
|
let args = expression.arguments?.map(handleNullableExpression(context));
|
|
481
|
-
let callee = visitExpression(expression.callee, context);
|
|
482
497
|
|
|
483
498
|
if (!callee) {
|
|
484
499
|
throw `No function named ${getCallTargetName(expression.callee)} is defined in this context`;
|
|
@@ -516,11 +531,25 @@ function visitArrowFunctionExpression(expression: ArrowExpression, context: Eval
|
|
|
516
531
|
);
|
|
517
532
|
|
|
518
533
|
return visitExpression(expression.body, context.addVariables(vars));
|
|
519
|
-
}.bind(
|
|
534
|
+
}.bind(null);
|
|
520
535
|
}
|
|
521
536
|
|
|
522
537
|
function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
|
|
523
|
-
|
|
538
|
+
const obj = visitChainOperand(expression.object, context);
|
|
539
|
+
|
|
540
|
+
if (obj === shortCircuit) {
|
|
541
|
+
return shortCircuit;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (expression.optional && (obj === null || obj === undefined)) {
|
|
545
|
+
return shortCircuit;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (obj === null) {
|
|
549
|
+
throw TypeError(
|
|
550
|
+
`TypeError: cannot read properties of null (reading '${describePropertyName(expression.property, context)}')`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
524
553
|
|
|
525
554
|
if (obj === undefined) {
|
|
526
555
|
switch (expression.object.type) {
|
|
@@ -529,42 +558,32 @@ function visitMemberExpression(expression: jsep.MemberExpression, context: Evalu
|
|
|
529
558
|
throw ReferenceError(`ReferenceError: ${objectName} is not defined`);
|
|
530
559
|
}
|
|
531
560
|
case 'MemberExpression': {
|
|
532
|
-
|
|
533
|
-
|
|
561
|
+
throw TypeError(
|
|
562
|
+
`TypeError: cannot read properties of undefined (reading '${describePropertyName(
|
|
563
|
+
expression.property,
|
|
564
|
+
context,
|
|
565
|
+
)}')`,
|
|
566
|
+
);
|
|
534
567
|
}
|
|
535
568
|
default:
|
|
536
569
|
throw `VisitMemberExpression does not support operator '${expression.object.type}' type`;
|
|
537
570
|
}
|
|
538
571
|
}
|
|
539
572
|
|
|
540
|
-
|
|
541
|
-
if (typeof obj === 'string') {
|
|
542
|
-
newObj = String.prototype;
|
|
543
|
-
} else if (typeof obj === 'number') {
|
|
544
|
-
newObj = Number.prototype;
|
|
545
|
-
} else if (typeof obj === 'function') {
|
|
546
|
-
// no-op
|
|
547
|
-
} else if (typeof obj !== 'object') {
|
|
573
|
+
if (typeof obj !== 'object' && typeof obj !== 'function' && typeof obj !== 'string' && typeof obj !== 'number') {
|
|
548
574
|
throw `VisitMemberExpression does not support member access on type ${typeof obj}`;
|
|
549
575
|
}
|
|
550
576
|
|
|
551
|
-
|
|
577
|
+
// `a.b` names its property statically; `a[b]` evaluates it, so the result has to be turned into the
|
|
578
|
+
// exact key the indexer will use before it can be validated
|
|
579
|
+
const key = expression.computed
|
|
580
|
+
? toPropertyKey(visitExpression(expression.property, context))
|
|
581
|
+
: staticPropertyName(expression.property);
|
|
552
582
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
result = visitExpression(expression.property, context);
|
|
558
|
-
break;
|
|
559
|
-
default: {
|
|
560
|
-
const property = visitExpression(expression.property, context);
|
|
561
|
-
if (typeof property === 'undefined') {
|
|
562
|
-
throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
|
|
563
|
-
}
|
|
564
|
-
validatePropertyName(property);
|
|
565
|
-
result = obj[property];
|
|
566
|
-
}
|
|
567
|
-
}
|
|
583
|
+
validatePropertyName(key);
|
|
584
|
+
validatePropertyAccess(obj, key);
|
|
585
|
+
|
|
586
|
+
const result = obj[key];
|
|
568
587
|
|
|
569
588
|
if (typeof result === 'function') {
|
|
570
589
|
return result.bind(obj);
|
|
@@ -629,19 +648,9 @@ function visitTemplateElement(expression: TemplateElement, context: EvaluationCo
|
|
|
629
648
|
function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext) {
|
|
630
649
|
validatePropertyName(expression.name);
|
|
631
650
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
const thisObj = context.thisObj;
|
|
636
|
-
if (thisObj && (typeof thisObj === 'object' || typeof thisObj === 'function') && expression.name in thisObj) {
|
|
637
|
-
const result = thisObj[expression.name];
|
|
638
|
-
validatePropertyName(result);
|
|
639
|
-
return result;
|
|
640
|
-
} else if (context.variables && expression.name in context.variables) {
|
|
641
|
-
const result = context.variables[expression.name];
|
|
642
|
-
validatePropertyName(result);
|
|
643
|
-
return result;
|
|
644
|
-
} else if (expression.name in context.globals) {
|
|
651
|
+
if (context.variables && Object.hasOwn(context.variables, expression.name)) {
|
|
652
|
+
return context.variables[expression.name];
|
|
653
|
+
} else if (Object.hasOwn(context.globals, expression.name)) {
|
|
645
654
|
return context.globals[expression.name];
|
|
646
655
|
} else {
|
|
647
656
|
return undefined;
|
|
@@ -649,14 +658,12 @@ function visitIdentifier(expression: jsep.Identifier, context: EvaluationContext
|
|
|
649
658
|
}
|
|
650
659
|
|
|
651
660
|
function visitLiteral(expression: jsep.Literal, context: EvaluationContext) {
|
|
652
|
-
validatePropertyName(expression.value);
|
|
653
661
|
return expression.value;
|
|
654
662
|
}
|
|
655
663
|
|
|
656
664
|
// Internal helpers and utilities
|
|
657
665
|
|
|
658
666
|
interface EvaluationContext {
|
|
659
|
-
thisObj: object | undefined;
|
|
660
667
|
variables: VariablesMap;
|
|
661
668
|
globals: typeof globals | typeof globalsAsync;
|
|
662
669
|
addVariables(vars: VariablesMap): EvaluationContext;
|
|
@@ -672,7 +679,6 @@ function createAsynchronousContext(variables: VariablesMap): EvaluationContext {
|
|
|
672
679
|
|
|
673
680
|
function createContextInternal(variables: VariablesMap, globals_: typeof globals | typeof globalsAsync) {
|
|
674
681
|
const context = {
|
|
675
|
-
thisObj: undefined,
|
|
676
682
|
variables: { ...variables },
|
|
677
683
|
globals: { ...globals_ },
|
|
678
684
|
addVariables(vars: VariablesMap) {
|
|
@@ -686,6 +692,35 @@ function createContextInternal(variables: VariablesMap, globals_: typeof globals
|
|
|
686
692
|
return context;
|
|
687
693
|
}
|
|
688
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Marks that an optional link (`a?.b`) found a nullish value. Optional chaining abandons the rest of the
|
|
697
|
+
* chain rather than just the one access, so this is returned in place of a value and passed along by each
|
|
698
|
+
* member access and call until it reaches the end of the chain, where {@link unwrapShortCircuit} turns it
|
|
699
|
+
* into `undefined`. It has to be distinguishable from a real `undefined`, since `a.b.c` where `a.b` is
|
|
700
|
+
* genuinely undefined is still an error.
|
|
701
|
+
*/
|
|
702
|
+
const shortCircuit = Symbol('optional chain short-circuited');
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Visits the object of a member access or the callee of a call, i.e. a link in a chain rather than the end
|
|
706
|
+
* of one. Dispatching directly keeps a short circuit intact, where {@link visitExpression} would have
|
|
707
|
+
* already flattened it to `undefined`.
|
|
708
|
+
*/
|
|
709
|
+
function visitChainOperand(expression: jsep.Expression, context: EvaluationContext) {
|
|
710
|
+
switch (expression.type) {
|
|
711
|
+
case 'MemberExpression':
|
|
712
|
+
return visitMemberExpression(expression as jsep.MemberExpression, context);
|
|
713
|
+
case 'CallExpression':
|
|
714
|
+
return visitCallExpression(expression as jsep.CallExpression, context);
|
|
715
|
+
default:
|
|
716
|
+
return visitExpression(expression, context);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function unwrapShortCircuit(result: unknown) {
|
|
721
|
+
return result === shortCircuit ? undefined : result;
|
|
722
|
+
}
|
|
723
|
+
|
|
689
724
|
// helper useful for handling arrays of expressions, since `null` expressions should not be
|
|
690
725
|
// dispatched to `visitExpression()`
|
|
691
726
|
function handleNullableExpression(context: EvaluationContext) {
|
|
@@ -704,6 +739,73 @@ function validatePropertyName(name: unknown) {
|
|
|
704
739
|
}
|
|
705
740
|
}
|
|
706
741
|
|
|
742
|
+
/**
|
|
743
|
+
* Returns the name of a statically-accessed property, i.e. the `b` of `a.b`. jsep only ever parses an
|
|
744
|
+
* identifier here, so anything else means we were handed an AST we did not build.
|
|
745
|
+
*/
|
|
746
|
+
function staticPropertyName(expression: jsep.Expression) {
|
|
747
|
+
if (expression.type !== 'Identifier') {
|
|
748
|
+
throw `VisitMemberExpression does not support a property of type '${expression.type}'`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return (expression as jsep.Identifier).name;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Narrows the result of a computed property expression to the key that `obj[key]` would actually read.
|
|
756
|
+
* Anything else is refused rather than converted: JS applies ToPropertyKey to whatever it is given, so a
|
|
757
|
+
* value like `['const' + 'ructor']` reads `constructor` even though it compares equal to nothing.
|
|
758
|
+
*/
|
|
759
|
+
function toPropertyKey(value: unknown): string | number {
|
|
760
|
+
if (typeof value === 'undefined') {
|
|
761
|
+
throw { type: 'Illegal property access', message: 'No property was supplied to the property access' };
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
765
|
+
throw {
|
|
766
|
+
type: 'Illegal property access',
|
|
767
|
+
message: `Cannot use a value of type ${value === null ? 'null' : typeof value} as a property name`,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return value;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Properties of `Object.prototype` that are harmless enough to leave reachable */
|
|
775
|
+
const safeBasePropertyNames = new Set(['toString', 'valueOf', 'hasOwnProperty']);
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Refuses any property that resolves on `Object.prototype` or `Function.prototype`.
|
|
779
|
+
*/
|
|
780
|
+
function validatePropertyAccess(obj: object | string | number, key: string | number) {
|
|
781
|
+
if (safeBasePropertyNames.has(String(key))) {
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// primitives are boxed so that `hasOwn()` sees a string's `length` and indices
|
|
786
|
+
let holder: object | null = Object(obj);
|
|
787
|
+
while (holder !== null) {
|
|
788
|
+
if (Object.hasOwn(holder, key)) {
|
|
789
|
+
if (holder === Object.prototype || holder === Function.prototype) {
|
|
790
|
+
throw { type: 'Illegal property access', message: `Cannot access the ${key} property of objects` };
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
holder = Object.getPrototypeOf(holder);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** Best-effort name for a property, used only to build error messages */
|
|
801
|
+
function describePropertyName(expression: jsep.Expression, context: EvaluationContext) {
|
|
802
|
+
try {
|
|
803
|
+
return visitExpressionName(expression, context);
|
|
804
|
+
} catch {
|
|
805
|
+
return '<computed>';
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
707
809
|
function isValidVariableType(val: unknown): val is VariablesMap['a'] {
|
|
708
810
|
if (
|
|
709
811
|
typeof val === 'string' ||
|
package/src/extractor.test.ts
CHANGED
|
@@ -28,6 +28,12 @@ describe('OpenMRS Expression Extractor', () => {
|
|
|
28
28
|
expect(extractVariableNames('`${a.b}`')).toEqual(['a']);
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
+
it('reports computed property names as variables', () => {
|
|
32
|
+
expect(extractVariableNames('o[k]')).toEqual(['o', 'k']);
|
|
33
|
+
expect(extractVariableNames('o["x"]')).toEqual(['o']);
|
|
34
|
+
expect(extractVariableNames('o.x')).toEqual(['o']);
|
|
35
|
+
});
|
|
36
|
+
|
|
31
37
|
it('supports RegExp', () => {
|
|
32
38
|
expect(extractVariableNames('/.*/.test(a)')).toEqual(['a']);
|
|
33
39
|
});
|
package/src/extractor.ts
CHANGED
|
@@ -129,6 +129,13 @@ function visitArrowFunctionExpression(expression: ArrowExpression, context: Eval
|
|
|
129
129
|
|
|
130
130
|
function visitMemberExpression(expression: jsep.MemberExpression, context: EvaluationContext) {
|
|
131
131
|
visitExpression(expression.object, context);
|
|
132
|
+
|
|
133
|
+
// the `b` of `a[b]` is a variable the caller has to supply, whereas the `b` of `a.b` is a property name
|
|
134
|
+
if (expression.computed) {
|
|
135
|
+
visitExpression(expression.property, context);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
const newContext = { ...context };
|
|
133
140
|
newContext.isLocalExpression = true;
|
|
134
141
|
visitExpression(expression.property, newContext);
|