node vm support (#5518)

Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com>
This commit is contained in:
lohit
2025-09-08 06:09:25 +05:30
committed by GitHub
parent 3c656270b3
commit a6b0b6c117
8 changed files with 309 additions and 6 deletions

View File

@@ -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 (
<StyledWrapper>

View File

@@ -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'
});
/**

View File

@@ -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 (

View File

@@ -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()
})
});

View File

@@ -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
};

View File

@@ -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,

View File

@@ -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({

View File

@@ -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<Object>} 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
};