diff --git a/package.json b/package.json index 7f92ea115..cc8b8323a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/core/modules/coderunner/codeRunner.js b/src/lib/core/modules/coderunner/codeRunner.js index 2288c69d4..cb9e9f136 100644 --- a/src/lib/core/modules/coderunner/codeRunner.js +++ b/src/lib/core/modules/coderunner/codeRunner.js @@ -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); + }); } } diff --git a/src/lib/core/modules/coderunner/runCode.js b/src/lib/core/modules/coderunner/runCode.js deleted file mode 100644 index 03e02db92..000000000 --- a/src/lib/core/modules/coderunner/runCode.js +++ /dev/null @@ -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; diff --git a/src/lib/core/modules/coderunner/vm.ts b/src/lib/core/modules/coderunner/vm.ts new file mode 100644 index 000000000..5bdd7c7a4 --- /dev/null +++ b/src/lib/core/modules/coderunner/vm.ts @@ -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 '' + * 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} cb Callback function that is called on error or completion of evaluation. + */ + public async doEval(code: string, tolerateError = false, cb: Callback) { + 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; diff --git a/src/lib/modules/code_generator/index.js b/src/lib/modules/code_generator/index.js index 11deb66e4..59e744532 100644 --- a/src/lib/modules/code_generator/index.js +++ b/src/lib/modules/code_generator/index.js @@ -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"; diff --git a/src/lib/modules/console/index.ts b/src/lib/modules/console/index.ts index a78872ce3..0bf0798e6 100644 --- a/src/lib/modules/console/index.ts +++ b/src/lib/modules/console/index.ts @@ -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() { diff --git a/src/lib/modules/tests/test.js b/src/lib/modules/tests/test.js index 926f5c31c..2db2b86d1 100644 --- a/src/lib/modules/tests/test.js +++ b/src/lib/modules/tests/test.js @@ -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); diff --git a/src/test/vm.js b/src/test/vm.js new file mode 100644 index 000000000..3891750c6 --- /dev/null +++ b/src/test/vm.js @@ -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(); + }); + }); + }); +}); diff --git a/src/typings/callbacks.d.ts b/src/typings/callbacks.d.ts new file mode 100644 index 000000000..a7a620a85 --- /dev/null +++ b/src/typings/callbacks.d.ts @@ -0,0 +1 @@ +export type Callback = (err?: Error | null, val?: Tv) => void; diff --git a/yarn.lock b/yarn.lock index baee4c2e2..2f25366a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"