Source

behaviors/script-behavior.js

/* eslint-disable guard-for-in */

import GeneratorBehavior from './generator-behavior.js';
import * as AyvaModules from '../util/script-whitelist.js';

/**
 * A behavior that allows the execution of scripts stored in a string.
 *
 * WARNING: Scripts are run inside of a rudimentary sandbox. The sandbox is NOT secure.
 *          Care should be taken to avoid running scripts from untrusted sources.
 *
 * For full details on how to use this class, see the {@tutorial behavior-api} tutorial.
 */
class ScriptBehavior extends GeneratorBehavior {
  #scope;

  #code;

  /**
   * Create a new ScriptBehavior.
   *
   * @param {String} script - the script.
   * @param {Object} scope - an object containing variables to include in the script's scope.
   */
  constructor (script, scope = {}) {
    super();

    this.#scope = { ...AyvaModules, ...scope };

    // Escape quotes and backslashes.
    this.#code = script.replace(/["'`\\]/g, (v) => `\\${v}`);
  }

  * generate (ayva) {
    const generator = this.#createSandboxGenerator(ayva);

    /* c8 ignore start */
    // We turn off code coverage for this block because the branch at
    // the end of the loop is never hit due to how generators work... or something like that :)
    while (!this.complete) {
      // We only perform this in a loop so we do not have to recreate the sandbox each iteration.
      yield* generator(ayva);

      // Force a yield in case the sandbox generator did not yield a value.
      // This prevents an infinite loop.
      yield;
    }
    /* c8 ignore stop */
  }

  #createSandboxGenerator () {
    const scopeValues = [];
    let sandboxFunction = 'new Function(';

    for (const scopeProp in this.#scope) {
      sandboxFunction += `"${scopeProp}", `;
      scopeValues.push(this.#scope[scopeProp]);
    }

    for (const prop in globalThis) {
      // Blacklist globals that haven't been put in scope.
      if (!Object.prototype.hasOwnProperty.call(this.#scope, prop)) {
        sandboxFunction += `"${prop}", `;
      }
    }

    // Blacklist eval and Function
    sandboxFunction += '"eval", "Function", ';

    // Create the generate function from script.
    sandboxFunction += `\`
      return (function*(ayva) { 
        ${this.#code} 
      }).bind(this);\`);`;

    try {
      const sandbox = eval(sandboxFunction); // eslint-disable-line no-eval
      return sandbox.call(this, ...scopeValues);
    } catch (error) {
      throw new SyntaxError('Invalid AyvaScript.');
    }
  }
}

export default ScriptBehavior;