From a6b0b6c1172682e559767a550d0eea879b2240fa Mon Sep 17 00:00:00 2001 From: lohit Date: Mon, 8 Sep 2025 06:09:25 +0530 Subject: [PATCH] node vm support (#5518) Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com> --- .../src/components/Preferences/Beta/index.js | 7 +- packages/bruno-app/src/utils/beta-features.js | 3 +- .../bruno-electron/src/ipc/network/index.js | 11 +- .../bruno-electron/src/store/preferences.js | 6 +- packages/bruno-js/src/index.js | 4 +- .../bruno-js/src/runtime/script-runtime.js | 43 ++++ packages/bruno-js/src/runtime/test-runtime.js | 8 + .../bruno-js/src/sandbox/node-vm/index.js | 233 ++++++++++++++++++ 8 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 packages/bruno-js/src/sandbox/node-vm/index.js diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js index 4c74bfbd6..fe88fbe98 100644 --- a/packages/bruno-app/src/components/Preferences/Beta/index.js +++ b/packages/bruno-app/src/components/Preferences/Beta/index.js @@ -14,6 +14,11 @@ const BETA_FEATURES = [ id: 'grpc', label: 'gRPC Support', description: 'Enable gRPC request support for making gRPC calls to services' + }, + { + id: 'nodevm', + label: 'Node VM Runtime', + description: 'Enable Node VM runtime for JavaScript execution in Developer Mode' } ]; @@ -68,7 +73,7 @@ const Beta = ({ close }) => { .catch((err) => console.log(err) && toast.error('Failed to update beta preferences')); }; - const hasAnyBetaFeatures = Object.values(formik.values).length > 0; + const hasAnyBetaFeatures = BETA_FEATURES.length > 0; return ( diff --git a/packages/bruno-app/src/utils/beta-features.js b/packages/bruno-app/src/utils/beta-features.js index 40c7635d6..624a147dc 100644 --- a/packages/bruno-app/src/utils/beta-features.js +++ b/packages/bruno-app/src/utils/beta-features.js @@ -5,7 +5,8 @@ import { useSelector } from 'react-redux'; * Contains all available beta feature keys */ export const BETA_FEATURES = Object.freeze({ - GRPC: 'grpc' + GRPC: 'grpc', + NODE_VM: 'nodevm' }); /** diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 5ae010bc2..fe67fb76f 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -52,7 +52,16 @@ const saveCookies = (url, headers) => { const getJsSandboxRuntime = (collection) => { const securityConfig = get(collection, 'securityConfig', {}); - return securityConfig.jsSandboxMode === 'safe' ? 'quickjs' : 'vm2'; + + if (securityConfig.jsSandboxMode === 'safe') { + return 'quickjs'; + } + + if (preferencesUtil.isBetaFeatureEnabled('nodevm')) { + return 'nodevm'; + } + + return 'vm2'; }; const configureRequest = async ( diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index e711e5309..bf2fde374 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -42,7 +42,8 @@ const defaultPreferences = { responsePaneOrientation: 'horizontal' }, beta: { - grpc: false + grpc: false, + nodevm: false } }; @@ -80,7 +81,8 @@ const preferencesSchema = Yup.object().shape({ responsePaneOrientation: Yup.string().oneOf(['horizontal', 'vertical']) }), beta: Yup.object({ - grpc: Yup.boolean() + grpc: Yup.boolean(), + nodevm: Yup.boolean() }) }); diff --git a/packages/bruno-js/src/index.js b/packages/bruno-js/src/index.js index fe6447cfb..06c3a6504 100644 --- a/packages/bruno-js/src/index.js +++ b/packages/bruno-js/src/index.js @@ -2,10 +2,12 @@ const ScriptRuntime = require('./runtime/script-runtime'); const TestRuntime = require('./runtime/test-runtime'); const VarsRuntime = require('./runtime/vars-runtime'); const AssertRuntime = require('./runtime/assert-runtime'); +const { runScriptInNodeVm } = require('./sandbox/node-vm'); module.exports = { ScriptRuntime, TestRuntime, VarsRuntime, - AssertRuntime + AssertRuntime, + runScriptInNodeVm }; diff --git a/packages/bruno-js/src/runtime/script-runtime.js b/packages/bruno-js/src/runtime/script-runtime.js index 7a9b1a5bc..145a08b96 100644 --- a/packages/bruno-js/src/runtime/script-runtime.js +++ b/packages/bruno-js/src/runtime/script-runtime.js @@ -14,6 +14,7 @@ const BrunoRequest = require('../bruno-request'); const BrunoResponse = require('../bruno-response'); const { cleanJson } = require('../utils'); const { createBruTestResultMethods } = require('../utils/results'); +const { runScriptInNodeVm } = require('../sandbox/node-vm'); // Inbuilt Library Support const ajv = require('ajv'); @@ -111,6 +112,27 @@ class ScriptRuntime { context.bru.runRequest = runRequestByItemPathname; } + if (this.runtime === 'nodevm') { + const result = await runScriptInNodeVm({ + script, + context, + collectionPath, + scriptingConfig + }); + + return { + request, + envVariables: cleanJson(result.envVariables), + runtimeVariables: cleanJson(result.runtimeVariables), + persistentEnvVariables: bru.persistentEnvVariables, + globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), + results: cleanJson(result.results), + nextRequestName: result.nextRequestName, + skipRequest: bru.skipRequest, + stopExecution: bru.stopExecution + }; + } + if (this.runtime === 'quickjs') { await executeQuickJsVmAsync({ script: script, @@ -260,6 +282,27 @@ class ScriptRuntime { context.bru.runRequest = runRequestByItemPathname; } + if (this.runtime === 'nodevm') { + const result = await runScriptInNodeVm({ + script, + context, + collectionPath, + scriptingConfig + }); + + return { + response, + envVariables: cleanJson(result.envVariables), + persistentEnvVariables: cleanJson(bru.persistentEnvVariables), + runtimeVariables: cleanJson(result.runtimeVariables), + globalEnvironmentVariables: cleanJson(globalEnvironmentVariables), + results: cleanJson(result.results), + nextRequestName: result.nextRequestName, + skipRequest: bru.skipRequest, + stopExecution: bru.stopExecution + }; + } + if (this.runtime === 'quickjs') { await executeQuickJsVmAsync({ script: script, diff --git a/packages/bruno-js/src/runtime/test-runtime.js b/packages/bruno-js/src/runtime/test-runtime.js index ae4e2c963..de8e37ba1 100644 --- a/packages/bruno-js/src/runtime/test-runtime.js +++ b/packages/bruno-js/src/runtime/test-runtime.js @@ -1,4 +1,5 @@ const { NodeVM } = require('@usebruno/vm2'); +const { runScriptInNodeVm } = require('../sandbox/node-vm'); const chai = require('chai'); const path = require('path'); const http = require('http'); @@ -132,6 +133,13 @@ class TestRuntime { script: testsFile, context: context }); + } else if (this.runtime === 'nodevm') { + await runScriptInNodeVm({ + script: testsFile, + context, + collectionPath, + scriptingConfig + }); } else { // default runtime is vm2 const vm = new NodeVM({ diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js new file mode 100644 index 000000000..5f5b488fe --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -0,0 +1,233 @@ +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const { get } = require('lodash'); +const lodash = require('lodash'); +const { cleanJson } = require('../../utils'); + +class ScriptError extends Error { + constructor(error, script) { + super(error.message); + this.name = 'ScriptError'; + this.originalError = error; + this.script = script; + this.stack = error.stack; + } +} + +/** + * Executes a script in a Node.js VM context with enhanced security and module loading + * @param {Object} options - Configuration options + * @param {string} options.script - The script code to execute + * @param {Object} options.context - The execution context with Bruno objects + * @param {string} options.collectionPath - Path to the collection directory + * @param {Object} options.scriptingConfig - Scripting configuration options + * @returns {Promise} Execution results including variables and test results + * @throws {ScriptError} When script execution fails + */ +async function runScriptInNodeVm({ + script, + context, + collectionPath, + scriptingConfig +}) { + if (script.trim().length === 0) { + return { + envVariables: cleanJson(context.bru.envVariables), + collectionVariables: cleanJson(context.bru.collectionVariables), + nextRequestName: context.bru.nextRequest, + results: context.__brunoTestResults ? cleanJson(context.__brunoTestResults.getResults()) : null + }; + } + + try { + // Create script context with all necessary variables + const scriptContext = { + // Bruno context + console: context.console, + req: context.req, + res: context.res, + bru: context.bru, + expect: context.expect, + assert: context.assert, + __brunoTestResults: context.__brunoTestResults, + test: context.test, + // Configuration for nested module loading + scriptingConfig: scriptingConfig, + // Global objects + Buffer: global.Buffer, + process: global.process, + setTimeout: global.setTimeout, + setInterval: global.setInterval, + clearTimeout: global.clearTimeout, + clearInterval: global.clearInterval, + setImmediate: global.setImmediate, + clearImmediate: global.clearImmediate + }; + + // Create shared cache for local modules + const localModuleCache = new Map(); + + // Create a custom require function and add it to the context + scriptContext.require = createCustomRequire({ + scriptingConfig, + collectionPath, + scriptContext, + currentModuleDir: collectionPath, + localModuleCache + }); + + // Execute the script in an isolated VM context + await vm.runInNewContext(` + (async function(){ + ${script} + })(); + `, scriptContext, { + filename: path.join(collectionPath, 'script.js'), + displayErrors: true + }); + } catch (error) { + throw new ScriptError(error, script); + } + + return { + envVariables: cleanJson(context.bru.envVariables), + collectionVariables: cleanJson(context.bru.collectionVariables), + nextRequestName: context.bru.nextRequest, + results: context.__brunoTestResults ? cleanJson(context.__brunoTestResults.getResults()) : null + }; +} + + +/** + * Creates a custom require function with enhanced security and local module support + * @param {Object} options - Configuration options + * @param {Object} options.scriptingConfig - Scripting configuration with additional context roots + * @param {string} options.collectionPath - Base collection path for security checks + * @param {Object} options.scriptContext - Script execution context + * @param {string} options.currentModuleDir - Current module directory for relative imports + * @param {Map} options.localModuleCache - Cache for loaded local modules + * @returns {Function} Custom require function + */ +function createCustomRequire({ + scriptingConfig, + collectionPath, + scriptContext, + currentModuleDir = collectionPath, + localModuleCache = new Map() +}) { + const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []); + const additionalContextRootsAbsolute = lodash + .chain(additionalContextRoots) + .map((acr) => (acr.startsWith('/') ? acr : path.join(collectionPath, acr))) + .value(); + additionalContextRootsAbsolute.push(collectionPath); + + return (moduleName) => { + // Check if it's a local module (starts with ./ or ../) + if (moduleName.startsWith('./') || moduleName.startsWith('../')) { + return loadLocalModule({ moduleName, collectionPath, scriptContext, localModuleCache, currentModuleDir }); + } + + // First try to require as a native/npm module + try { + return require(moduleName); + } catch { + // If that fails, try to resolve from additionalContextRoots + try { + const modulePath = require.resolve(moduleName, { paths: additionalContextRootsAbsolute }); + return require(modulePath); + } catch (error) { + throw new Error(`Could not resolve module "${moduleName}": ${error.message}\n\nThis most likely means you did not install the module under "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map(root => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`); + } + } + }; +} + +/** + * Loads a local module from the filesystem with security checks and caching + * @param {Object} options - Configuration options + * @param {string} options.moduleName - Name/path of the module to load + * @param {string} options.collectionPath - Base collection path for security validation + * @param {Object} options.scriptContext - Script execution context to inherit + * @param {Map} options.localModuleCache - Cache for loaded modules + * @param {string} options.currentModuleDir - Directory of the current module for relative resolution + * @returns {*} The exported content of the loaded module + * @throws {Error} When module is outside collection path or cannot be loaded + */ +function loadLocalModule({ + moduleName, + collectionPath, + scriptContext, + localModuleCache, + currentModuleDir +}) { + // Check if the filename has an extension + const hasExtension = path.extname(moduleName) !== ''; + const resolvedFilename = hasExtension ? moduleName : `${moduleName}.js`; + + // Resolve the file path relative to the current module's directory + const filePath = path.resolve(currentModuleDir, resolvedFilename); + const normalizedFilePath = path.normalize(filePath); + const normalizedCollectionPath = path.normalize(collectionPath); + + // Cross-platform security check: ensure the resolved file is within collectionPath + const relativePath = path.relative(normalizedCollectionPath, normalizedFilePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + throw new Error(`Access to files outside of the collectionPath is not allowed: ${moduleName}`); + } + + // Check cache first (use normalized path as key) + if (localModuleCache.has(normalizedFilePath)) { + return localModuleCache.get(normalizedFilePath); + } + + if (!fs.existsSync(normalizedFilePath)) { + throw new Error(`Cannot find module ${moduleName}`); + } + + // Read and execute the local module + const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8'); + + // Create module object + const moduleObj = { exports: {} }; + + // Get the directory of this module for nested imports + const moduleDir = path.dirname(normalizedFilePath); + + // Create a new context that inherits from the script context + const moduleContext = { + ...scriptContext, + module: moduleObj, + exports: moduleObj.exports, + __filename: normalizedFilePath, + __dirname: moduleDir, + // Create a custom require function for this module that resolves relative to its directory + require: createCustomRequire({ + scriptingConfig: scriptContext.scriptingConfig || {}, + collectionPath, + scriptContext, + currentModuleDir: moduleDir, + localModuleCache + }) + }; + + try { + // Execute the module code in the shared context + vm.runInNewContext(moduleCode, moduleContext, { + filename: normalizedFilePath, + displayErrors: true + }); + + // Cache the result using normalized path + localModuleCache.set(normalizedFilePath, moduleObj.exports); + + return moduleObj.exports; + } catch (error) { + throw new Error(`Error loading local module ${moduleName}: ${error.message}`); + } +} + +module.exports = { + runScriptInNodeVm +};