feat(@embark/core): Run all code in VM2

All code to be run in the console is run through a completely sandboxed VM2 instance, instead of the default Node VM.

VM2 will only allow whitelisted packages in a `require` statement. The whitelisted packages needed to run EmbarkJS scripts are:
```
[
  "@babel/runtime-corejs2/helpers/interopRequireDefault",
  "@babel/runtime-corejs2/core-js/json/stringify",
  "@babel/runtime-corejs2/core-js/promise",
  "@babel/runtime-corejs2/core-js/object/assign",
  "eth-ens-namehash"
]
```

This can be circumvented in an Embark context (ie Plugin) if needed, for example in a Plugin constructor:
```
Embark.events.emit('runcode:register', 'require', require('lodash'), false);

Embark.events.request("runcode:eval", "_.head(['a', 'b', 'c', 'd']);", (err, result) => {
  if(err) return console.log('========> error: ' + err);
  console.log('========> ' + result);
});
```
Will emit `========> a`.

NOTE: Attempts to use this method to override `require` and `eval` should be handled by Embark and not allowed.

NOTE: VM2 seems to allow `eval`, however it is in a completely sandboxed environment, so I'm unsure that we need to be too concerned with this. Thoughts?

Refactor tests to use standalone instance of the newly created VM class, so that code is not evaluated through the console. This was done based on the new unit test case where accounts are redefined in a subsequent unit test, which was not originally working with the initial VM2 PR.

Refactor `codeRunner`, put all code-affecting logic in the `VM` class.

Changed `runCode` to `VM` and converted to TypeScript

Add unit tests for `VM`.
This commit is contained in:
emizzle 2018-12-20 22:33:47 +11:00 committed by Eric Mastro
parent bdb6719baf
commit 9a9eb45836
10 changed files with 292 additions and 154 deletions

View File

@ -164,6 +164,7 @@
"url-loader": "1.1.2",
"uuid": "3.3.2",
"viz.js": "1.8.2",
"vm2": "3.6.4",
"web3": "1.0.0-beta.37",
"web3-bzz": "1.0.0-beta.37",
"web3-core": "1.0.0-beta.37",

View File

@ -1,7 +1,5 @@
const RunCode = require('./runCode.js');
const Utils = require('../../../utils/utils');
const WEB3_INVALID_RESPONSE_ERROR = 'Invalid JSON RPC response';
const VM = require('./vm');
const fs = require('../../fs');
class CodeRunner {
constructor(options) {
@ -11,7 +9,29 @@ class CodeRunner {
this.events = options.events;
this.ipc = options.ipc;
this.commands = [];
this.runCode = new RunCode({logger: this.logger});
this.vm = new VM({
require: {
mock: {
fs: {
access: fs.access,
diagramPath: fs.diagramPath,
dappPath: fs.dappPath,
embarkPath: fs.embarkPath,
existsSync: fs.existsSync,
ipcPath: fs.ipcPath,
pkgPath: fs.pkgPath,
readFile: fs.readFile,
readFileSync: fs.readFileSync,
readJSONSync: fs.readJSONSync,
readdir: fs.readdir,
readdirSync: fs.readdirSync,
stat: fs.stat,
statSync: fs.statSync,
tmpDir: fs.tmpDir
}
}
}
}, this.logger);
this.registerIpcEvents();
this.IpcClientListen();
this.registerEvents();
@ -24,7 +44,7 @@ class CodeRunner {
}
this.ipc.on('runcode:getCommands', (_err, callback) => {
let result = {web3Config: this.runCode.getWeb3Config(), commands: this.commands};
let result = {web3Config: this.vm.getWeb3Config(), commands: this.commands};
callback(null, result);
});
}
@ -49,7 +69,7 @@ class CodeRunner {
registerCommands() {
this.events.setCommandHandler('runcode:getContext', (cb) => {
cb(this.runCode.context);
cb(this.vm.options.sandbox);
});
this.events.setCommandHandler('runcode:eval', this.evalCode.bind(this));
}
@ -59,49 +79,26 @@ class CodeRunner {
this.commands.push({varName, code});
this.ipc.broadcast("runcode:newCommand", {varName, code});
}
this.runCode.registerVar(varName, code);
this.vm.registerVar(varName, code);
}
async evalCode(code, cb, forConsoleOnly = false, tolerateError = false) {
cb = cb || function() {};
const awaitIdx = code.indexOf('await');
let awaiting = false;
async evalCode(code, cb, isNotUserInput = false, tolerateError = false) {
cb = cb || function () {};
if (awaitIdx > -1) {
awaiting = true;
const instructions = Utils.compact(code.split(';'));
const last = instructions.pop();
if (!code) return cb(null, '');
if (!last.trim().startsWith('return')) {
instructions.push(`return ${last}`);
} else {
instructions.push(last);
this.vm.doEval(code, tolerateError, (err, result) => {
if(err) {
return cb(err);
}
code = `(async function() {${instructions.join(';')}})();`;
}
let result = this.runCode.doEval(code, tolerateError, forConsoleOnly);
if (forConsoleOnly && this.ipc.isServer()) {
this.commands.push({code});
this.ipc.broadcast("runcode:newCommand", {code});
}
if (!awaiting) {
return cb(null, result);
}
try {
const value = await result;
cb(null, value);
} catch (error) {
// Improve error message when there's no connection to node
if (error.message && error.message.indexOf(WEB3_INVALID_RESPONSE_ERROR) !== -1) {
error.message += '. Are you connected to an Ethereum node?';
if (isNotUserInput && this.ipc.isServer()) {
this.commands.push({code});
this.ipc.broadcast("runcode:newCommand", {code});
}
cb(error);
}
cb(null, result);
});
}
}

View File

@ -1,71 +0,0 @@
const vm = require('vm');
const fs = require('../../fs');
const noop = function() {};
class RunCode {
constructor({logger}) {
this.logger = logger;
const customRequire = (mod) => {
return require(customRequire.resolve(mod));
};
customRequire.resolve = (mod) => {
return require.resolve(
mod,
{paths: [fs.dappPath('node_modules'), fs.embarkPath('node_modules')]}
);
};
const newGlobal = Object.create(global);
newGlobal.fs = fs;
this.context = Object.assign({}, {
global: newGlobal, console, exports, require: customRequire, module,
__filename, __dirname, process, setTimeout, setInterval, clearTimeout,
clearInterval
});
}
doEval(code, tolerateError = false, forConsoleOnly = false) {
// Check if we want this code to run on the console or by user input. If it is by
// user input, we disallow `require` and `eval`.
let context = (forConsoleOnly) ? this.context : Object.assign({}, this.context, {
eval: noop, require: noop
});
try {
return vm.runInNewContext(code, context);
} catch(e) {
if (!tolerateError) {
this.logger.error(e.message);
}
return e.message;
}
}
registerVar(varName, code) {
// Disallow `eval` and `require`, just in case.
if(code === eval || code === require) return;
// TODO: Update all the code being dependent of web3
// To identify, look at the top of the file for something like:
// /*global web3*/
if (varName === 'web3') {
global.web3 = code;
}
this.context["global"][varName] = code;
this.context[varName] = code;
}
getWeb3Config() {
const Web3 = require('web3');
const provider = this.context.web3.currentProvider;
let providerUrl;
if(provider instanceof Web3.providers.HttpProvider){
providerUrl = provider.host;
} else if (provider instanceof Web3.providers.WebsocketProvider) {
providerUrl = provider.connection._url;
}
return {defaultAccount: this.context.web3.eth.defaultAccount, providerUrl: providerUrl};
}
}
module.exports = RunCode;

View File

@ -0,0 +1,150 @@
import { NodeVM, NodeVMOptions } from "vm2";
import { Callback } from "../../../../typings/callbacks";
import { Logger } from "../../../../typings/logger";
const fs = require("../../fs");
const { recursiveMerge } = require("../../../utils/utils");
const Utils = require("../../../utils/utils");
const WEB3_INVALID_RESPONSE_ERROR: string = "Invalid JSON RPC response";
/**
* Wraps an instance of NodeVM from VM2 (https://github.com/patriksimek/vm2) and allows
* code evaluations in the fully sandboxed NodeVM context.
*/
class VM {
/**
* The local instance of NodeVM that is wrapped
*/
private vm!: NodeVM;
/**
* These external requires are the whitelisted requires allowed during evaluation
* of code in the VM. Any other require attempts will error with "The module '<module-name>'
* is not whitelisted in VM."
* Currently, all of the allowed external requires appear in the EmbarkJS scripts. If
* the requires change in any of the EmbarkJS scripts, they will need to be updated here.
*/
private options: NodeVMOptions = {
require: {
builtin: ["path"],
external: [
"@babel/runtime-corejs2/helpers/interopRequireDefault",
"@babel/runtime-corejs2/core-js/json/stringify",
"@babel/runtime-corejs2/core-js/promise",
"@babel/runtime-corejs2/core-js/object/assign",
"eth-ens-namehash",
"swarm-api",
],
},
sandbox: { __dirname: fs.dappPath() },
};
/**
* @constructor
* @param {NodeVMOptions} options Options to instantiate the NodeVM with.
* @param {Logger} logger Logger.
*/
constructor(options: NodeVMOptions, private logger: Logger) {
this.options = recursiveMerge(this.options, options);
this.setupNodeVm();
}
/**
* Transforms a snippet of code such that the last expression is returned,
* so long as it is not an assignment.
* @param {String} code Code to run.
* @returns Formatted code.
*/
private static formatCode(code: string) {
const instructions = Utils.compact(code.split(";"));
const last = instructions.pop().trim();
const awaiting = code.indexOf("await") > -1;
if (!(last.startsWith("return") || last.indexOf("=") > -1)) {
instructions.push(`return ${last}`);
} else {
instructions.push(last);
}
code = instructions.join(";");
return `module.exports = (${awaiting ? "async" : ""} () => {${code};})()`;
}
/**
* Evaluate a snippet of code in the VM.
* @param {String} code Code to evaluate.
* @param {Boolean} tolerateError If true, errors are logged to the logger (appears in the console).
* @param {Callback<any>} cb Callback function that is called on error or completion of evaluation.
*/
public async doEval(code: string, tolerateError = false, cb: Callback<any>) {
code = VM.formatCode(code);
let result: any;
try {
result = this.vm.run(code, __filename);
} catch (e) {
if (!tolerateError) {
this.logger.error(e.message);
}
return cb(null, e.message);
}
try {
return cb(null, await result);
} catch (error) {
// Improve error message when there's no connection to node
if (error.message && error.message.indexOf(WEB3_INVALID_RESPONSE_ERROR) !== -1) {
error.message += ". Are you connected to an Ethereum node?";
}
return cb(error);
}
}
/**
* Registers a variable in the global context of the VM (called the "sandbox" in VM2 terms).
* @param {String} varName Name of the variable to register.
* @param {any} code Value of the variable to register.
*/
public registerVar(varName: string, code: any) {
// Disallow `eval` and `require`, just in case.
if (code === eval || code === require) { return; }
this.options.sandbox[varName] = code;
this.setupNodeVm();
}
/**
* Reinstantiates the NodeVM based on the @type {VMOptions} which are passed in when this
* @type {VM} class is instantiated. The "sandbox" member of the options can be modified
* by calling @function {registerVar}.
*/
private setupNodeVm() {
this.vm = new NodeVM(this.options);
}
/**
* Gets the registered @type {Web3} object, and returns an @type {object} with it's
* defaultAccount and provider URL.
* @typedef {getWeb3Config}
* @property {string} defaultAccount
* @property {string} providerUrl
* @returns {getWeb3Config} The configured values of the web3 object registered to this
* VM instance.
*/
public getWeb3Config() {
const Web3 = require("web3");
const provider = this.options.sandbox.web3.currentProvider;
let providerUrl;
if (provider instanceof Web3.providers.HttpProvider) {
providerUrl = provider.host;
} else if (provider instanceof Web3.providers.WebsocketProvider) {
providerUrl = provider.connection._url;
}
return { defaultAccount: this.options.sandbox.web3.eth.defaultAccount, providerUrl };
}
}
module.exports = VM;

View File

@ -85,6 +85,10 @@ class CodeGenerator {
self.events.setCommandHandler('code-generator:embarkjs:provider-code', (cb) => {
cb(self.getEmbarkJsProviderCode());
});
self.events.setCommandHandler('code-generator:embarkjs:init-provider-code', (cb) => {
cb(self.getInitProviderCode());
});
}
generateProvider(isDeployment) {
@ -338,6 +342,27 @@ class CodeGenerator {
), '');
}
getInitProviderCode() {
const codeTypes = {
blockchain: this.blockchainConfig || {},
communication: this.communicationConfig || {},
names: this.namesystemConfig || {},
storage: this.storageConfig || {}
};
return this.plugins.getPluginsFor("initConsoleCode").reduce((acc, plugin) => {
Object.keys(codeTypes).forEach((codeTypeName) => {
(plugin.embarkjs_init_console_code[codeTypeName] || []).forEach((initCode) => {
const [block, shouldInit] = initCode;
if (shouldInit.call(plugin, codeTypes[codeTypeName])) {
acc += block;
}
});
});
return acc;
}, "");
}
buildContractJS(contractName, contractJSON, cb) {
let contractCode = "";
contractCode += "import web3 from 'Embark/web3';\n";

View File

@ -179,36 +179,17 @@ class Console {
}
this.events.once("code-generator-ready", () => {
this.events.request("code-generator:embarkjs:provider-code", (code: string) => {
this.events.request("code-generator:embarkjs:provider-code", (providerCode: string) => {
const func = () => {};
this.events.request("runcode:eval", code, func, true);
this.events.request("runcode:eval", this.getInitProviderCode(), () => {
this.events.emit("console:provider:done");
this.providerReady = true;
}, true);
});
});
}
private getInitProviderCode() {
const codeTypes: any = {
blockchain: this.config.blockchainConfig || {},
communication: this.config.communicationConfig || {},
names: this.config.namesystemConfig || {},
storage: this.config.storageConfig || {},
};
return this.plugins.getPluginsFor("initConsoleCode").reduce((acc: any, plugin: any) => {
Object.keys(codeTypes).forEach((codeTypeName: string) => {
(plugin.embarkjs_init_console_code[codeTypeName] || []).forEach((initCode: any) => {
const [block, shouldInit] = initCode;
if (shouldInit.call(plugin, codeTypes[codeTypeName])) {
acc += block;
}
this.events.request("runcode:eval", providerCode, func, true);
this.events.request("code-generator:embarkjs:init-provider-code", (initCode: string) => {
this.events.request("runcode:eval", initCode, () => {
this.events.emit("console:provider:done");
this.providerReady = true;
}, true);
});
});
return acc;
}, "");
});
}
private registerConsoleCommands() {

View File

@ -6,6 +6,9 @@ const EmbarkJS = require('embarkjs');
const utils = require('../../utils/utils');
const constants = require('../../constants');
const web3Utils = require('web3-utils');
const VM = require('../../core/modules/coderunner/vm');
const Web3 = require('web3');
const IpfsApi = require("ipfs-api");
const BALANCE_10_ETHER_IN_HEX = '0x8AC7230489E80000';
@ -37,7 +40,7 @@ class Test {
}
if (!this.ipc.connected) {
this.logger.error("Could not connect to Embark's IPC. Is embark running?");
if(!this.options.inProcess) process.exit(1);
if (!this.options.inProcess) process.exit(1);
}
return this.connectToIpcNode(callback);
}
@ -177,7 +180,7 @@ class Test {
options = {};
}
if (!callback) {
callback = function() {
callback = function () {
};
}
if (!options.contracts) {
@ -218,20 +221,30 @@ class Test {
function changeGlobalWeb3(accounts, next) {
self.events.request('blockchain:get', (web3) => {
global.web3 = web3;
EmbarkJS.Blockchain.setProvider('web3');
next(null, accounts);
});
},
function reconfigEns(accounts, next) {
self.events.request("ens:config", (config) => {
EmbarkJS.Names.setProvider('ens', config);
next(null, accounts);
self.vm = new VM({
sandbox: {
EmbarkJS,
web3: web3,
Web3: Web3,
IpfsApi
}
});
self.events.request("code-generator:embarkjs:provider-code", (code) => {
self.vm.doEval(code, false, (err, _result) => {
if(err) return next(err);
self.events.request("code-generator:embarkjs:init-provider-code", (code) => {
self.vm.doEval(code, false, (err, _result) => {
next(err, accounts);
});
});
});
});
});
}
], (err, accounts) => {
if (err) {
// TODO Do not exit in case of not a normal run (eg after a change)
if(!self.options.inProcess) process.exit(1);
if (!self.options.inProcess) process.exit(1);
}
callback(null, accounts);
});
@ -308,7 +321,7 @@ class Test {
});
}
], function(err, accounts) {
], function (err, accounts) {
if (err) {
self.logger.error(__('terminating due to error'));
self.logger.error(err.message || err);

36
src/test/vm.js Normal file
View File

@ -0,0 +1,36 @@
/*globals describe, it*/
const TestLogger = require('../lib/utils/test_logger');
const VM = require('../lib/core/modules/coderunner/vm');
const {expect} = require('chai');
describe('embark.vm', function () {
const testObj = {
shouldReturnEmbark: 'embark',
shouldReturnEmbarkAwait: async () => {return new Promise(resolve => resolve('embark'));}
};
const vm = new VM({sandbox: {testObj}}, new TestLogger({}));
describe('#evaluateCode', function () {
it('should be able to evaluate basic code', function (done) {
vm.doEval('1 + 1', false, (err, result) => {
expect(err).to.be.null;
expect(result).to.be.equal(2);
done();
});
});
it('should be able to access the members of the sandbox', function (done) {
vm.doEval('testObj.shouldReturnEmbark', false, (err, result) => {
expect(err).to.be.null;
expect(result).to.be.equal('embark');
done();
});
});
it('should be able to evaluate async code using await', function (done) {
vm.doEval('await testObj.shouldReturnEmbarkAwait()', false, (err, result) => {
expect(err).to.be.null;
expect(result).to.be.equal('embark');
done();
});
});
});
});

1
src/typings/callbacks.d.ts vendored Normal file
View File

@ -0,0 +1 @@
export type Callback<Tv> = (err?: Error | null, val?: Tv) => void;

View File

@ -11602,6 +11602,11 @@ vm-browserify@0.0.4:
dependencies:
indexof "0.0.1"
vm2@3.6.4:
version "3.6.4"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.6.4.tgz#88b27a9f328a0630671841363692a57d24dead33"
integrity sha512-LFj8YL9DyGn+fwgG2J+10HyuIpdIRHHN8/3NwKoc2e2t2Pr0aXV/2OSODceDR7NP7VNr8RTqmxHRYcwbNvpbwg==
watchpack@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"