Escribiendo un marco de JavaScript – Evaluación de código de espacio aislado

En este capítulo, explicaré las diferentes formas de evaluar el código en el navegador y los problemas que causan. También introduciré un método, que se basa en algunas características nuevas o menos conocidas de JavaScript.

El mal usado eval

La función eval() evalúa el código JavaScript representado como una cadena.

Una solución común para la evaluación del código es la función eval(). El código evaluado con eval() tiene acceso a cierres y al alcance global, lo que conduce a un problema de seguridad denominado inyección de código; y hace que eval() sea una de las características más notorias de JavaScript.

A pesar de ser mal visto, eval() es muy útil en algunas situaciones. La mayoría de los marcos front-end modernos requieren su funcionalidad, pero no se atreven a usarlos debido al problema que mencionamos anteriormente. Como resultado, surgieron muchas soluciones alternativas para evaluar cadenas en una caja de arena en lugar del alcance global. El sandbox evita que el código acceda a datos seguros. Por lo general, es un objeto de JavaScript simple, que reemplaza el objeto global para el código evaluado.

El camino comun

La alternativa a eval() más común es la reimplementación completa: un proceso de dos pasos, que consiste en analizar e interpretar la cadena pasada. Primero, el analizador crea un árbol de sintaxis abstracta, luego el intérprete recorre el árbol y lo interpreta como un código dentro de una sandbox (caja de arena).

Esta es una solución muy utilizada, pero podría decirse que es demasiado pesada para algo tan simple. Reescribir todo desde cero en lugar de aplicar parches eval() presenta muchas oportunidades de errores y requiere modificaciones frecuentes para seguir incluso las últimas actualizaciones de idiomas.

Una forma alternativa

NX intenta evitar la reimplementación de código nativo. La evaluación es manejada por una pequeña biblioteca que usa algunas características nuevas o menos conocidas de JavaScript.

Esta sección introducirá progresivamente estas características y las utilizará para explicar la biblioteca de evaluación de códigos nx-compile. La biblioteca tiene una función llamada compileCode(), que funciona de la siguiente manera:


const code = compileCode('return num1 + num2')

// esto registra 17 en la consola
console.log(code({num1: 10, num2: 7}))

const globalNum = 12
const otherCode = compileCode('return globalNum')

// se evita el acceso de alcance global, este registro no está definido en la consola
console.log(otherCode({num1: 2, num2: 3}))

Al final de este artículo, implementaremos la función compileCode() en menos de 20 líneas.

new Function()

El constructor Function crea un nuevo objeto Función. En JavaScript, cada función es en realidad un objeto de función.

El constructor Function es una alternativa a eval(). new Function(…args, ‘funcBody’) evalúa la cadena ‘funcBody’ pasada como código y devuelve una nueva función que ejecuta ese código. Se diferencia de eval() de dos maneras principales.

  • Evalúa el código pasado solo una vez. Al llamar a la función devuelta, se ejecutará el código sin volver a evaluarlo.
  • No tiene acceso a las variables de cierre locales, sin embargo, todavía puede acceder al ámbito global.


function compileCode (src) {
return new Function(src)
}

new Function(), es una mejor alternativa para eval() en nuestro caso de uso. Tiene un rendimiento y una seguridad superiores, pero aún se debe evitar el acceso de alcance global para que sea viable.

La palabra clave ‘with’

La instrucción with amplía la cadena de alcance de una declaración.

with es una palabra clave menos conocida en JavaScript. Permite una ejecución semi-arenada. El código dentro de un bloque with intenta recuperar las variables del objeto del recinto de seguridad pasado primero, pero si no lo encuentra allí, busca la variable en el cierre y en el ámbito global. El acceso al ámbito de cierre se impide por new Function() lo que solo tenemos que preocuparnos por el ámbito global.


function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
return new Function('sandbox', src)
}

with, utiliza el inoperador internamente. Para cada acceso variable dentro del bloque, evalúa la variable en la condición sandbox. Si la condición es verdadera, recupera la variable de la sandbox. De lo contrario, busca la variable en el ámbito global. Si nos usamos a with para evaluar siempre la variable en la sandbox como true, podríamos evitar que acceda al ámbito global.

Proxies ES6

El objeto Proxy se utiliza para definir el comportamiento personalizado para operaciones fundamentales como la búsqueda o asignación de propiedades.

Un ES6 Proxy envuelve un objeto y define funciones de captura, que pueden interceptar operaciones fundamentales en ese objeto. Las funciones de captura se invocan cuando se produce una operación. Al envolver el objeto de la zona de pruebas en una trampa Proxy podemos sobrescribir el comportamiento predeterminado del inoperador.


function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {has})
return code(sandboxProxy)
}
}

// esta trampa intercepta operaciones 'in' en el proxy del sandbox.
function has (target, key) {
return true
}

El código anterior engaña al bloque with y a la variable in en el sandbox ya que siempre se evaluará como verdadero porque la trampa has siempre devuelve verdadero. El código dentro del bloque with nunca intentará acceder al objeto global.

Evaluación de código en la sandbox: declaración ‘with’ y proxies

Symbol.unscopables

Un símbolo es un tipo de datos único e inmutable y se puede utilizar como un identificador para las propiedades del objeto.

Symbol.unscopables, es un símbolo muy conocido. Un símbolo conocido es un JavaScript incorporado Symbol, que representa el comportamiento del idioma interno. Se pueden utilizar símbolos conocidos para agregar o sobrescribir iteraciones o comportamiento de conversión primitivo, por ejemplo:

El conocido símbolo Symbol.unscopables se usa para especificar un valor de objeto cuyos nombres de propiedad propios y heredados se excluyen de los enlaces de entorno ‘with’.

Symbol.unscopables define las propiedades que no se pueden abrir de un objeto. Las propiedades que no se pueden capturar nunca se recuperan del objeto de sandbox en las declaraciones with, en su lugar se recuperan directamente desde el cierre o el ámbito global. Symbol.unscopables es una característica muy rara vez utilizada.

Podemos solucionar el problema anterior definiendo una trampa get en el sandbox Proxy, que intercepta la recuperación de Symbol.unscopables y siempre devuelve undefined. Esto engañará al bloque with para que piense que nuestro objeto de sandbox no tiene propiedades que no se puedan reparar.


function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {has, get})
return code(sandboxProxy)
}
}

function has (target, key) {
return true
}

function get (target, key) {
if (key === Symbol.unscopables) return undefined
return target[key]
}

WeakMaps para el almacenamiento en caché

El código ahora es seguro, pero su rendimiento aún puede actualizarse, ya que crea un nuevo Proxy en cada invocación de la función return. Esto se puede evitar almacenando en caché y utilizando el mismo Proxy para cada llamada de función con el mismo objeto de espacio aislado.

Un proxy pertenece a un objeto de sandbox, por lo que simplemente podríamos agregar el proxy al objeto de sandbox como una propiedad. Sin embargo, esto expondría nuestros detalles de implementación al público, y no funcionaría en el caso de un objeto inmóvil de sandbox congelado Object.freeze(). Usar una WeakMap es una mejor alternativa en este caso.

El objeto WeakMap es una colección de pares clave / valor en los que las claves tienen una referencia débil. Las claves deben ser objetos y los valores pueden ser valores arbitrarios.

Se puede usar a la WeakMap para adjuntar datos a un objeto sin extenderlo directamente con propiedades. Podemos usar WeakMaps para agregar indirectamente el caché de los Proxies a los objetos de la sandbox.


const sandboxProxies = new WeakMap()

function compileCode (src) {
src = 'with (sandbox) {' + src + '}'
const code = new Function('sandbox', src)

return function (sandbox) {
if (!sandboxProxies.has(sandbox)) {
const sandboxProxy = new Proxy(sandbox, {has, get})
sandboxProxies.set(sandbox, sandboxProxy)
}
return code(sandboxProxies.get(sandbox))
}
}

function has (target, key) {
return true
}

function get (target, key) {
if (key === Symbol.unscopables) return undefined
return target[key]
}

De esta manera solo se creará un Proxy por objeto sandbox.

El compileCode() en ejemplo anterior es un evaluador de código de espacio de trabajo que funciona en solo 19 líneas de código.

Además de explicar la evaluación del código, el objetivo de este capítulo fue mostrar cómo se pueden usar las nuevas características de ES6 para alterar las existentes, en lugar de reinventarlas.