@memberjunction/global 6.2.0-edge.5 → 6.2.0-edge.7
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/README.md +8 -4
- package/dist/ClassFactory.d.ts +30 -0
- package/dist/ClassFactory.d.ts.map +1 -1
- package/dist/ClassFactory.js +69 -5
- package/dist/ClassFactory.js.map +1 -1
- package/dist/KeyedSerialTaskQueue.d.ts +18 -1
- package/dist/KeyedSerialTaskQueue.d.ts.map +1 -1
- package/dist/KeyedSerialTaskQueue.js +24 -3
- package/dist/KeyedSerialTaskQueue.js.map +1 -1
- package/dist/SQLExpressionValidator.d.ts +34 -9
- package/dist/SQLExpressionValidator.d.ts.map +1 -1
- package/dist/SQLExpressionValidator.js +58 -17
- package/dist/SQLExpressionValidator.js.map +1 -1
- package/dist/SQLScreeningScanner.d.ts +28 -2
- package/dist/SQLScreeningScanner.d.ts.map +1 -1
- package/dist/SQLScreeningScanner.js +166 -23
- package/dist/SQLScreeningScanner.js.map +1 -1
- package/dist/SafeExpressionEvaluator.d.ts +93 -109
- package/dist/SafeExpressionEvaluator.d.ts.map +1 -1
- package/dist/SafeExpressionEvaluator.js +494 -346
- package/dist/SafeExpressionEvaluator.js.map +1 -1
- package/dist/__tests__/ClassFactory.collision.test.js +110 -0
- package/dist/__tests__/ClassFactory.collision.test.js.map +1 -1
- package/dist/__tests__/ClassFactory.test.js +21 -0
- package/dist/__tests__/ClassFactory.test.js.map +1 -1
- package/dist/__tests__/FieldSecurityEgressCompliance.test.d.ts +2 -0
- package/dist/__tests__/FieldSecurityEgressCompliance.test.d.ts.map +1 -0
- package/dist/__tests__/FieldSecurityEgressCompliance.test.js +228 -0
- package/dist/__tests__/FieldSecurityEgressCompliance.test.js.map +1 -0
- package/dist/__tests__/FieldTransformEngine.screen.test.d.ts +2 -0
- package/dist/__tests__/FieldTransformEngine.screen.test.d.ts.map +1 -0
- package/dist/__tests__/FieldTransformEngine.screen.test.js +41 -0
- package/dist/__tests__/FieldTransformEngine.screen.test.js.map +1 -0
- package/dist/__tests__/FieldTransformEngine.security.test.d.ts +2 -0
- package/dist/__tests__/FieldTransformEngine.security.test.d.ts.map +1 -0
- package/dist/__tests__/FieldTransformEngine.security.test.js +74 -0
- package/dist/__tests__/FieldTransformEngine.security.test.js.map +1 -0
- package/dist/__tests__/KeyedSerialTaskQueue.test.js +92 -0
- package/dist/__tests__/KeyedSerialTaskQueue.test.js.map +1 -1
- package/dist/__tests__/PlatformStringCompliance.baseline.d.ts +10 -0
- package/dist/__tests__/PlatformStringCompliance.baseline.d.ts.map +1 -0
- package/dist/__tests__/PlatformStringCompliance.baseline.js +12 -0
- package/dist/__tests__/PlatformStringCompliance.baseline.js.map +1 -0
- package/dist/__tests__/PlatformStringCompliance.test.d.ts +8 -0
- package/dist/__tests__/PlatformStringCompliance.test.d.ts.map +1 -0
- package/dist/__tests__/PlatformStringCompliance.test.js +152 -0
- package/dist/__tests__/PlatformStringCompliance.test.js.map +1 -0
- package/dist/__tests__/ProofOfPossession.test.d.ts +2 -0
- package/dist/__tests__/ProofOfPossession.test.d.ts.map +1 -0
- package/dist/__tests__/ProofOfPossession.test.js +123 -0
- package/dist/__tests__/ProofOfPossession.test.js.map +1 -0
- package/dist/__tests__/SQLExpressionValidator.dialect.test.d.ts +2 -0
- package/dist/__tests__/SQLExpressionValidator.dialect.test.d.ts.map +1 -0
- package/dist/__tests__/SQLExpressionValidator.dialect.test.js +183 -0
- package/dist/__tests__/SQLExpressionValidator.dialect.test.js.map +1 -0
- package/dist/__tests__/SQLScreeningScanner.tokens.test.d.ts +2 -0
- package/dist/__tests__/SQLScreeningScanner.tokens.test.d.ts.map +1 -0
- package/dist/__tests__/SQLScreeningScanner.tokens.test.js +82 -0
- package/dist/__tests__/SQLScreeningScanner.tokens.test.js.map +1 -0
- package/dist/__tests__/SafeExpressionEvaluator.allowlist.test.d.ts +2 -0
- package/dist/__tests__/SafeExpressionEvaluator.allowlist.test.d.ts.map +1 -0
- package/dist/__tests__/SafeExpressionEvaluator.allowlist.test.js +200 -0
- package/dist/__tests__/SafeExpressionEvaluator.allowlist.test.js.map +1 -0
- package/dist/__tests__/SafeExpressionEvaluator.security.test.js +11 -0
- package/dist/__tests__/SafeExpressionEvaluator.security.test.js.map +1 -1
- package/dist/__tests__/SafeExpressionEvaluator.test.js +4 -2
- package/dist/__tests__/SafeExpressionEvaluator.test.js.map +1 -1
- package/dist/__tests__/UUIDCompliance.test.js +3 -4
- package/dist/__tests__/UUIDCompliance.test.js.map +1 -1
- package/dist/__tests__/UserScopedCacheKey.test.d.ts +2 -0
- package/dist/__tests__/UserScopedCacheKey.test.d.ts.map +1 -0
- package/dist/__tests__/UserScopedCacheKey.test.js +25 -0
- package/dist/__tests__/UserScopedCacheKey.test.js.map +1 -0
- package/dist/fieldRules/FieldTransformEngine.d.ts +12 -1
- package/dist/fieldRules/FieldTransformEngine.d.ts.map +1 -1
- package/dist/fieldRules/FieldTransformEngine.js +21 -8
- package/dist/fieldRules/FieldTransformEngine.js.map +1 -1
- package/dist/fieldRules/SafeTransformAstEvaluator.d.ts +16 -0
- package/dist/fieldRules/SafeTransformAstEvaluator.d.ts.map +1 -0
- package/dist/fieldRules/SafeTransformAstEvaluator.js +797 -0
- package/dist/fieldRules/SafeTransformAstEvaluator.js.map +1 -0
- package/dist/fieldRules/index.d.ts +1 -0
- package/dist/fieldRules/index.d.ts.map +1 -1
- package/dist/fieldRules/index.js +1 -0
- package/dist/fieldRules/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/util/BinaryEncoding.d.ts +2 -0
- package/dist/util/BinaryEncoding.d.ts.map +1 -1
- package/dist/util/BinaryEncoding.js +3 -5
- package/dist/util/BinaryEncoding.js.map +1 -1
- package/dist/util/ProofOfPossession.d.ts +68 -0
- package/dist/util/ProofOfPossession.d.ts.map +1 -0
- package/dist/util/ProofOfPossession.js +163 -0
- package/dist/util/ProofOfPossession.js.map +1 -0
- package/dist/util/UserScopedCacheKey.d.ts +42 -0
- package/dist/util/UserScopedCacheKey.d.ts.map +1 -0
- package/dist/util/UserScopedCacheKey.js +49 -0
- package/dist/util/UserScopedCacheKey.js.map +1 -0
- package/package.json +1 -1
|
@@ -12,47 +12,410 @@
|
|
|
12
12
|
import { parse } from 'acorn';
|
|
13
13
|
import { MJLruCache } from './MJLruCache.js';
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
15
|
+
* **The approved globals — the only host globals an expression can reach.**
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
* This is the single list reviewers should check. Every other free identifier in an expression must
|
|
18
|
+
* be an evaluation-context key or an arrow-function parameter in scope; anything else is refused
|
|
19
|
+
* before compilation (see {@link SafeExpressionEvaluator.ScreenExpression}).
|
|
20
|
+
*
|
|
21
|
+
* An approved global is usable ONLY in call position — `Math.max(a, b)`, `Number(x)` — never as a
|
|
22
|
+
* bare value, a property read (`Math.PI`), or a callback (`items.map(Number)`). That rule is what
|
|
23
|
+
* stops a function being handed to code that calls it with attacker-chosen arguments: two confirmed
|
|
24
|
+
* bypasses of the earlier denylist did exactly that (`a.reduce(fetch)` and
|
|
25
|
+
* `p.reduce(Object.assign)`).
|
|
26
|
+
*
|
|
27
|
+
* Every listed member is a pure function of its arguments. Deliberately absent: `Object.assign`,
|
|
28
|
+
* `Object.defineProperty`, `Object.getPrototypeOf`/`setPrototypeOf` (mutate or open the prototype
|
|
29
|
+
* chain), `Math.random` (a guard that changes per run is not a guard), `String.fromCharCode`, and
|
|
30
|
+
* every host API (`fetch`, `setTimeout`, `require`, `process`, ...).
|
|
21
31
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
32
|
+
* Both halves of `Math.abs(x)` are fixed identifiers in the AST, so admitting them adds no dynamic
|
|
33
|
+
* lookup.
|
|
34
|
+
*/
|
|
35
|
+
export const APPROVED_EXPRESSION_GLOBALS = new Map([
|
|
36
|
+
['Math', { Callable: false, Methods: new Set([
|
|
37
|
+
'abs', 'ceil', 'floor', 'round', 'trunc', 'sign', 'min', 'max',
|
|
38
|
+
'pow', 'sqrt', 'cbrt', 'log', 'log2', 'log10', 'exp', 'hypot',
|
|
39
|
+
]) }],
|
|
40
|
+
['JSON', { Callable: false, Methods: new Set(['parse', 'stringify']) }],
|
|
41
|
+
['Object', { Callable: false, Methods: new Set(['keys', 'values', 'entries']) }],
|
|
42
|
+
['Array', { Callable: false, Methods: new Set(['isArray']) }],
|
|
43
|
+
['Number', { Callable: true, Methods: new Set(['isInteger', 'isFinite', 'isNaN', 'isSafeInteger', 'parseInt', 'parseFloat']) }],
|
|
44
|
+
['String', { Callable: true, Methods: new Set() }],
|
|
45
|
+
['Boolean', { Callable: true, Methods: new Set() }],
|
|
46
|
+
['Date', { Callable: false, Methods: new Set(['now']) }],
|
|
47
|
+
['parseInt', { Callable: true, Methods: new Set() }],
|
|
48
|
+
['parseFloat', { Callable: true, Methods: new Set() }],
|
|
49
|
+
['isNaN', { Callable: true, Methods: new Set() }],
|
|
50
|
+
['isFinite', { Callable: true, Methods: new Set() }],
|
|
51
|
+
]);
|
|
52
|
+
/**
|
|
53
|
+
* Global names that are VALUES, not functions, and may appear anywhere a value can: `x === undefined`.
|
|
54
|
+
* Each is a non-writable primitive, so referencing one cannot reach host code.
|
|
55
|
+
*/
|
|
56
|
+
export const APPROVED_VALUE_GLOBALS = new Set(['undefined', 'NaN', 'Infinity']);
|
|
57
|
+
/**
|
|
58
|
+
* Namespace globals whose listed methods an expression may CALL, keyed by namespace name.
|
|
59
|
+
* Derived from {@link APPROVED_EXPRESSION_GLOBALS}.
|
|
60
|
+
*/
|
|
61
|
+
export const SAFE_GLOBAL_NAMESPACE_METHODS = new Map([...APPROVED_EXPRESSION_GLOBALS].filter(([, g]) => g.Methods.size > 0).map(([name, g]) => [name, g.Methods]));
|
|
62
|
+
/**
|
|
63
|
+
* Global functions an expression may call by bare name (`Number(payload.count) > 3`).
|
|
64
|
+
* Derived from {@link APPROVED_EXPRESSION_GLOBALS}.
|
|
65
|
+
*/
|
|
66
|
+
export const SAFE_GLOBAL_CALLABLES = new Set([...APPROVED_EXPRESSION_GLOBALS].filter(([, g]) => g.Callable).map(([name]) => name));
|
|
67
|
+
/**
|
|
68
|
+
* Every global name an expression may reference (in call position), as a namespace or as a bare
|
|
69
|
+
* callable. Derived from {@link APPROVED_EXPRESSION_GLOBALS}, so a caller validating conditions ahead
|
|
70
|
+
* of evaluation (`ai-core-plus`'s task-graph door) cannot bless a name the runtime would refuse.
|
|
71
|
+
*/
|
|
72
|
+
export const SAFE_EXPRESSION_GLOBALS = new Set(APPROVED_EXPRESSION_GLOBALS.keys());
|
|
73
|
+
/**
|
|
74
|
+
* Identifiers that must never resolve to a host global, kept as a second layer behind the allowlist.
|
|
75
|
+
* Under the allowlist none of these can be reached anyway (they are neither context keys nor
|
|
76
|
+
* approved globals); listing them keeps a precise rejection message when the screen runs without
|
|
77
|
+
* known context keys ({@link SafeExpressionEvaluator.ValidateSyntax}) and bars them as arrow
|
|
78
|
+
* parameter names.
|
|
25
79
|
*/
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
['JSON', new Set(['parse', 'stringify'])],
|
|
32
|
-
['Object', new Set(['keys', 'values', 'entries'])],
|
|
33
|
-
['Array', new Set(['isArray'])],
|
|
34
|
-
['Number', new Set(['isInteger', 'isFinite', 'isNaN', 'isSafeInteger', 'parseInt', 'parseFloat'])],
|
|
35
|
-
['Date', new Set(['now'])],
|
|
80
|
+
const DANGEROUS_IDENTIFIERS = new Set([
|
|
81
|
+
'eval', 'Function', 'globalThis', 'global', 'window', 'document', 'process',
|
|
82
|
+
'require', 'module', 'exports', '__dirname', '__filename', 'self', 'top',
|
|
83
|
+
'parent', 'frames', 'Reflect', 'Proxy', 'WebAssembly', 'Atomics',
|
|
84
|
+
'SharedArrayBuffer', 'import', 'arguments', 'constructor', 'prototype', '__proto__',
|
|
36
85
|
]);
|
|
37
86
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
87
|
+
* Property names that open the prototype chain / constructor climb. Denied on both dotted
|
|
88
|
+
* (`x.constructor`) and literal-bracket (`x["constructor"]`) access.
|
|
40
89
|
*/
|
|
41
|
-
|
|
42
|
-
'
|
|
90
|
+
const DANGEROUS_PROPERTY_NAMES = new Set([
|
|
91
|
+
'constructor', '__proto__', 'prototype',
|
|
43
92
|
]);
|
|
93
|
+
/** Binary operators the evaluator permits. `instanceof`/`in`/bitwise are excluded. */
|
|
94
|
+
const ALLOWED_BINARY_OPERATORS = new Set([
|
|
95
|
+
'==', '!=', '===', '!==', '<', '<=', '>', '>=', '+', '-', '*', '/', '%',
|
|
96
|
+
]);
|
|
97
|
+
/** Unary operators the evaluator permits. `delete`/`void` are excluded. */
|
|
98
|
+
const ALLOWED_UNARY_OPERATORS = new Set(['!', '-', '+', 'typeof']);
|
|
44
99
|
/**
|
|
45
|
-
*
|
|
100
|
+
* Instance methods an expression may call on any value. None takes a function argument except the
|
|
101
|
+
* {@link CALLBACK_METHODS}, whose callback must be written inline.
|
|
46
102
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
103
|
+
* The string/array/number methods after `reduce` (`split` ... `toPrecision`) were added when
|
|
104
|
+
* field-rule and integration formulas began going through this screen: they are pure, take no
|
|
105
|
+
* callback, and cover the formula shapes in use (`value.split(' ')[0]`, `value.toFixed(2)`).
|
|
106
|
+
* `replace`/`replaceAll` (accept a replacer function) and `repeat` (unbounded allocation) stay out.
|
|
51
107
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
108
|
+
const SAFE_METHODS = new Set([
|
|
109
|
+
'length',
|
|
110
|
+
'includes',
|
|
111
|
+
'startsWith',
|
|
112
|
+
'endsWith',
|
|
113
|
+
'indexOf',
|
|
114
|
+
'lastIndexOf',
|
|
115
|
+
'toLowerCase',
|
|
116
|
+
'toUpperCase',
|
|
117
|
+
'trim',
|
|
118
|
+
'trimStart',
|
|
119
|
+
'trimEnd',
|
|
120
|
+
'toString',
|
|
121
|
+
'valueOf',
|
|
122
|
+
'some',
|
|
123
|
+
'every',
|
|
124
|
+
'find',
|
|
125
|
+
'filter',
|
|
126
|
+
'map',
|
|
127
|
+
'reduce',
|
|
128
|
+
'split',
|
|
129
|
+
'slice',
|
|
130
|
+
'substring',
|
|
131
|
+
'join',
|
|
132
|
+
'concat',
|
|
133
|
+
'charAt',
|
|
134
|
+
'at',
|
|
135
|
+
'padStart',
|
|
136
|
+
'padEnd',
|
|
137
|
+
'toFixed',
|
|
138
|
+
'toPrecision',
|
|
55
139
|
]);
|
|
140
|
+
/**
|
|
141
|
+
* Safe methods whose first argument is a callback. The callback must be an inline arrow function —
|
|
142
|
+
* never an identifier or member expression — so no existing function (host or context) can be
|
|
143
|
+
* handed to them and invoked with arguments the expression chose.
|
|
144
|
+
*/
|
|
145
|
+
const CALLBACK_METHODS = new Set(['some', 'every', 'find', 'filter', 'map', 'reduce']);
|
|
146
|
+
/**
|
|
147
|
+
* Approved-global methods that would call a function passed at the given argument index
|
|
148
|
+
* (`JSON.parse`'s reviver, `JSON.stringify`'s replacer). The argument there must be a literal or an
|
|
149
|
+
* array literal (`JSON.stringify(x, null, 2)`), never a function value.
|
|
150
|
+
*
|
|
151
|
+
* Only that one slot is constrained. Arguments after it (`JSON.stringify`'s `space`) are screened
|
|
152
|
+
* like any other value, since neither method ever calls them; `JSON.parse` ignores extra arguments.
|
|
153
|
+
*/
|
|
154
|
+
const NON_FUNCTION_ARGUMENT_SLOTS = new Map([
|
|
155
|
+
['JSON.parse', 1],
|
|
156
|
+
['JSON.stringify', 1],
|
|
157
|
+
]);
|
|
158
|
+
/** A context key must be a plain identifier: keys become parameter names of the compiled function. */
|
|
159
|
+
const CONTEXT_KEY_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
|
160
|
+
/** Maximum expression length accepted by the screen. */
|
|
161
|
+
const MAX_EXPRESSION_LENGTH = 1000;
|
|
162
|
+
/** Builds the standard rejection message. Callers match on "forbidden construct". */
|
|
163
|
+
function forbidden(detail) {
|
|
164
|
+
return `Expression contains a forbidden construct: ${detail}`;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Walks one parsed expression against the allowlist.
|
|
168
|
+
*
|
|
169
|
+
* Free identifiers are checked against, in order: arrow parameters in scope, the evaluation-context
|
|
170
|
+
* keys (when known), and {@link APPROVED_VALUE_GLOBALS}. Approved globals are recognised only where a
|
|
171
|
+
* call is built from them. When the context keys are NOT known (`ValidateSyntax` without keys), a
|
|
172
|
+
* free identifier that is not an approved global or a denied name is let through, because whether it
|
|
173
|
+
* resolves is a question about a run that has not happened; `Evaluate` re-screens with the real keys.
|
|
174
|
+
*/
|
|
175
|
+
class ExpressionScreen {
|
|
176
|
+
constructor(contextKeys) {
|
|
177
|
+
this.contextKeys = contextKeys;
|
|
178
|
+
this.scopes = [];
|
|
179
|
+
}
|
|
180
|
+
Check(node) {
|
|
181
|
+
switch (node.type) {
|
|
182
|
+
case 'Literal': {
|
|
183
|
+
const lit = node;
|
|
184
|
+
// Regex literals can be crafted for catastrophic backtracking; permit strings/numbers/booleans/null only.
|
|
185
|
+
if (lit.value instanceof RegExp || 'regex' in lit) {
|
|
186
|
+
return forbidden('regular expression literals are not allowed');
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
case 'Identifier':
|
|
191
|
+
return this.checkValueIdentifier(node.name);
|
|
192
|
+
case 'ChainExpression':
|
|
193
|
+
// `a?.b`, `a?.[0]`, `a?.b()` — the optional marker changes only whether the member
|
|
194
|
+
// read short-circuits on null/undefined, so unwrapping lands the inner node back in
|
|
195
|
+
// the ordinary member/call rules and `a?.constructor` stays blocked.
|
|
196
|
+
return this.Check(node.expression);
|
|
197
|
+
case 'MemberExpression':
|
|
198
|
+
return this.checkMemberExpression(node);
|
|
199
|
+
case 'CallExpression':
|
|
200
|
+
return this.checkCallExpression(node);
|
|
201
|
+
case 'ArrowFunctionExpression':
|
|
202
|
+
return forbidden(`arrow functions are allowed only as the inline callback of ${[...CALLBACK_METHODS].map(m => `.${m}()`).join(', ')}`);
|
|
203
|
+
case 'BinaryExpression': {
|
|
204
|
+
const bin = node;
|
|
205
|
+
if (!ALLOWED_BINARY_OPERATORS.has(bin.operator)) {
|
|
206
|
+
return forbidden(`the operator "${bin.operator}"`);
|
|
207
|
+
}
|
|
208
|
+
return this.Check(bin.left) ?? this.Check(bin.right);
|
|
209
|
+
}
|
|
210
|
+
case 'LogicalExpression': {
|
|
211
|
+
const log = node;
|
|
212
|
+
return this.Check(log.left) ?? this.Check(log.right);
|
|
213
|
+
}
|
|
214
|
+
case 'UnaryExpression': {
|
|
215
|
+
const un = node;
|
|
216
|
+
if (!ALLOWED_UNARY_OPERATORS.has(un.operator)) {
|
|
217
|
+
return forbidden(`the unary operator "${un.operator}"`);
|
|
218
|
+
}
|
|
219
|
+
return this.Check(un.argument);
|
|
220
|
+
}
|
|
221
|
+
case 'ConditionalExpression': {
|
|
222
|
+
const cond = node;
|
|
223
|
+
return this.Check(cond.test) ?? this.Check(cond.consequent) ?? this.Check(cond.alternate);
|
|
224
|
+
}
|
|
225
|
+
case 'ArrayExpression': {
|
|
226
|
+
const arr = node;
|
|
227
|
+
for (const el of arr.elements) {
|
|
228
|
+
if (!el)
|
|
229
|
+
continue; // array hole
|
|
230
|
+
if (el.type === 'SpreadElement') {
|
|
231
|
+
return forbidden('spread elements are not allowed');
|
|
232
|
+
}
|
|
233
|
+
const err = this.Check(el);
|
|
234
|
+
if (err)
|
|
235
|
+
return err;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
default:
|
|
240
|
+
return forbidden(`${node.type} is not allowed`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/** Whether `name` is bound by an enclosing arrow function. */
|
|
244
|
+
isBoundParameter(name) {
|
|
245
|
+
return this.scopes.some(scope => scope.has(name));
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* An identifier in VALUE position (anything but the callee of a call): it must be an arrow
|
|
249
|
+
* parameter in scope, a context key, or an approved value global (`undefined`). Approved function
|
|
250
|
+
* globals are refused here — `items.map(Number)` and `[Math][0]` hand a host function out as a value.
|
|
251
|
+
*/
|
|
252
|
+
checkValueIdentifier(name) {
|
|
253
|
+
if (this.isBoundParameter(name)) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (this.contextKeys?.has(name)) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
if (DANGEROUS_IDENTIFIERS.has(name)) {
|
|
260
|
+
return forbidden(`the identifier "${name}"`);
|
|
261
|
+
}
|
|
262
|
+
if (APPROVED_VALUE_GLOBALS.has(name)) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
if (APPROVED_EXPRESSION_GLOBALS.has(name)) {
|
|
266
|
+
return forbidden(`"${name}" may only be called (e.g. ${name}${APPROVED_EXPRESSION_GLOBALS.get(name)?.Callable ? '(x)' : '.method(x)'}), not used as a value or callback`);
|
|
267
|
+
}
|
|
268
|
+
if (this.contextKeys === null) {
|
|
269
|
+
// Context keys unknown (ValidateSyntax): resolution is decided at evaluation time.
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
return forbidden(`the identifier "${name}" is not defined in the evaluation context and is not an arrow-function parameter`);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Member access is allowed for dotted property reads and literal-key bracket reads, but never
|
|
276
|
+
* for a computed key built at runtime (the string-concat escape route) or a prototype-chain
|
|
277
|
+
* property.
|
|
278
|
+
*/
|
|
279
|
+
checkMemberExpression(node) {
|
|
280
|
+
if (node.property.type === 'PrivateIdentifier') {
|
|
281
|
+
return forbidden('private fields are not allowed');
|
|
282
|
+
}
|
|
283
|
+
if (node.computed) {
|
|
284
|
+
// Bracket access must use a literal — a computed key (`x["con"+"structor"]`, `x[y]`) is
|
|
285
|
+
// exactly the route a textual denylist misses.
|
|
286
|
+
if (node.property.type !== 'Literal') {
|
|
287
|
+
return forbidden('computed member access must use a literal index/key');
|
|
288
|
+
}
|
|
289
|
+
const value = node.property.value;
|
|
290
|
+
if (typeof value === 'string') {
|
|
291
|
+
if (DANGEROUS_PROPERTY_NAMES.has(value)) {
|
|
292
|
+
return forbidden(`access to "${value}"`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
else if (typeof value !== 'number') {
|
|
296
|
+
return forbidden('computed member access must use a string or numeric literal');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const name = node.property.name;
|
|
301
|
+
if (DANGEROUS_PROPERTY_NAMES.has(name)) {
|
|
302
|
+
return forbidden(`access to "${name}"`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return this.Check(node.object);
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* A call is admitted only when its callee is a FIXED name: a callable approved global
|
|
309
|
+
* (`parseInt(...)`), a listed method of an approved global namespace (`Math.abs(...)`), or a safe
|
|
310
|
+
* instance method on a screened receiver (`payload.title.includes(...)`). Everything else — a
|
|
311
|
+
* computed callee, an unlisted bare identifier, a call on an arrow or call result that is not
|
|
312
|
+
* itself a safe method — is rejected.
|
|
313
|
+
*/
|
|
314
|
+
checkCallExpression(node) {
|
|
315
|
+
const callee = node.callee;
|
|
316
|
+
if (callee.type === 'Identifier') {
|
|
317
|
+
const name = callee.name;
|
|
318
|
+
if (this.isBoundParameter(name) || !SAFE_GLOBAL_CALLABLES.has(name)) {
|
|
319
|
+
return forbidden(`calling "${name}" — only safe methods and approved global functions may be invoked`);
|
|
320
|
+
}
|
|
321
|
+
return this.checkCallArguments(node, null, null);
|
|
322
|
+
}
|
|
323
|
+
if (callee.type !== 'MemberExpression') {
|
|
324
|
+
return forbidden('only method calls on the safe-method allowlist may be invoked');
|
|
325
|
+
}
|
|
326
|
+
const member = callee;
|
|
327
|
+
if (member.computed || member.property.type !== 'Identifier') {
|
|
328
|
+
return forbidden('methods must be called by a dotted, non-computed name');
|
|
329
|
+
}
|
|
330
|
+
const method = member.property.name;
|
|
331
|
+
const receiver = member.object;
|
|
332
|
+
// `Math.max(...)`: the namespace is an approved global in call position, so it is not
|
|
333
|
+
// screened as a value. A parameter or context key with the same name shadows the global
|
|
334
|
+
// and is screened as an ordinary receiver below.
|
|
335
|
+
if (receiver.type === 'Identifier') {
|
|
336
|
+
const namespace = receiver.name;
|
|
337
|
+
const shadowed = this.isBoundParameter(namespace) || this.contextKeys?.has(namespace) === true;
|
|
338
|
+
if (!shadowed && SAFE_GLOBAL_NAMESPACE_METHODS.get(namespace)?.has(method) === true) {
|
|
339
|
+
return this.checkCallArguments(node, null, `${namespace}.${method}`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!SAFE_METHODS.has(method)) {
|
|
343
|
+
return forbidden(`the method "${method}" is not on the safe-method allowlist`);
|
|
344
|
+
}
|
|
345
|
+
const receiverError = this.checkReceiver(receiver);
|
|
346
|
+
if (receiverError)
|
|
347
|
+
return receiverError;
|
|
348
|
+
return this.checkCallArguments(node, method, null);
|
|
349
|
+
}
|
|
350
|
+
/** Screens the receiver of a safe instance method. `super.x()` is never valid. */
|
|
351
|
+
checkReceiver(receiver) {
|
|
352
|
+
if (receiver.type === 'Super') {
|
|
353
|
+
return forbidden('super is not allowed');
|
|
354
|
+
}
|
|
355
|
+
return this.Check(receiver);
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Walks a call's arguments. Spreads are rejected outright. For a callback method the first
|
|
359
|
+
* argument must be an inline arrow function; for `JSON.parse`/`JSON.stringify` the reviver /
|
|
360
|
+
* replacer slot must be a literal or array literal.
|
|
361
|
+
*/
|
|
362
|
+
checkCallArguments(node, instanceMethod, globalMethod) {
|
|
363
|
+
const callbackMethod = instanceMethod !== null && CALLBACK_METHODS.has(instanceMethod);
|
|
364
|
+
const nonFunctionSlot = globalMethod !== null ? NON_FUNCTION_ARGUMENT_SLOTS.get(globalMethod) : undefined;
|
|
365
|
+
for (let i = 0; i < node.arguments.length; i++) {
|
|
366
|
+
const arg = node.arguments[i];
|
|
367
|
+
if (arg.type === 'SpreadElement') {
|
|
368
|
+
return forbidden('spread arguments are not allowed');
|
|
369
|
+
}
|
|
370
|
+
if (callbackMethod && i === 0) {
|
|
371
|
+
if (arg.type !== 'ArrowFunctionExpression') {
|
|
372
|
+
return forbidden(`the callback of .${instanceMethod}() must be an inline arrow function (e.g. x => x.price > 100)`);
|
|
373
|
+
}
|
|
374
|
+
const err = this.checkArrowFunction(arg);
|
|
375
|
+
if (err)
|
|
376
|
+
return err;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (nonFunctionSlot === i && arg.type !== 'Literal' && arg.type !== 'ArrayExpression') {
|
|
380
|
+
return forbidden(`argument ${i + 1} of ${globalMethod}() must be a literal or an array literal`);
|
|
381
|
+
}
|
|
382
|
+
const err = this.Check(arg);
|
|
383
|
+
if (err)
|
|
384
|
+
return err;
|
|
385
|
+
}
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* An inline callback: parameters must be simple identifiers that do not shadow an approved or
|
|
390
|
+
* denied global, and the body must be an expression. Parameters are in scope for the body only.
|
|
391
|
+
*/
|
|
392
|
+
checkArrowFunction(node) {
|
|
393
|
+
if (node.async) {
|
|
394
|
+
return forbidden('async functions are not allowed');
|
|
395
|
+
}
|
|
396
|
+
if (node.body.type === 'BlockStatement') {
|
|
397
|
+
return forbidden('arrow-function bodies must be an expression, not a block');
|
|
398
|
+
}
|
|
399
|
+
const params = new Set();
|
|
400
|
+
for (const param of node.params) {
|
|
401
|
+
if (param.type !== 'Identifier') {
|
|
402
|
+
return forbidden('arrow-function parameters must be simple identifiers');
|
|
403
|
+
}
|
|
404
|
+
const name = param.name;
|
|
405
|
+
if (DANGEROUS_IDENTIFIERS.has(name) || APPROVED_EXPRESSION_GLOBALS.has(name) || APPROVED_VALUE_GLOBALS.has(name)) {
|
|
406
|
+
return forbidden(`the parameter "${name}"`);
|
|
407
|
+
}
|
|
408
|
+
params.add(name);
|
|
409
|
+
}
|
|
410
|
+
this.scopes.push(params);
|
|
411
|
+
try {
|
|
412
|
+
return this.Check(node.body);
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
this.scopes.pop();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
56
419
|
/**
|
|
57
420
|
* Safe expression evaluator that prevents arbitrary code execution while
|
|
58
421
|
* supporting common boolean expressions and property access patterns.
|
|
@@ -62,17 +425,19 @@ export const SAFE_EXPRESSION_GLOBALS = new Set([
|
|
|
62
425
|
* - Logical: &&, ||, !
|
|
63
426
|
* - Property access: dot notation (e.g., payload.customer.name), including optional chaining (`?.`)
|
|
64
427
|
* - Array access: bracket notation with a literal index/key (e.g., items[0])
|
|
65
|
-
* - Safe methods: .length, .includes(), .startsWith(), .endsWith()
|
|
66
|
-
* - Array methods: .some(), .every(), .find(), .filter()
|
|
67
|
-
*
|
|
428
|
+
* - Safe methods: .length, .includes(), .startsWith(), .endsWith(), .split(), .slice(), ...
|
|
429
|
+
* - Array methods: .some(), .every(), .find(), .filter(), .map(), .reduce() — the callback must be
|
|
430
|
+
* an inline arrow function (`items.some(i => i.price > 100)`, never `items.some(fn)`)
|
|
431
|
+
* - Approved globals, in call position only: {@link APPROVED_EXPRESSION_GLOBALS}
|
|
68
432
|
* (e.g. `Math.abs(output.delta) < 5`, `Number(payload.count) > 3`, `Object.keys(payload).length`)
|
|
69
433
|
* - Type checking: typeof
|
|
70
434
|
*
|
|
71
435
|
* Safety is enforced by parsing the expression to an AST and walking it against
|
|
72
|
-
* an ALLOWLIST
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
436
|
+
* an ALLOWLIST before it is compiled. Node types, operators, methods and free identifiers are
|
|
437
|
+
* all allowlisted: a free identifier must be a context key, an arrow parameter in scope, or an
|
|
438
|
+
* approved global used as a call. An unlisted construct (computed member access with a non-literal
|
|
439
|
+
* key, `.constructor`/`__proto__` access, any call outside the safe-method and approved-global
|
|
440
|
+
* lists, any other identifier, etc.) is rejected at validation time and never reaches the compiler. A structural
|
|
76
441
|
* allowlist cannot be defeated by string concatenation the way a textual
|
|
77
442
|
* denylist can, and it does not over-reject data that merely mentions a reserved
|
|
78
443
|
* word (e.g. `name == 'constructor'` is a legal comparison).
|
|
@@ -110,73 +475,26 @@ export class SafeExpressionEvaluator {
|
|
|
110
475
|
*/
|
|
111
476
|
this._compiledExpressionCache = new MJLruCache({ maxSize: 1000 });
|
|
112
477
|
}
|
|
113
|
-
/**
|
|
114
|
-
* Identifiers that must never resolve to a host global. Because the compiled
|
|
115
|
-
* function runs in global scope, a bare `process`/`Function`/`globalThis`/…
|
|
116
|
-
* would reach the real global object; identifiers are atomic AST tokens, so a
|
|
117
|
-
* name that is split with string concatenation parses as separate identifiers
|
|
118
|
-
* or an operator and is caught by the structural rules instead.
|
|
119
|
-
* @private
|
|
120
|
-
*/
|
|
121
|
-
static { this.DANGEROUS_IDENTIFIERS = new Set([
|
|
122
|
-
'eval', 'Function', 'globalThis', 'global', 'window', 'document', 'process',
|
|
123
|
-
'require', 'module', 'exports', '__dirname', '__filename', 'self', 'top',
|
|
124
|
-
'parent', 'frames', 'Reflect', 'Proxy', 'WebAssembly', 'Atomics',
|
|
125
|
-
'SharedArrayBuffer', 'import', 'arguments', 'constructor', 'prototype', '__proto__',
|
|
126
|
-
]); }
|
|
127
|
-
/**
|
|
128
|
-
* Property names that open the prototype chain / constructor climb. Denied on
|
|
129
|
-
* both dotted (`x.constructor`) and literal-bracket (`x["constructor"]`) access.
|
|
130
|
-
* @private
|
|
131
|
-
*/
|
|
132
|
-
static { this.DANGEROUS_PROPERTY_NAMES = new Set([
|
|
133
|
-
'constructor', '__proto__', 'prototype',
|
|
134
|
-
]); }
|
|
135
|
-
/** Binary operators the evaluator permits. `instanceof`/`in`/bitwise are excluded. @private */
|
|
136
|
-
static { this.ALLOWED_BINARY_OPERATORS = new Set([
|
|
137
|
-
'==', '!=', '===', '!==', '<', '<=', '>', '>=', '+', '-', '*', '/', '%',
|
|
138
|
-
]); }
|
|
139
|
-
/** Unary operators the evaluator permits. `delete`/`void` are excluded. @private */
|
|
140
|
-
static { this.ALLOWED_UNARY_OPERATORS = new Set(['!', '-', '+', 'typeof']); }
|
|
141
|
-
/**
|
|
142
|
-
* Safe methods that can be called on objects
|
|
143
|
-
* @private
|
|
144
|
-
*/
|
|
145
|
-
static { this.SAFE_METHODS = [
|
|
146
|
-
'length',
|
|
147
|
-
'includes',
|
|
148
|
-
'startsWith',
|
|
149
|
-
'endsWith',
|
|
150
|
-
'indexOf',
|
|
151
|
-
'lastIndexOf',
|
|
152
|
-
'toLowerCase',
|
|
153
|
-
'toUpperCase',
|
|
154
|
-
'trim',
|
|
155
|
-
'trimStart',
|
|
156
|
-
'trimEnd',
|
|
157
|
-
'toString',
|
|
158
|
-
'valueOf',
|
|
159
|
-
'some',
|
|
160
|
-
'every',
|
|
161
|
-
'find',
|
|
162
|
-
'filter',
|
|
163
|
-
'map',
|
|
164
|
-
'reduce'
|
|
165
|
-
]; }
|
|
166
478
|
/**
|
|
167
479
|
* Evaluates a boolean expression against a context object
|
|
168
480
|
*
|
|
169
481
|
* @param {string} expression - The boolean expression to evaluate
|
|
170
|
-
* @param {Record<string, any>} context - The context object containing variables
|
|
482
|
+
* @param {Record<string, any>} [context={}] - The context object containing variables. Its keys
|
|
483
|
+
* are the only free names the expression may use; a key named like an approved global
|
|
484
|
+
* shadows it (see {@link ScreenExpression}).
|
|
171
485
|
* @param {boolean} [enableDiagnostics=false] - Whether to include diagnostic information
|
|
172
486
|
*
|
|
173
487
|
* @returns {ExpressionEvaluationResult} The evaluation result
|
|
174
488
|
*/
|
|
175
|
-
Evaluate(expression, context, enableDiagnostics = false) {
|
|
489
|
+
Evaluate(expression, context = {}, enableDiagnostics = false) {
|
|
176
490
|
const startTime = Date.now();
|
|
177
491
|
try {
|
|
178
|
-
//
|
|
179
|
-
|
|
492
|
+
// Prepare safe context first: its keys are the only free names the expression may use.
|
|
493
|
+
// Throws on a key that is not a plain identifier (keys become parameter names).
|
|
494
|
+
const safeContext = this.createSafeContext(context);
|
|
495
|
+
const contextKeys = Object.keys(safeContext);
|
|
496
|
+
// Validate expression safety against the allowlist, with the real context keys
|
|
497
|
+
const validationError = SafeExpressionEvaluator.ScreenExpression(expression, contextKeys);
|
|
180
498
|
if (validationError) {
|
|
181
499
|
return {
|
|
182
500
|
success: false,
|
|
@@ -188,10 +506,7 @@ export class SafeExpressionEvaluator {
|
|
|
188
506
|
} : undefined
|
|
189
507
|
};
|
|
190
508
|
}
|
|
191
|
-
// Prepare safe context
|
|
192
|
-
const safeContext = this.createSafeContext(context);
|
|
193
509
|
// Create evaluation function
|
|
194
|
-
const contextKeys = Object.keys(safeContext);
|
|
195
510
|
const contextValues = contextKeys.map(key => safeContext[key]);
|
|
196
511
|
// Check cache for compiled expression
|
|
197
512
|
const cacheKey = `${expression}|${contextKeys.join(',')}`;
|
|
@@ -235,44 +550,37 @@ export class SafeExpressionEvaluator {
|
|
|
235
550
|
}
|
|
236
551
|
}
|
|
237
552
|
/** @deprecated Use {@link Evaluate}. */
|
|
238
|
-
evaluate(expression, context, enableDiagnostics = false) {
|
|
553
|
+
evaluate(expression, context = {}, enableDiagnostics = false) {
|
|
239
554
|
return this.Evaluate(expression, context, enableDiagnostics);
|
|
240
555
|
}
|
|
241
556
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
557
|
+
* Screens an expression against the allowlist WITHOUT compiling or running it. Returns the
|
|
558
|
+
* rejection message, or `null` when every construct is permitted.
|
|
244
559
|
*
|
|
245
|
-
*
|
|
560
|
+
* This is the single policy gate for every place MemberJunction compiles a user- or LLM-authored
|
|
561
|
+
* expression: {@link Evaluate}, {@link ValidateSyntax}, and the field-rule / integration formula
|
|
562
|
+
* engine (`FieldTransformEngine`), which passes `['value', 'fields']`.
|
|
246
563
|
*
|
|
247
|
-
*
|
|
564
|
+
* A context key or arrow parameter named like an approved global (`Math`, `JSON`, ...) shadows
|
|
565
|
+
* it and is screened as data, so `Math.abs(x)` with a `Math` context key is refused because
|
|
566
|
+
* `abs` is not a safe instance method. Do not pass approved-global names as context keys.
|
|
248
567
|
*
|
|
249
|
-
* @
|
|
568
|
+
* @param expression the expression to screen
|
|
569
|
+
* @param allowedIdentifiers the names the expression may reference as values — the evaluation
|
|
570
|
+
* context keys. Pass `null` only when they are not yet known (authoring-time syntax
|
|
571
|
+
* checks); free identifiers are then left for evaluation time to decide, while approved
|
|
572
|
+
* globals used as values, callbacks that are not inline arrows, and every structural rule
|
|
573
|
+
* are still enforced.
|
|
250
574
|
*/
|
|
251
|
-
|
|
575
|
+
static ScreenExpression(expression, allowedIdentifiers) {
|
|
252
576
|
if (!expression || typeof expression !== 'string') {
|
|
253
577
|
return 'Expression must be a non-empty string';
|
|
254
578
|
}
|
|
255
|
-
if (expression.length >
|
|
256
|
-
return
|
|
579
|
+
if (expression.length > MAX_EXPRESSION_LENGTH) {
|
|
580
|
+
return `Expression exceeds maximum length of ${MAX_EXPRESSION_LENGTH} characters`;
|
|
257
581
|
}
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
// syntax error).
|
|
261
|
-
let parenCount = 0;
|
|
262
|
-
for (const char of expression) {
|
|
263
|
-
if (char === '(')
|
|
264
|
-
parenCount++;
|
|
265
|
-
if (char === ')')
|
|
266
|
-
parenCount--;
|
|
267
|
-
if (parenCount < 0) {
|
|
268
|
-
return 'Unbalanced parentheses in expression';
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
if (parenCount !== 0) {
|
|
272
|
-
return 'Unbalanced parentheses in expression';
|
|
273
|
-
}
|
|
274
|
-
// A whitespace-only expression compiles to `Boolean( )`, i.e. false — there
|
|
275
|
-
// is nothing to parse or validate.
|
|
582
|
+
// A whitespace-only expression compiles to `Boolean( )`, i.e. false — there is nothing to
|
|
583
|
+
// parse or validate.
|
|
276
584
|
if (expression.trim() === '') {
|
|
277
585
|
return null;
|
|
278
586
|
}
|
|
@@ -281,205 +589,31 @@ export class SafeExpressionEvaluator {
|
|
|
281
589
|
program = parse(expression, { ecmaVersion: 2022 });
|
|
282
590
|
}
|
|
283
591
|
catch (e) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
return this.checkNode(program.body[0].expression);
|
|
290
|
-
}
|
|
291
|
-
/** Builds the standard rejection message. @private */
|
|
292
|
-
forbidden(detail) {
|
|
293
|
-
return `Expression contains a forbidden construct: ${detail}`;
|
|
294
|
-
}
|
|
295
|
-
/**
|
|
296
|
-
* Walks an AST node against the allowlist. Returns an error message on the first
|
|
297
|
-
* disallowed construct, or null if the entire subtree is permitted.
|
|
298
|
-
* @private
|
|
299
|
-
*/
|
|
300
|
-
checkNode(node) {
|
|
301
|
-
switch (node.type) {
|
|
302
|
-
case 'Literal': {
|
|
303
|
-
const lit = node;
|
|
304
|
-
// Regex literals can be crafted for catastrophic backtracking; permit strings/numbers/booleans/null only.
|
|
305
|
-
if (lit.value instanceof RegExp || 'regex' in lit) {
|
|
306
|
-
return this.forbidden('regular expression literals are not allowed');
|
|
307
|
-
}
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
case 'Identifier': {
|
|
311
|
-
const name = node.name;
|
|
312
|
-
if (SafeExpressionEvaluator.DANGEROUS_IDENTIFIERS.has(name)) {
|
|
313
|
-
return this.forbidden(`the identifier "${name}"`);
|
|
314
|
-
}
|
|
315
|
-
return null;
|
|
316
|
-
}
|
|
317
|
-
case 'ChainExpression':
|
|
318
|
-
// `a?.b`, `a?.[0]`, `a?.b()` — the optional marker changes only whether the member
|
|
319
|
-
// read short-circuits on null/undefined, so unwrapping lands the inner node back in
|
|
320
|
-
// the ordinary member/call rules and `a?.constructor` stays blocked.
|
|
321
|
-
return this.checkNode(node.expression);
|
|
322
|
-
case 'MemberExpression':
|
|
323
|
-
return this.checkMemberExpression(node);
|
|
324
|
-
case 'CallExpression':
|
|
325
|
-
return this.checkCallExpression(node);
|
|
326
|
-
case 'ArrowFunctionExpression':
|
|
327
|
-
return this.checkArrowFunction(node);
|
|
328
|
-
case 'BinaryExpression': {
|
|
329
|
-
const bin = node;
|
|
330
|
-
if (!SafeExpressionEvaluator.ALLOWED_BINARY_OPERATORS.has(bin.operator)) {
|
|
331
|
-
return this.forbidden(`the operator "${bin.operator}"`);
|
|
332
|
-
}
|
|
333
|
-
return this.checkNode(bin.left) ?? this.checkNode(bin.right);
|
|
334
|
-
}
|
|
335
|
-
case 'LogicalExpression': {
|
|
336
|
-
const log = node;
|
|
337
|
-
return this.checkNode(log.left) ?? this.checkNode(log.right);
|
|
338
|
-
}
|
|
339
|
-
case 'UnaryExpression': {
|
|
340
|
-
const un = node;
|
|
341
|
-
if (!SafeExpressionEvaluator.ALLOWED_UNARY_OPERATORS.has(un.operator)) {
|
|
342
|
-
return this.forbidden(`the unary operator "${un.operator}"`);
|
|
343
|
-
}
|
|
344
|
-
return this.checkNode(un.argument);
|
|
345
|
-
}
|
|
346
|
-
case 'ConditionalExpression': {
|
|
347
|
-
const cond = node;
|
|
348
|
-
return this.checkNode(cond.test) ?? this.checkNode(cond.consequent) ?? this.checkNode(cond.alternate);
|
|
349
|
-
}
|
|
350
|
-
case 'ArrayExpression': {
|
|
351
|
-
const arr = node;
|
|
352
|
-
for (const el of arr.elements) {
|
|
353
|
-
if (!el)
|
|
354
|
-
continue; // array hole
|
|
355
|
-
if (el.type === 'SpreadElement') {
|
|
356
|
-
return this.forbidden('spread elements are not allowed');
|
|
357
|
-
}
|
|
358
|
-
const err = this.checkNode(el);
|
|
359
|
-
if (err)
|
|
360
|
-
return err;
|
|
361
|
-
}
|
|
362
|
-
return null;
|
|
363
|
-
}
|
|
364
|
-
default:
|
|
365
|
-
return this.forbidden(`${node.type} is not allowed`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Member access is allowed for dotted property reads and literal-key bracket
|
|
370
|
-
* reads, but never for a computed key built at runtime (the string-concat
|
|
371
|
-
* escape route) or a prototype-chain property.
|
|
372
|
-
* @private
|
|
373
|
-
*/
|
|
374
|
-
checkMemberExpression(node) {
|
|
375
|
-
if (node.property.type === 'PrivateIdentifier') {
|
|
376
|
-
return this.forbidden('private fields are not allowed');
|
|
377
|
-
}
|
|
378
|
-
if (node.computed) {
|
|
379
|
-
// Bracket access must use a literal — a computed key (`x["con"+"structor"]`,
|
|
380
|
-
// `x[y]`) is exactly the route a textual denylist misses, so reject anything
|
|
381
|
-
// that is not a plain string/number literal.
|
|
382
|
-
if (node.property.type !== 'Literal') {
|
|
383
|
-
return this.forbidden('computed member access must use a literal index/key');
|
|
384
|
-
}
|
|
385
|
-
const value = node.property.value;
|
|
386
|
-
if (typeof value === 'string') {
|
|
387
|
-
if (SafeExpressionEvaluator.DANGEROUS_PROPERTY_NAMES.has(value)) {
|
|
388
|
-
return this.forbidden(`access to "${value}"`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
else if (typeof value !== 'number') {
|
|
392
|
-
return this.forbidden('computed member access must use a string or numeric literal');
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
else {
|
|
396
|
-
const name = node.property.name;
|
|
397
|
-
if (SafeExpressionEvaluator.DANGEROUS_PROPERTY_NAMES.has(name)) {
|
|
398
|
-
return this.forbidden(`access to "${name}"`);
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return this.checkNode(node.object);
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* A call is admitted only when its callee is a FIXED name on one of two allowlists: a bare
|
|
405
|
-
* safe global (`parseInt(...)`) or a dotted method that is either a safe instance method or a
|
|
406
|
-
* method of a safe global namespace (`Math.abs(...)`). Everything else — a computed callee, an
|
|
407
|
-
* unlisted bare identifier (`eval(...)`, `Function(...)`), a call on a call's result that is
|
|
408
|
-
* not itself a safe method — is rejected.
|
|
409
|
-
* @private
|
|
410
|
-
*/
|
|
411
|
-
checkCallExpression(node) {
|
|
412
|
-
const callee = node.callee;
|
|
413
|
-
if (callee.type === 'Identifier') {
|
|
414
|
-
const name = callee.name;
|
|
415
|
-
if (!SAFE_GLOBAL_CALLABLES.has(name)) {
|
|
416
|
-
return this.forbidden(`calling "${name}" — only safe methods and safe global functions may be invoked`);
|
|
592
|
+
// A precise message for the commonest typo. Counted only after the parser fails, so a
|
|
593
|
+
// parenthesis inside a string literal (`'(' + value + ')'`) is not miscounted.
|
|
594
|
+
if (SafeExpressionEvaluator.hasUnbalancedParentheses(expression)) {
|
|
595
|
+
return 'Unbalanced parentheses in expression';
|
|
417
596
|
}
|
|
418
|
-
return
|
|
597
|
+
return forbidden(`it is not a parseable single expression (${e instanceof Error ? e.message : String(e)})`);
|
|
419
598
|
}
|
|
420
|
-
if (
|
|
421
|
-
return
|
|
422
|
-
}
|
|
423
|
-
const member = callee;
|
|
424
|
-
if (member.computed || member.property.type !== 'Identifier') {
|
|
425
|
-
return this.forbidden('methods must be called by a dotted, non-computed name');
|
|
426
|
-
}
|
|
427
|
-
const method = member.property.name;
|
|
428
|
-
if (!this.isCallableMethod(member.object, method)) {
|
|
429
|
-
return this.forbidden(`the method "${method}" is not on the safe-method allowlist`);
|
|
430
|
-
}
|
|
431
|
-
const receiverError = this.checkNode(member.object);
|
|
432
|
-
if (receiverError)
|
|
433
|
-
return receiverError;
|
|
434
|
-
return this.checkCallArguments(node);
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Whether `<receiver>.<method>()` names a permitted call: a safe instance method on any value,
|
|
438
|
-
* or a method of a safe global namespace when the receiver is that namespace by bare name.
|
|
439
|
-
* @private
|
|
440
|
-
*/
|
|
441
|
-
isCallableMethod(receiver, method) {
|
|
442
|
-
if (SafeExpressionEvaluator.SAFE_METHODS.includes(method)) {
|
|
443
|
-
return true;
|
|
444
|
-
}
|
|
445
|
-
if (receiver.type !== 'Identifier') {
|
|
446
|
-
return false;
|
|
447
|
-
}
|
|
448
|
-
return SAFE_GLOBAL_NAMESPACE_METHODS.get(receiver.name)?.has(method) === true;
|
|
449
|
-
}
|
|
450
|
-
/** Walks a call's arguments; spreads are rejected outright. @private */
|
|
451
|
-
checkCallArguments(node) {
|
|
452
|
-
for (const arg of node.arguments) {
|
|
453
|
-
if (arg.type === 'SpreadElement') {
|
|
454
|
-
return this.forbidden('spread arguments are not allowed');
|
|
455
|
-
}
|
|
456
|
-
const err = this.checkNode(arg);
|
|
457
|
-
if (err)
|
|
458
|
-
return err;
|
|
599
|
+
if (program.body.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
|
|
600
|
+
return forbidden('only a single expression is permitted — no statements, blocks, or semicolons');
|
|
459
601
|
}
|
|
460
|
-
|
|
602
|
+
const keys = allowedIdentifiers === null ? null : new Set(allowedIdentifiers);
|
|
603
|
+
return new ExpressionScreen(keys).Check(program.body[0].expression);
|
|
461
604
|
}
|
|
462
|
-
/**
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
if (param.type !== 'Identifier') {
|
|
473
|
-
return this.forbidden('arrow-function parameters must be simple identifiers');
|
|
474
|
-
}
|
|
475
|
-
if (SafeExpressionEvaluator.DANGEROUS_IDENTIFIERS.has(param.name)) {
|
|
476
|
-
return this.forbidden(`the parameter "${param.name}"`);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
if (node.body.type === 'BlockStatement') {
|
|
480
|
-
return this.forbidden('arrow-function bodies must be an expression, not a block');
|
|
605
|
+
/** Counts parentheses textually; used only to word the error for an expression that failed to parse. @private */
|
|
606
|
+
static hasUnbalancedParentheses(expression) {
|
|
607
|
+
let depth = 0;
|
|
608
|
+
for (const char of expression) {
|
|
609
|
+
if (char === '(')
|
|
610
|
+
depth++;
|
|
611
|
+
if (char === ')')
|
|
612
|
+
depth--;
|
|
613
|
+
if (depth < 0)
|
|
614
|
+
return true;
|
|
481
615
|
}
|
|
482
|
-
return
|
|
616
|
+
return depth !== 0;
|
|
483
617
|
}
|
|
484
618
|
/**
|
|
485
619
|
* Creates a safe context object with only allowed properties
|
|
@@ -491,6 +625,9 @@ export class SafeExpressionEvaluator {
|
|
|
491
625
|
* @private
|
|
492
626
|
*/
|
|
493
627
|
createSafeContext(context) {
|
|
628
|
+
if (context === null || typeof context !== 'object') {
|
|
629
|
+
throw new Error('Context must be an object');
|
|
630
|
+
}
|
|
494
631
|
// Deep clone to prevent modifications to original
|
|
495
632
|
const safeContext = {};
|
|
496
633
|
for (const [key, value] of Object.entries(context)) {
|
|
@@ -498,6 +635,11 @@ export class SafeExpressionEvaluator {
|
|
|
498
635
|
if (this.isDangerousPropertyName(key)) {
|
|
499
636
|
continue;
|
|
500
637
|
}
|
|
638
|
+
// Every key becomes a parameter name of the compiled function, so anything but a plain
|
|
639
|
+
// identifier (`a = fetch(x)`, `...rest`, `a, b`) would be injected as code. Fail closed.
|
|
640
|
+
if (!CONTEXT_KEY_PATTERN.test(key)) {
|
|
641
|
+
throw new Error(`Context key "${key}" is not a valid identifier; context keys must match ${CONTEXT_KEY_PATTERN.source}`);
|
|
642
|
+
}
|
|
501
643
|
// Clone value safely
|
|
502
644
|
safeContext[key] = this.cloneValue(value);
|
|
503
645
|
}
|
|
@@ -525,6 +667,11 @@ export class SafeExpressionEvaluator {
|
|
|
525
667
|
/**
|
|
526
668
|
* Safely clones a value for use in evaluation context
|
|
527
669
|
*
|
|
670
|
+
* Functions are never carried into the context: a context should hold data, and a function
|
|
671
|
+
* reachable from an expression is one more thing that could be invoked. A function value
|
|
672
|
+
* becomes `undefined`, and function-valued properties are dropped from plain objects and
|
|
673
|
+
* class instances alike.
|
|
674
|
+
*
|
|
528
675
|
* @param {any} value - The value to clone
|
|
529
676
|
*
|
|
530
677
|
* @returns {any} The cloned value
|
|
@@ -536,6 +683,9 @@ export class SafeExpressionEvaluator {
|
|
|
536
683
|
return value;
|
|
537
684
|
}
|
|
538
685
|
const type = typeof value;
|
|
686
|
+
if (type === 'function') {
|
|
687
|
+
return undefined;
|
|
688
|
+
}
|
|
539
689
|
// Primitives are safe
|
|
540
690
|
if (type === 'string' || type === 'number' || type === 'boolean') {
|
|
541
691
|
return value;
|
|
@@ -548,7 +698,7 @@ export class SafeExpressionEvaluator {
|
|
|
548
698
|
if (type === 'object' && value.constructor === Object) {
|
|
549
699
|
const cloned = {};
|
|
550
700
|
for (const [key, val] of Object.entries(value)) {
|
|
551
|
-
if (!this.isDangerousPropertyName(key)) {
|
|
701
|
+
if (!this.isDangerousPropertyName(key) && typeof val !== 'function') {
|
|
552
702
|
cloned[key] = this.cloneValue(val);
|
|
553
703
|
}
|
|
554
704
|
}
|
|
@@ -563,7 +713,7 @@ export class SafeExpressionEvaluator {
|
|
|
563
713
|
if (type === 'object' && typeof value.constructor === 'function') {
|
|
564
714
|
const cloned = {};
|
|
565
715
|
for (const [key, val] of Object.entries(value)) {
|
|
566
|
-
if (!this.isDangerousPropertyName(key)) {
|
|
716
|
+
if (!this.isDangerousPropertyName(key) && typeof val !== 'function') {
|
|
567
717
|
// Don't recursively clone class instance properties
|
|
568
718
|
// Just copy them directly for safe shallow access
|
|
569
719
|
cloned[key] = val;
|
|
@@ -590,48 +740,46 @@ export class SafeExpressionEvaluator {
|
|
|
590
740
|
* is a perfectly good condition that simply has no data yet. A submit-time check built on
|
|
591
741
|
* `evaluate` therefore refuses every legitimate condition.
|
|
592
742
|
*
|
|
593
|
-
* So this
|
|
594
|
-
* `evaluate` applies (the AST allowlist), then the same function body
|
|
595
|
-
* never invoked. Values are never consulted, which is precisely the property wanted: unknown
|
|
743
|
+
* So this parses and never runs. The expression goes through the same policy screen
|
|
744
|
+
* `evaluate` applies (the AST allowlist), then the same function body `evaluate` compiles is
|
|
745
|
+
* parsed in strict mode — never compiled, never invoked. Values are never consulted, which is precisely the property wanted: unknown
|
|
596
746
|
* identifiers, absent properties and undefined chains all PASS, because none of them is a syntax
|
|
597
|
-
* error and whether they resolve is a question about a run that has not happened yet.
|
|
747
|
+
* error and whether they resolve is a question about a run that has not happened yet. Pass
|
|
748
|
+
* `contextKeys` when the evaluation context's keys ARE known, and unknown identifiers are refused
|
|
749
|
+
* here exactly as {@link Evaluate} would refuse them.
|
|
598
750
|
*
|
|
599
|
-
* **
|
|
600
|
-
*
|
|
601
|
-
* first, so the constructs `evaluate` refuses are refused here too — those produce a
|
|
602
|
-
* runtime refusal, and an author is better told now.
|
|
751
|
+
* **Parsing is not evaluation**, and the distinction is load-bearing for safety: no code is
|
|
752
|
+
* constructed from the expression here, so nothing in it can execute. The policy screen still
|
|
753
|
+
* runs first, so the constructs `evaluate` refuses are refused here too — those produce a
|
|
754
|
+
* permanent runtime refusal, and an author is better told now.
|
|
603
755
|
*
|
|
604
|
-
*
|
|
605
|
-
* CSP without `unsafe-eval`)
|
|
606
|
-
*
|
|
756
|
+
* Because nothing is compiled, the answer does not depend on the host allowing dynamic code (a
|
|
757
|
+
* strict CSP without `unsafe-eval`). `Undecidable` is kept in the result type for callers that
|
|
758
|
+
* check it, and is no longer returned.
|
|
607
759
|
*
|
|
608
760
|
* @param expression the expression to check
|
|
609
|
-
* @
|
|
610
|
-
*
|
|
761
|
+
* @param contextKeys the names the expression may reference, when known; omitted, free
|
|
762
|
+
* identifiers are left for evaluation time (see {@link ScreenExpression})
|
|
763
|
+
* @returns `Valid: true` when it parses; `Valid: false` with `Error` when it does not
|
|
611
764
|
*/
|
|
612
|
-
ValidateSyntax(expression) {
|
|
613
|
-
const policyError =
|
|
765
|
+
ValidateSyntax(expression, contextKeys) {
|
|
766
|
+
const policyError = SafeExpressionEvaluator.ScreenExpression(expression, contextKeys ?? null);
|
|
614
767
|
if (policyError) {
|
|
615
768
|
return { Valid: false, Error: policyError };
|
|
616
769
|
}
|
|
617
770
|
try {
|
|
618
|
-
// Same
|
|
619
|
-
//
|
|
620
|
-
|
|
771
|
+
// Same body `evaluate` compiles, parsed in strict mode so strict-only syntax errors are
|
|
772
|
+
// caught too. Parsed, never compiled.
|
|
773
|
+
parse(`"use strict"; return Boolean(${expression});`, { ecmaVersion: 2022, allowReturnOutsideFunction: true });
|
|
621
774
|
return { Valid: true };
|
|
622
775
|
}
|
|
623
776
|
catch (e) {
|
|
624
|
-
|
|
625
|
-
return { Valid: false, Error: e.message };
|
|
626
|
-
}
|
|
627
|
-
// Anything else — an EvalError from a CSP, a host without `Function` — means this
|
|
628
|
-
// environment cannot judge, not that the expression is wrong.
|
|
629
|
-
return { Valid: true, Undecidable: true };
|
|
777
|
+
return { Valid: false, Error: e instanceof Error ? e.message : String(e) };
|
|
630
778
|
}
|
|
631
779
|
}
|
|
632
780
|
/** @deprecated Use {@link ValidateSyntax}. */
|
|
633
|
-
validateSyntax(expression) {
|
|
634
|
-
return this.ValidateSyntax(expression);
|
|
781
|
+
validateSyntax(expression, contextKeys) {
|
|
782
|
+
return this.ValidateSyntax(expression, contextKeys);
|
|
635
783
|
}
|
|
636
784
|
/**
|
|
637
785
|
* Evaluates multiple expressions and returns all results
|