overlookmotel / livepack

Serialize live running code to Javascript
MIT License
40 stars 1 forks source link

Execute `eval()` in global scope with external vars injected #278

Open overlookmotel opened 2 years ago

overlookmotel commented 2 years ago

There are currently various problems with use of eval():

Could solve all these problems by running all eval() code in global scope and explicitly passing in variables the eval expression can access.

Input:

const x = 1;
let y = 2;
export default z => eval(z);

Current output (even with mangle option enabled):

export default (0, eval)("\"use strict\";(x, y) => z => eval(z);")(1, 2);

Could instead output:

function _eval(code, localEval, isStrict, ...varMappings) {
  // Get unused var name
  let usedPostfix = 0;
  for (const [, aliasName] of varMappings) {
    const match = aliasName.match(/^_(\d+)$/);
    if (match) {
      const postfix = match[1] * 1;
      if (postfix > usedPostfix) usedPostfix = postfix;
    }
  }
  const safeVarName = `_${usedPostfix + 1}`;

  // Create object to use in `with() {}` to allow access to external vars
  const withObj = Object.create(null);
  let thisValue;
  for (const [varName, aliasName, isConst] of varMappings) {
    const get = () => localEval(aliasName);
    if (varName === 'this') {
      thisValue = get();
    } else {
      const set = isConst
        ? _constViolation
        : localEval(`${safeVarName} => ${aliasName} = ${safeVarName}`)
      Object.defineProperty(withObj, varName, {get, set});
    }
  }

  // Execute code in global scope, with access to local vars via `with () {}` object getters/setters
  if (isStrict) code = `'"use strict";'+${code}`;
  const firstVarName = varMappings[0][0];
  return new Function(
    firstVarName,
    `with (${firstVarName}) { return eval(${code}) }`
  ).call(thisValue, withObj);
}

function _constViolation() {
  const c = 0;
  c = 1;
}

export default (
  (a, b) => c => _eval( 'z', d => eval(d), true, ['x', 'a', true], ['y', 'b'] , ['z', 'c'] )
)( 1, 2 );

Note that:

  1. Vars in scope functions can be renamed.
  2. Const violations in the eval() code throw errors as they should.
  3. Use of this is properly supported.
  4. The variable holding the with object is named same as one of the vars that the with object provides, so it remains inaccessible.

This output is much more verbose, but more correct, and the _eval() helper would only need to be included in output once even if multiple eval()s in the application.

overlookmotel commented 2 years ago

Above implemented as a runtime helper in eval-helper branch.

There is one problem with this approach:

The function created by new Function(...) is sloppy mode. This is required for usage of with() {}. However, sloppy mode functions treat this in an annoying way - calling a sloppy mode function with f.call(undefined) or f.call(null) results in this inside the function being the global object. If called with f.call(123) (or any other primitive value), the value of this inside the function is boxed (i.e. Number(123)).

This can be fixed by changing the above to:

return new Function(
  firstVarName,
  `with (${firstVarName}) {
    return function() {
      "use strict";
      return eval(${code})
    };
  }`
 )(withObj).call(thisValue);

However, this prevents access to arguments provided by the with() {} object.

Again, this can be worked around by replacing .call(thisValue) with .apply(thisValue, argumentsValue). As arguments cannot be used as a var in strict mode, we know that arguments must be a genuine Arguments object, so .apply() will work.

However it's still not quite right. arguments inside eval() is not the same arguments object as outside. So arguments[0] = 123 inside eval() will not affect the external arguments var.

This problem can be solved by replacing the properties of arguments inside eval() with getters/setters which refer to the external arguments var.

It also needs further code to set any additional properties of arguments object, set its prototype, and call Object.freeze() etc if that's been applied to the external arguments object.

All of this is implemented on eval-helper branch.

However, there's still cases which won't work e.g. arguments.x = 456 (where x is a new property). What the code in eval() will do is not knowable ahead of time, so could only create getters+setters for properties that exist initially.

delete arguments[0] also wouldn't apply to the external arguments object.

It's not possible to use a Proxy as can't assign to arguments in strict mode, and this wouldn't be supported on older browsers in any case.

I don't think this is completely solvable.

Two options:

  1. Abandon this approach as it's impossible to make it work 100%.
  2. Accept the shortcomings in these edge cases in favour of the advantages listed above.

NB Most of the problems listed at the top are solvable by other means, but it's a pain.

overlookmotel commented 2 years ago

I also considered using with () {} to prevent access to external vars and re-route accesses to the global object as if the var didn't exist. i.e.:

const _hiddenVar = 1;

const withObj = Object.create(null);
Object.defineProperty( withObj, '_hiddenVar', {
  get() {
    if (!(_hiddenVar in global)) throw new ReferenceError('_hiddenVar is not defined');
    return global._hiddenVar;
  },
  set(v) {
    if (!(_hiddenVar in global)) throw new ReferenceError('_hiddenVar is not defined');
    global._hiddenVar = v;
  }
} );

with (withObj) {
  eval('_hiddenVar');
}

Unfortunately, this doesn't work due to differences in behavior with non-existent globals between strict and sloppy mode.

In strict mode, assigning to a non-existent global throws an error (as above). But in sloppy mode assignment succeeds.

Which is the correct behavior depends on whether it's strict or sloppy mode where the assignment is made. As this is happening inside eval(), and the code being evaluated is unknown until runtime, it's impossible to determine whether an error should be thrown or not without parsing and examining the code being eval()-ed at runtime.

Obviously shipping a parser in compiled code to deal with these cases is impractical.

overlookmotel commented 2 years ago

Going to have exactly the same problem with arguments if output is compiled to ES5 (no arrow functions).

Babel converts this:

function f() {
  return (x, y) => [ this, arguments, x, y, eval('[this, arguments, x, y]') ];
}

to:

function f() {
  var _arguments = arguments, _this = this;
  return function (x, y) {
    return [_this, _arguments, x, y, eval("[this, arguments, x, y]")];
  };
}

We can do a bit better with:

function f() {
  return Object.defineProperties(
    Function.prototype.bind.apply(
      function () {
        var y = arguments[--arguments.length];
        delete arguments[arguments.length];
        var x = arguments[--arguments.length];
        delete arguments[arguments.length];
        return [ this, arguments, x, y, eval("[this, arguments, x, y]") ];
      },
      [this].concat( Array.prototype.slice.call(arguments) )
    ),
    { length: {value: 2}, name: {value: ''} }
  );
}

But still we have the same problem with arguments inside and outside the inner function not being same object, and therefore changes to one not being reflected in the other.

Could add more code to pull the same tricks as the eval shim (creating getters/setters on arguments inside inner function) but it's never going to do better than the eval shim, and will have the further disadvantage of exposing temp vars inside eval().

So this problem is not entirely solvable in ES5 either.

overlookmotel commented 2 years ago

Another problem: How to deal with super in eval? e.g.:

const obj = {
  foo() {
    return () => eval('super.foo()');
  }
};
Object.setPrototypeOf( obj, {
  foo() { return 1; }
} );
export default obj.foo();

eval() wrapper would need to populate value of super.

Could replace new Function(...) in implementation above with something like:

const objWrapper = (0, eval)(`
  ({
    _(${firstVarName}) {
      with (${firstVarName}) {
        return eval(${code})
      }
    }
  })
`);
Object.setPrototypeOf( objWrapper, Object.getPrototypeOf( superValue ) );
return objWrapper._.call(thisValue, withObj);

However, this assumes the prototype of original obj is not changed within the eval(). e.g. this input would defeat it:

const obj = {
  foo() {
    return () => eval(`
      Object.setPrototypeOf( obj, { foo: () => 2 } );
      super.foo()
    `);
  }
};
Object.setPrototypeOf( obj, {
  foo() { return 1; }
} );
export default obj.foo();

Only ways to capture the change of prototype would be:

  1. Pass in a Proxy to obj with a trap to catch a change in prototype.
  2. Shim Object.setPrototypeOf() and Object.prototype.__proto__ setter to catch change in prototype.

Option (1) is not workable in browsers which don't support Proxy, so out of the question (unless all browsers which support super also support Proxy?)

Option (2) is pretty nasty.

Would need to apply the shim globally to catch obj.__proto__ = ....

Shim would look something like:

const watchCallbacks = [];
function protoBeingSet(obj, proto) {
  watchCallbacks.forEach( cb => cb(obj, proto) );
}

const protoPropDescriptor = Object.getOwnPropertyDescriptor(Object.prototype, '__proto__'),
  protoPropSetter = protoPropDescriptor.set;
Object.defineProperty(Object.prototype, '__proto__', {
  ...protoPropDescriptor,
  set(proto) {
    protoBeingSet(this, proto);
    return protoPropSetter.apply(this, arguments);
  }
});

const {setPrototypeOf} = Object;
Object.setPrototypeOf = function(obj, proto) {
  protoBeingSet(obj, proto);
  return setPrototypeOf.apply(this, arguments);
};

Eval shim would add a callback to watchCallbacks:

watchCallbacks.push( (alteredObject, newProto) => {
  if (alteredObject === superValue) {
    Object.setPrototypeOf(objWrapper, newProto);
  }
} );

Problem is that need to remove watch callback from the array once eval() is done to avoid a memory leak. eval()-ed code may contain async code (e.g. setTimeout()) so can't assume watcher can be removed immediately after executing eval().

Could use a WeakMap keyed by the tracked object instead of an array to partly solve this, but only if WeakMap is available in all browsers which support super(). I don't know if this is the case. And it doesn't fully solve it anyway, since tracked object may still be referenced elsewhere and so the watcher function would be retained even after it's no longer needed.

A lot of problems!

NB The machinery to handle this could be omitted if eval() is never used in an a method where super is available (i.e. object method or class method where that class has a super class). So would only bloat output code where it's possible it would be used.

Or could opt just not to support this edge case.

overlookmotel commented 2 years ago

Actually, maybe this would do the trick:

const objWrapper = (0, eval)(`
  ({
    _(${firstVarName}) {
      with (${firstVarName}) {
        return eval(${code})
      }
    }
  })
`);
Object.setPrototypeOf( objWrapper, superValue );
return objWrapper._.call(thisValue, withObj);

i.e. Set prototype of the wrapper object to the super object, instead of super object's prototype.

Don't need to worry about delete super.foo in the eval()-ed code as that's not valid JS.

Not sure if assignment to super would work (super.foo = ...) as super is referring to the wrong object. I'm not actually clear on what super assignments do.

overlookmotel commented 2 years ago

NB These problems with super are difficult to solve even if not using the eval shim (not impossible, but very convoluted).