Skip to main content
Javascript

Writing a JavaScript Framework - Sandbox Code Evaluation

In this chapter, I'll explain the different ways to evaluate code in the browser and the problems they cause. I'll also introduce a method, which is based on some new or lesser known JavaScript features.

The misused eval

The function eval () evaluates the JavaScript code represented as a string.

A common solution for code evaluation is the function eval (). The code evaluated with eval () you have access to closures and global reach, which leads to a security problem called code injection; and makes eval () be one of the most noticeable features of JavaScript.

Despite being frowned upon, eval () it is very useful in some situations. Most frames front-end Modern ones require their functionality, but don't dare to use them due to the problem we mentioned above. As a result, many workarounds for evaluating strings in a sandbox rather than global reach. The sandbox prevents code from accessing secure data. Usually it is a simple JavaScript object, which overrides the global object for the evaluated code.

The common way

The alternative to eval () most common is full redeployment: a two-step process, which consists of parsing and interpreting the passed string. First, the parser creates an abstract syntax tree, then the interpreter loops through the tree and interprets it as code inside a sandbox.

This is a widely used solution, but arguably too heavy for something so simple. Rewrite everything from scratch instead of applying patches eval () presents many opportunities for errors and requires frequent modifications to follow even the latest language updates.

An alternative way

NX tries to avoid the redeployment of native code. The evaluation is handled by a small library that uses some new or less known JavaScript features.

This section will progressively introduce these features and use them to explain the code evaluation library. nx-compile. The library has a function called compileCode (), which works as follows:

const code = compileCode ('return num1 + num2') // this logs 17 in console console.log (code ({num1: 10, num2: 7})) const globalNum = 12 const otherCode = compileCode ('return globalNum' ) // global scope access is prevented, this record is not defined in the console console.log (otherCode ({num1: 2, num2: 3}))

At the end of this article, we will implement the function compileCode () in less than 20 lines.

new Function ()

The builder Function create a new object Function. In JavaScript, each function is actually a function object.

The Function constructor is an alternative to eval (). new Function (… args, 'funcBody') evaluate the string 'funcBody' passed as code and returns a new function that executes that code. It differs from eval () in two main ways.

  • Evaluate passed code only once. Calling the returned function will execute the code without re-evaluating it.
  • You do not have access to the local closing variables, however you can still access the global scope.
function compileCode (src) {return new Function (src)}

new Function (), is a better alternative for eval () in our use case. It has superior performance and security, but global reach access must still be avoided to be viable.

The keyword 'with'

The instruction with extends the scope string of a statement.

with is a lesser known keyword in JavaScript. It allows a semi-sandblasted execution. The code inside a block with It tries to retrieve the variables from the sandbox object passed first, but if it can't find it there, it looks for the variable in the closure and in the global scope. Access to the closing scope is prevented by new Function () what we only have to worry about in the global sphere.

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

with, uses the inoperator internally. For each variable access within the block, evaluate the variable in the condition sandbox. If the condition is true, retrieve the variable from the sandbox. Otherwise, it looks for the variable in the global scope. If we use with to always evaluate the variable in the sandbox as true, We could prevent it from accessing the global scope.

ES6 proxies

The object Proxy It is used to define custom behavior for fundamental operations such as searching or mapping properties.

A ES6 Proxy wraps an object and defines catch functions, which can intercept fundamental operations on that object. Capture functions are invoked when an operation occurs. By wrapping the test area object in a trap Proxy we can override the default behavior of the inoperator.

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)}} // this trap intercepts 'in' operations on the sandbox proxy. function has (target, key) {return true}

The above code tricks the with block already the variable in at sandbox since it will always evaluate to true because the trap have you always returns true. The code inside the with block it will never try to access the global object.

Code evaluation in the sandbox: 'with' statement and proxies

Symbol.unscopables

A symbol is a unique, immutable data type and can be used as an identifier for object properties.

Symbol.unscopables, it is a well-known symbol. A well-known symbol is a built-in JavaScript Symbol, A representing the behavior of the internal language. Known symbols can be used to add or overwrite iterations or primitive conversion behavior, for example:

The well-known symbol Symbol.unscopables used to specify an object value whose own and inherited property names are excluded from environment bindings 'with'.

Symbol.unscopables defines the non-openable properties of an object. Properties that cannot be captured are never retrieved from the sandbox object in declarations with, instead they are retrieved directly from the closure or global scope. Symbol.unscopables it is a very rarely used feature.

We can solve the above problem by defining a trap get at sandbox Proxy, which intercepts the recovery of Symbol.unscopables and it always returns undefined. This will fool the block with so that he thinks that our object of sandbox it has no properties that cannot be repaired.

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 for caching

The code is now safe, but its performance can still be updated as it creates a new Proxy on each invocation of the return function. This can be avoided by caching and using the same Proxy for each function call with the same sandbox object.

A proxy belongs to a sandbox object, so we could simply add the proxy to the sandbox object as a property. However, this would expose our deployment details to the public, and would not work in the case of a frozen sandbox stationary object Object.freeze (). Use a WeakMap ands a better alternative in this case.

The object WeakMap is a collection of key / value pairs in which the keys are weakly referenced. Keys must be objects, and values can be arbitrary values.

The WeakMap can be used to attach data to an object without directly extending it with properties. We can use WeakMaps to indirectly add the cache of Proxies to objects in the 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]}

This way only one Proxy will be created per sandbox object.

The compileCode () the example above is a workspace code evaluator that works on just 19 lines of code.

In addition to explaining the code evaluation, the goal of this chapter was to show how new ES6 features can be used to alter existing ones, rather than reinvent them.