/**
* @file evaluator.js
* @description Evaluates a parsed expression against a scope.
*
* ## This is where the security boundary actually is
*
* The old sandbox tried to be a boundary from outside the evaluation: it
* wrapped values that reached an expression through the scope, and it grepped
* the source text for `constructor`, `__proto__` and `prototype`. Neither
* survives contact with the language.
*
* ```js
* ({})['const'+'ructor']['const'+'ructor']('return 1')()
* ```
*
* The object literal is created by the engine *inside* the expression, so it
* never passes through the scope and is never wrapped; and the property name is
* assembled at runtime, so no amount of reading the source finds it. Both
* escapes are consequences of handing the expression to `new Function` and
* inspecting from the outside.
*
* Here, nothing is handed over. Every property read goes through
* {@link readMember} with the key already resolved to a string, so `x.constructor`
* and `x['const'+'ructor']` arrive at the same check as the same value. Every
* call goes through {@link invoke}. There is no path to a value the evaluator
* did not itself produce.
*
* ## What is guaranteed, and what is not
*
* Guaranteed: an expression cannot reach the `Function` constructor, cannot
* read or write `__proto__` / `constructor` / `prototype` however the key is
* spelled, cannot reach a built-in prototype object, and cannot name a global
* outside {@link ALLOWED_GLOBALS}. There is no `eval` and no `new Function`, so
* a page carrying only Avenx expressions does not need `'unsafe-eval'`.
*
* Not guaranteed: this is not an isolation boundary against hostile expression
* source*. An expression can still call any function the scope legitimately
* exposes, and a bridge action can do whatever its own JavaScript does. The
* boundary protects the runtime from accidents and from reaching outside the
* declared scope; it does not make it safe to evaluate expressions written by
* an untrusted party. Anything stronger would need a separate realm, and
* claiming it without one is how the previous sandbox came to be believed.
*
* ## Determinism
*
* Global resolution goes through the tracer's substitution point, exactly as
* the previous sandbox did, so a recorded session still observes and replays
* the non-deterministic values (`Date`, `Math.random`) an expression sees.
* There is a single evaluation choke point here, which is what Trace needs.
* @module lib/core/expression/evaluator
*/
import { AvenxError, AvenxErrorCodes } from '../runtime/AvenxError.js';
import { resolveSandboxGlobal } from '../trace/globals.js';
/**
* Globals an expression may name.
*
* Deliberately identical to the set the previous sandbox allowed, so migrating
* an application changes nothing about which globals its templates can see.
* @type {Set<string>}
*/
export const ALLOWED_GLOBALS = new Set([
'Math', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date', 'Error',
'Map', 'Set', 'Promise', 'console', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent',
'undefined', 'NaN', 'Infinity',
]);
/**
* Globals an expression may never name, whether or not the host defines them.
*
* Listed explicitly rather than inferred from `in globalThis`, because the
* diagnostic has to be the same in every environment: `localStorage` is
* restricted in a browser, in happy-dom and in bare Node, and a developer
* reading AVX_R15 should not get a different answer depending on where the
* expression happened to run.
* @type {Set<string>}
*/
const RESTRICTED_GLOBALS = new Set([
'window', 'document', 'localStorage', 'sessionStorage', 'location', 'navigator',
'history', 'fetch', 'alert', 'confirm', 'prompt', 'setTimeout', 'setInterval',
'clearTimeout', 'clearInterval', 'XMLHttpRequest', 'WebSocket', 'global',
'globalThis', 'process', 'eval', 'Function', 'Reflect', 'Proxy', 'Symbol',
'require', 'import', 'structuredClone',
]);
/**
* Property names an expression may never read or write.
*
* Checked against the *resolved* key, so a computed access spelled
* `x['const' + 'ructor']` is rejected on the same terms as `x.constructor`.
* @type {Set<string>}
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* The dynamic-code constructors.
*
* Reaching any of these would be arbitrary code execution, so they are refused
* as values however they were obtained.
* @type {Set<Function>}
*/
const FUNCTION_CONSTRUCTORS = new Set(
[
Function,
Object.getPrototypeOf(function* () {}).constructor,
Object.getPrototypeOf(async () => {}).constructor,
Object.getPrototypeOf(async function* () {}).constructor,
].filter((ctor) => typeof ctor === 'function'),
);
/**
* Built-in prototypes shared by every object in the realm.
*
* Handing one to an expression would let it mutate state shared with the host
* page, so they are refused as values rather than by enumerating every mutating
* method on them.
* @type {Set<object>}
*/
const PROTECTED_PROTOTYPES = new Set(
[
Object.prototype, Array.prototype, Function.prototype, String.prototype,
Number.prototype, Boolean.prototype, Date.prototype, RegExp.prototype,
Error.prototype, Map.prototype, Set.prototype, WeakMap.prototype,
WeakSet.prototype, Promise.prototype,
].filter(Boolean),
);
/**
* Raises a sandbox violation.
* @param {string} message - What was refused.
* @throws {AvenxError} Always.
*/
function refuse(message) {
throw new AvenxError(AvenxErrorCodes.SANDBOX_VIOLATION, message);
}
/**
* Refuses a value that must never reach an expression.
* @param {any} value - The value about to be returned.
* @returns {any} The value, when it is allowed.
*/
function guardValue(value) {
if (typeof value === 'function' && FUNCTION_CONSTRUCTORS.has(value)) {
refuse('Access to the Function constructor is blocked for security reasons.');
}
if (value !== null && (typeof value === 'object' || typeof value === 'function') && PROTECTED_PROTOTYPES.has(value)) {
refuse('Access to built-in prototypes is blocked for security reasons.');
}
return value;
}
/**
* Reads a property, with the key already resolved.
*
* This is the single gate the old sandbox lacked. Because the key arrives as a
* value rather than as source text, there is no spelling of it that avoids the
* check.
* @param {any} object - The object to read from.
* @param {any} key - The resolved property key.
* @param {boolean} optional - Whether the access used `?.`.
* @returns {any} The property value.
*/
export function readMember(object, key, optional) {
if (object === null || object === undefined) {
if (optional) return undefined;
throw new TypeError(`Cannot read property "${String(key)}" of ${object === null ? 'null' : 'undefined'}`);
}
const name = typeof key === 'symbol' ? key : String(key);
if (typeof name === 'string' && FORBIDDEN_KEYS.has(name)) {
refuse(`Access to property "${name}" is blocked for security reasons.`);
}
return guardValue(object[name]);
}
/**
* Writes a property, with the key already resolved.
* @param {any} object - The object to write to.
* @param {any} key - The resolved property key.
* @param {any} value - The value to assign.
* @returns {any} The assigned value.
*/
export function writeMember(object, key, value) {
if (object === null || object === undefined) {
throw new TypeError(`Cannot set property "${String(key)}" of ${object === null ? 'null' : 'undefined'}`);
}
const name = typeof key === 'symbol' ? key : String(key);
if (typeof name === 'string' && FORBIDDEN_KEYS.has(name)) {
refuse(`Writing to property "${name}" is blocked for security reasons.`);
}
if (PROTECTED_PROTOTYPES.has(object)) {
refuse('Writing to a built-in prototype is blocked for security reasons.');
}
object[name] = value;
return value;
}
/**
* Calls a function on behalf of an expression.
* @param {any} fn - The callee.
* @param {any} thisArg - The receiver.
* @param {any[]} args - The arguments.
* @param {string} description - How the callee was written, for the error.
* @returns {any} The result.
*/
export function invoke(fn, thisArg, args, description) {
if (typeof fn !== 'function') {
throw new TypeError(`${description} is not a function`);
}
if (FUNCTION_CONSTRUCTORS.has(fn)) {
refuse('Calling the Function constructor is blocked for security reasons.');
}
for (const arg of args) {
if (arg !== null && (typeof arg === 'object' || typeof arg === 'function') && PROTECTED_PROTOTYPES.has(arg)) {
refuse('Passing a built-in prototype is blocked for security reasons.');
}
}
return guardValue(fn.apply(thisArg, args));
}
/**
* A chain of name bindings.
*
* Arrow-function parameters and nothing else live here; everything else
* resolves against the scope the caller supplied. Keeping the two separate is
* what stops a lambda parameter from silently writing to component state.
*/
class Frame {
/**
* @param {Frame|null} parent - The enclosing frame.
* @param {Object<string, any>} bindings - Names bound by this frame.
*/
constructor(parent, bindings) {
this.parent = parent;
this.bindings = bindings;
}
/**
* Finds the frame binding a name.
* @param {string} name - The name to resolve.
* @returns {Frame|null} The binding frame, or null.
*/
lookup(name) {
let frame = this;
while (frame) {
if (Object.prototype.hasOwnProperty.call(frame.bindings, name)) {
return frame;
}
frame = frame.parent;
}
return null;
}
}
/**
* Evaluates a parsed expression.
* @param {object} node - The AST root.
* @param {object} scope - The evaluation scope. Reads and writes of names the
* scope binds go through it, which is what keeps dependency tracking working.
* @param {object} [options] - Evaluation options.
* @param {Frame} [options.frame] - The enclosing binding frame.
* @returns {any} The expression's value.
*/
export function evaluate(node, scope, options = {}) {
const frame = options.frame || null;
return evalNode(node, scope, frame);
}
/**
* Resolves an identifier to a value.
* @param {string} name - The identifier.
* @param {object} scope - The evaluation scope.
* @param {Frame|null} frame - The enclosing binding frame.
* @returns {any} The bound value.
*/
function readIdentifier(name, scope, frame) {
const bindingFrame = frame && frame.lookup(name);
if (bindingFrame) {
return bindingFrame.bindings[name];
}
if (scope && name in scope) {
return guardValue(scope[name]);
}
if (ALLOWED_GLOBALS.has(name)) {
// Routed through the tracer's substitution point rather than read straight
// off globalThis, so a recording can log the non-deterministic values an
// expression observes and a replay can hand the same ones back.
return guardValue(resolveSandboxGlobal(name));
}
if (isKnownGlobal(name)) {
refuse(
`[Avenx Sandbox Violation] Access to global object "${name}" is restricted inside templates. Decouple browser APIs into component methods.`,
);
}
return undefined;
}
/**
* Whether a name exists on the host global object.
* @param {string} name - The identifier.
* @returns {boolean} True when the host defines it.
*/
function isKnownGlobal(name) {
if (RESTRICTED_GLOBALS.has(name)) {
return true;
}
try {
return typeof globalThis !== 'undefined' && name in globalThis;
} catch {
return false;
}
}
/**
* Assigns to an identifier.
* @param {string} name - The identifier.
* @param {any} value - The value to assign.
* @param {object} scope - The evaluation scope.
* @param {Frame|null} frame - The enclosing binding frame.
* @returns {any} The assigned value.
*/
function writeIdentifier(name, value, scope, frame) {
const bindingFrame = frame && frame.lookup(name);
if (bindingFrame) {
bindingFrame.bindings[name] = value;
return value;
}
if (ALLOWED_GLOBALS.has(name) && !(scope && name in scope)) {
refuse(`Assigning to the global "${name}" is blocked for security reasons.`);
}
if (!(scope && name in scope) && isKnownGlobal(name)) {
refuse(
`[Avenx Sandbox Violation] Access to global object "${name}" is restricted inside templates. Decouple browser APIs into component methods.`,
);
}
scope[name] = value;
return value;
}
/**
* Applies a binary operator.
* @param {string} operator - The operator.
* @param {any} left - Left operand.
* @param {any} right - Right operand.
* @returns {any} The result.
*/
function applyBinary(operator, left, right) {
switch (operator) {
case '+': return left + right;
case '-': return left - right;
case '*': return left * right;
case '/': return left / right;
case '%': return left % right;
case '**': return left ** right;
// Loose equality is part of the language being evaluated, not a choice
// made here: an expression that writes `==` must mean `==`.
case '==': return left == right;
case '!=': return left != right;
case '===': return left === right;
case '!==': return left !== right;
case '<': return left < right;
case '>': return left > right;
case '<=': return left <= right;
case '>=': return left >= right;
case '&': return left & right;
case '|': return left | right;
case '^': return left ^ right;
case '<<': return left << right;
case '>>': return left >> right;
case '>>>': return left >>> right;
case 'in': return left in Object(right);
case 'instanceof': return left instanceof right;
default:
refuse(`Unsupported operator "${operator}"`);
return undefined;
}
}
/**
* Computes the value an assignment operator produces.
* @param {string} operator - The assignment operator.
* @param {any} current - The current value.
* @param {any} operand - The right-hand value.
* @returns {any} The value to store.
*/
function applyCompound(operator, current, operand) {
if (operator === '=') return operand;
return applyBinary(operator.slice(0, -1), current, operand);
}
/**
* Evaluates one AST node.
* @param {object} node - The node.
* @param {object} scope - The evaluation scope.
* @param {Frame|null} frame - The enclosing binding frame.
* @returns {any} The node's value.
*/
function evalNode(node, scope, frame) {
switch (node.type) {
case 'Literal':
return node.value;
case 'Identifier':
return readIdentifier(node.name, scope, frame);
case 'TemplateLiteral': {
let out = node.quasis[0];
for (let i = 0; i < node.expressions.length; i++) {
out += String(evalNode(node.expressions[i], scope, frame));
out += node.quasis[i + 1];
}
return out;
}
case 'ArrayLiteral': {
const items = [];
for (const element of node.elements) {
if (element.type === 'Spread') {
items.push(...evalNode(element.argument, scope, frame));
} else {
items.push(evalNode(element, scope, frame));
}
}
return items;
}
case 'ObjectLiteral': {
// Created with a null prototype and then given Object.prototype, so a
// key named __proto__ cannot change the object's prototype on the way in.
const result = {};
for (const property of node.properties) {
if (property.kind === 'spread') {
Object.assign(result, evalNode(property.value, scope, frame));
continue;
}
const key = property.key.computed
? String(evalNode(property.key.node, scope, frame))
: property.key.node.value;
if (FORBIDDEN_KEYS.has(key)) {
refuse(`Defining property "${key}" is blocked for security reasons.`);
}
result[key] = evalNode(property.value, scope, frame);
}
return result;
}
case 'Member': {
const object = evalNode(node.object, scope, frame);
if (node.optional && (object === null || object === undefined)) {
return undefined;
}
const key = node.computed ? evalNode(node.property, scope, frame) : node.property.value;
return readMember(object, key, node.optional);
}
case 'Call': {
const { callee } = node;
let thisArg;
let fn;
let description;
if (callee.type === 'Member') {
const object = evalNode(callee.object, scope, frame);
if (callee.optional && (object === null || object === undefined)) {
return undefined;
}
const key = callee.computed ? evalNode(callee.property, scope, frame) : callee.property.value;
thisArg = object;
fn = readMember(object, key, callee.optional);
description = `${describe(callee.object)}.${String(key)}`;
} else {
thisArg = undefined;
fn = evalNode(callee, scope, frame);
description = describe(callee);
}
if (node.optional && (fn === null || fn === undefined)) {
return undefined;
}
const args = [];
for (const arg of node.args) {
if (arg.type === 'Spread') {
args.push(...evalNode(arg.argument, scope, frame));
} else {
args.push(evalNode(arg, scope, frame));
}
}
return invoke(fn, thisArg, args, description);
}
case 'New': {
const ctor = evalNode(node.callee, scope, frame);
if (typeof ctor !== 'function') {
throw new TypeError(`${describe(node.callee)} is not a constructor`);
}
if (FUNCTION_CONSTRUCTORS.has(ctor)) {
refuse('Constructing a function from a string is blocked for security reasons.');
}
const args = [];
for (const arg of node.args) {
if (arg.type === 'Spread') {
args.push(...evalNode(arg.argument, scope, frame));
} else {
args.push(evalNode(arg, scope, frame));
}
}
return guardValue(Reflect.construct(ctor, args));
}
case 'Unary': {
if (node.operator === 'typeof' && node.argument.type === 'Identifier') {
// `typeof maybeUndefined` must not throw for an unbound name, which is
// the whole reason the operator gets used in a template.
const bindingFrame = frame && frame.lookup(node.argument.name);
if (!bindingFrame && !(scope && node.argument.name in scope) && !ALLOWED_GLOBALS.has(node.argument.name)) {
return 'undefined';
}
}
const value = evalNode(node.argument, scope, frame);
switch (node.operator) {
case '!': return !value;
case '-': return -value;
case '+': return +value;
case '~': return ~value;
case 'typeof': return typeof value;
case 'void': return undefined;
default:
refuse(`Unsupported operator "${node.operator}"`);
return undefined;
}
}
case 'Binary':
return applyBinary(node.operator, evalNode(node.left, scope, frame), evalNode(node.right, scope, frame));
case 'Logical': {
const left = evalNode(node.left, scope, frame);
if (node.operator === '&&') return left ? evalNode(node.right, scope, frame) : left;
if (node.operator === '||') return left ? left : evalNode(node.right, scope, frame);
return left === null || left === undefined ? evalNode(node.right, scope, frame) : left;
}
case 'Conditional':
return evalNode(node.test, scope, frame)
? evalNode(node.consequent, scope, frame)
: evalNode(node.alternate, scope, frame);
case 'Assignment': {
const { target } = node;
if (target.type === 'Identifier') {
if (node.operator === '=') {
return writeIdentifier(target.name, evalNode(node.value, scope, frame), scope, frame);
}
const current = readIdentifier(target.name, scope, frame);
if (node.operator === '&&=' && !current) return current;
if (node.operator === '||=' && current) return current;
if (node.operator === '??=' && current !== null && current !== undefined) return current;
const operand = evalNode(node.value, scope, frame);
const next = node.operator.length === 3 && node.operator[2] === '='
? operand
: applyCompound(node.operator, current, operand);
return writeIdentifier(target.name, next, scope, frame);
}
const object = evalNode(target.object, scope, frame);
const key = target.computed ? evalNode(target.property, scope, frame) : target.property.value;
if (node.operator === '=') {
return writeMember(object, key, evalNode(node.value, scope, frame));
}
const current = readMember(object, key, false);
if (node.operator === '&&=' && !current) return current;
if (node.operator === '||=' && current) return current;
if (node.operator === '??=' && current !== null && current !== undefined) return current;
const operand = evalNode(node.value, scope, frame);
const next = node.operator.length === 3 && node.operator[2] === '='
? operand
: applyCompound(node.operator, current, operand);
return writeMember(object, key, next);
}
case 'Update': {
const { argument } = node;
if (argument.type === 'Identifier') {
const current = Number(readIdentifier(argument.name, scope, frame));
const next = node.operator === '++' ? current + 1 : current - 1;
writeIdentifier(argument.name, next, scope, frame);
return node.prefix ? next : current;
}
const object = evalNode(argument.object, scope, frame);
const key = argument.computed ? evalNode(argument.property, scope, frame) : argument.property.value;
const current = Number(readMember(object, key, false));
const next = node.operator === '++' ? current + 1 : current - 1;
writeMember(object, key, next);
return node.prefix ? next : current;
}
case 'Arrow': {
const params = node.params;
const body = node.body;
return (...args) => {
const bindings = {};
for (let i = 0; i < params.length; i++) {
bindings[params[i]] = args[i];
}
return evalNode(body, scope, new Frame(frame, bindings));
};
}
case 'Program': {
// A statement program's value is discarded: `executeStatement` returns
// undefined unless the body used `return`, which is not an expression
// and therefore never reaches here.
for (const statement of node.body) {
evalNode(statement, scope, frame);
}
return undefined;
}
case 'Sequence': {
let result;
for (const expression of node.expressions) {
result = evalNode(expression, scope, frame);
}
return result;
}
case 'Spread':
refuse('A spread element is not valid here');
return undefined;
default:
refuse(`Unsupported expression node "${node.type}"`);
return undefined;
}
}
/**
* Describes a node for an error message.
* @param {object} node - The node.
* @returns {string} A short human-readable description.
*/
function describe(node) {
switch (node.type) {
case 'Identifier': return node.name;
case 'Literal': return JSON.stringify(node.value);
case 'Member':
return node.computed
? `${describe(node.object)}[…]`
: `${describe(node.object)}.${node.property.value}`;
case 'Call': return `${describe(node.callee)}(…)`;
default: return 'expression';
}
}