diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.js index 385479e9937..91bf06900c0 100644 --- a/packages/bruno-app/src/utils/codemirror/javascript-lint.js +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.js @@ -14,6 +14,8 @@ if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); const { filter } = require('lodash'); + const TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN = /\bawait(?:\s+|\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)+import\s*\(/; + function validator(text, options) { if (!window.JSHINT) { if (window.console) { @@ -61,11 +63,26 @@ if (!SERVER_RENDERED) { * codemirror error: "Missing semicolon." * - W024: 'await' used as identifier (JSHint doesn't recognize top-level await syntax) * codemirror error: "Expected an identifier and instead saw 'await' (a reserved word)." + * - E033: 'import' in dynamic import after top-level await + * codemirror error: "Expected an operator and instead saw 'import'." * * Once JSHINT top level await support is added, this file can be removed * and we can use the default javascript-lint addon from codemirror */ errors = filter(errors, (error) => { + if ( + error.code === 'E033' + ) { + if ( + error.a === 'import' + && error.evidence + && TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN.test(error.evidence) + && error.scope === '(main)' + ) { + return false; + } + } + if (error.code === 'E058' || error.code === 'W024') { if ( error.evidence diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js new file mode 100644 index 00000000000..7a59e3bb613 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js @@ -0,0 +1,52 @@ +const { describe, it, expect, beforeEach, jest } = require('@jest/globals'); +const { JSHINT } = require('jshint'); + +jest.mock('codemirror', () => { + const codemirror = require('test-utils/mocks/codemirror'); + return codemirror; +}); + +describe('javascript lint', () => { + let CodeMirror; + let lintJavascript; + + beforeEach(() => { + jest.resetModules(); + window.JSHINT = JSHINT; + + require('./javascript-lint'); + CodeMirror = require('codemirror'); + CodeMirror.Pos = (line, ch) => ({ line, ch }); + lintJavascript = CodeMirror.lint.javascript; + }); + + it('does not report top-level await dynamic import errors', () => { + const result = lintJavascript('const helper = await import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + + it('does not report top-level await dynamic import errors split across lines', () => { + const result = lintJavascript('const helper = await\n import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + + it('does not report top-level await dynamic import errors with a comment between await and import', () => { + const result = lintJavascript('const helper = await /* load esm */ import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + + it('continues to report unrelated syntax errors', () => { + const result = lintJavascript('const value = ;'); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + severity: 'error' + }) + ]) + ); + }); +}); diff --git a/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js index 04405648059..a39c1729e62 100644 --- a/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js +++ b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js @@ -3,61 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const nodeModule = require('node:module'); -const { isBuiltinModule, isPathWithinAllowedRoots } = require('./utils'); - -/** - * Resolve a local module path, handling files and directories - * Follows Node.js resolution algorithm: - * 1. Exact path (with extension) - * 2. Path + .js extension - * 3. Directory with package.json (main field) - * 4. Directory with index.js - * @param {string} fromDir - Directory to resolve from - * @param {string} moduleName - Module name/path - * @returns {string} Resolved absolute path - */ -function resolveLocalModulePath(fromDir, moduleName) { - const basePath = path.resolve(fromDir, moduleName); - - // 1. If has extension, use as-is - if (path.extname(moduleName)) { - return path.normalize(basePath); - } - - // 2. Try with .js extension - const withJs = basePath + '.js'; - if (fs.existsSync(withJs)) { - return path.normalize(withJs); - } - - // 3. Check if it's a directory - if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { - // 3a. Check for package.json with main field - const pkgPath = path.join(basePath, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg.main) { - const mainPath = path.resolve(basePath, pkg.main); - if (fs.existsSync(mainPath)) { - return path.normalize(mainPath); - } - } - } catch { - // Ignore JSON parse errors, fall through to index.js - } - } - - // 3b. Check for index.js - const indexPath = path.join(basePath, 'index.js'); - if (fs.existsSync(indexPath)) { - return path.normalize(indexPath); - } - } - - // 4. Fall back to original path (will likely fail with file not found) - return path.normalize(basePath); -} +const { isBuiltinModule, isPathWithinAllowedRoots, resolveLocalModulePath } = require('./utils'); /** * Creates a custom require function with enhanced security and local module support diff --git a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js new file mode 100644 index 00000000000..b0b416f2f23 --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js @@ -0,0 +1,506 @@ +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const nodeModule = require('node:module'); +const { pathToFileURL } = require('node:url'); + +const { isBuiltinModule, isPathWithinAllowedRoots, resolveLocalModulePath } = require('./utils'); +const { createCustomRequire } = require('./cjs-loader'); + +// ESM modules share the same backing cache map as CJS modules. Prefix keys so a +// CJS require cache entry and an ESM import cache entry for the same file do not collide. +const ESM_CACHE_PREFIX = 'esm:'; + +/** + * Creates the dynamic import callback used by vm.Script and vm.SourceTextModule. + * The callback resolves local files, Node builtins, and npm dependencies through + * Bruno's allowed-root checks before returning a vm.Module instance. + * @param {Object} options - Configuration options. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {string} [options.currentModuleDir=collectionPath] - Directory used to resolve relative imports. + * @param {Map} [options.localModuleCache] - Shared CJS/ESM module cache. + * @param {string[]} [options.additionalContextRootsAbsolute] - Absolute roots allowed for local module access. + * @returns {(moduleName: string, referrer?: vm.Module) => Promise} Custom dynamic import callback. + */ +function createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir = collectionPath, + localModuleCache = new Map(), + additionalContextRootsAbsolute = [] +}) { + return async (moduleName, referrer) => { + const normalizedModuleName = moduleName.replace(/\\/g, '/'); + const referrerDir = getReferrerDir(referrer, currentModuleDir); + + // 1. Handle local modules (./path, ../path) + if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + currentModuleDir: referrerDir, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + // 2. Handle absolute paths - route through local module security checks + // This prevents bypassing additionalContextRoots by using absolute paths. + if (path.isAbsolute(normalizedModuleName)) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + currentModuleDir: referrerDir, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + // 3. Handle Node.js builtin modules + // Note: Builtins are loaded via native require, bypassing VM isolation. + // This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins. + if (isBuiltinModule(moduleName)) { + return loadBuiltinModule({ + moduleName, + isolatedContext, + localModuleCache + }); + } + + // 4. Handle npm modules - resolve collection dependencies before Bruno's bundled dependencies. + return loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); + }; +} + +/** + * Gets the directory imports should resolve from for a referrer module. + * @param {vm.Module|undefined} referrer - Module requesting the import. + * @param {string} fallbackDir - Directory used when the referrer has no absolute identifier. + * @returns {string} Directory for resolving relative import specifiers. + */ +function getReferrerDir(referrer, fallbackDir) { + if (referrer?.identifier && path.isAbsolute(referrer.identifier)) { + return path.dirname(referrer.identifier); + } + + return fallbackDir; +} + +/** + * Loads a local ESM, CJS, or JSON module from the filesystem. + * The module specifier is checked before and after path resolution so extension + * inference or package main resolution cannot escape the allowed roots. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Local or absolute module specifier. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {string} options.currentModuleDir - Directory used to resolve relative paths. + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} [options.additionalContextRootsAbsolute] - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the module is outside allowed roots or cannot be found. + */ +async function loadLocalModule({ + moduleName, + collectionPath, + isolatedContext, + currentModuleDir, + localModuleCache, + additionalContextRootsAbsolute = [] +}) { + // Validate the raw module name doesn't try to escape allowed roots + const preliminaryPath = path.resolve(currentModuleDir, moduleName); + if (!isPathWithinAllowedRoots(path.normalize(preliminaryPath), additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + // Resolve the module path, handling files and directories + const resolvedPath = resolveLocalModulePath(currentModuleDir, moduleName); + + // Final security check after resolution + if (!isPathWithinAllowedRoots(resolvedPath, additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Cannot find module ${moduleName}`); + } + + return executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); +} + +/** + * Loads a Node.js builtin module and exposes it as a synthetic ESM namespace. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Builtin module name, with or without the node: prefix. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @returns {Promise} Synthetic VM module for the builtin exports. + */ +async function loadBuiltinModule({ + moduleName, + isolatedContext, + localModuleCache +}) { + const normalizedName = moduleName.replace(/^node:/, ''); + + // Dynamic import must resolve to a vm.Module. Builtins are native CommonJS-ish + // values here, so expose them through a synthetic ESM namespace. + return createSyntheticModuleFromExports({ + identifier: `node:${normalizedName}`, + exportsValue: require(moduleName), + isolatedContext, + localModuleCache + }); +} + +/** + * Resolves and loads an npm dependency into the VM context. + * Collection-local dependencies are preferred over Bruno's own dependencies. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Package specifier to resolve. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the dependency cannot be resolved or loaded. + */ +async function loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + let resolvedPath; + + // Module resolution order: + // 1. Collection's node_modules (user-installed packages for their collection) + // 2. Bruno's node_modules (fallback for built-in dependencies) + // + // This order ensures user packages take precedence, allowing users to: + // - Override Bruno's bundled package versions + // - Install collection-specific dependencies + if (collectionPath) { + try { + const collectionRequire = nodeModule.createRequire(path.join(collectionPath, 'package.json')); + resolvedPath = collectionRequire.resolve(moduleName); + } catch { + // Module not found in collection, continue to fallback. + } + } + + // Fall back to Bruno's node_modules + if (!resolvedPath) { + try { + resolvedPath = require.resolve(moduleName, { paths: module.paths }); + } catch (mainError) { + throw new Error( + `Could not resolve module "${moduleName}": ${mainError.message}\n\n` + + `Install it with: npm install ${moduleName}` + ); + } + } + + return executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); +} + +/** + * Executes or wraps a resolved module path and returns a VM module instance. + * ESM files are loaded as SourceTextModule instances; CJS and JSON values are + * bridged into synthetic ESM modules. + * @param {Object} options - Configuration options. + * @param {string} options.resolvedPath - Absolute path resolved for the module. + * @param {string} options.moduleName - Original module specifier. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the module cannot be loaded. + */ +async function executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + // JSON files are exposed as default-only ESM modules, matching Node's import + // shape closely enough for Bruno scripts without parsing import attributes. + if (resolvedPath.endsWith('.json')) { + const jsonValue = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + return createSyntheticModuleFromExports({ + identifier: resolvedPath, + exportsValue: jsonValue, + isolatedContext, + localModuleCache, + defaultOnly: true + }); + } + + if (shouldLoadAsEsm(resolvedPath)) { + return loadSourceTextModule({ + resolvedPath, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + const moduleRequire = createCustomRequire({ + collectionPath, + isolatedContext, + currentModuleDir: path.dirname(resolvedPath), + localModuleCache, + additionalContextRootsAbsolute + }); + const exportsValue = moduleRequire(resolvedPath); + + // CJS dependencies can still be imported from ESM scripts. Execute them through + // Bruno's CJS loader, then bridge their exports into a synthetic ESM namespace. + return createSyntheticModuleFromExports({ + identifier: resolvedPath, + exportsValue, + isolatedContext, + localModuleCache + }); +} + +/** + * Creates, links, evaluates, and caches a real ESM source module. + * Static imports are delegated back through the custom import resolver so nested + * dependencies keep the same security and collection-resolution behavior. + * @param {Object} options - Configuration options. + * @param {string} options.resolvedPath - Absolute path to the ESM file. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} Linked and evaluated source text module. + */ +async function loadSourceTextModule({ + resolvedPath, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + const cacheKey = getEsmCacheKey(resolvedPath); + if (localModuleCache.has(cacheKey)) { + const cachedModule = localModuleCache.get(cacheKey); + await evaluateModuleIfNeeded(cachedModule); + return cachedModule; + } + + const moduleSource = fs.readFileSync(resolvedPath, 'utf8'); + const moduleDir = path.dirname(resolvedPath); + + // Real ESM files are evaluated as SourceTextModule instances inside the same + // isolated context as the parent Bruno script. + const sourceModule = new vm.SourceTextModule(moduleSource, { + context: isolatedContext, + identifier: resolvedPath, + initializeImportMeta(meta) { + meta.url = pathToFileURL(resolvedPath).href; + }, + importModuleDynamically: createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: moduleDir, + localModuleCache, + additionalContextRootsAbsolute + }) + }); + + localModuleCache.set(cacheKey, sourceModule); + + // Static imports inside imported ESM modules must go through the same Bruno + // resolver so allowed roots and collection-first package resolution are preserved. + await sourceModule.link((nestedModuleName, referencingModule) => { + return createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: path.dirname(referencingModule.identifier), + localModuleCache, + additionalContextRootsAbsolute + })(nestedModuleName, referencingModule); + }); + await evaluateModuleIfNeeded(sourceModule); + + return sourceModule; +} + +/** + * Wraps a plain JavaScript value in a SyntheticModule so dynamic import can + * return CJS, JSON, and builtin modules through an ESM-compatible namespace. + * @param {Object} options - Configuration options. + * @param {string} options.identifier - Stable module identifier used as the ESM cache key. + * @param {*} options.exportsValue - Value to expose through synthetic exports. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {boolean} [options.defaultOnly=false] - Whether to expose only the default export. + * @returns {Promise} Linked and evaluated synthetic module. + */ +async function createSyntheticModuleFromExports({ + identifier, + exportsValue, + isolatedContext, + localModuleCache, + defaultOnly = false +}) { + const cacheKey = getEsmCacheKey(identifier); + if (localModuleCache.has(cacheKey)) { + const cachedModule = localModuleCache.get(cacheKey); + await evaluateModuleIfNeeded(cachedModule); + return cachedModule; + } + + const exportNames = getSyntheticExportNames(exportsValue, defaultOnly); + // SyntheticModule lets non-ESM values satisfy the vm importModuleDynamically + // contract, which requires a vm.Module rather than a plain object. + const syntheticModule = new vm.SyntheticModule(exportNames, function () { + this.setExport('default', exportsValue); + + if (!defaultOnly && exportsValue && (typeof exportsValue === 'object' || typeof exportsValue === 'function')) { + Object.keys(exportsValue).forEach((key) => { + if (key === 'default') { + return; + } + this.setExport(key, exportsValue[key]); + }); + } + }, { + context: isolatedContext, + identifier + }); + + localModuleCache.set(cacheKey, syntheticModule); + + await syntheticModule.link(() => {}); + await evaluateModuleIfNeeded(syntheticModule); + + return syntheticModule; +} + +/** + * Determines whether a resolved path should be evaluated as ESM. + * @param {string} resolvedPath - Absolute resolved module path. + * @returns {boolean} True for .mjs files and .js files inside type=module packages. + */ +function shouldLoadAsEsm(resolvedPath) { + if (resolvedPath.endsWith('.mjs')) { + return true; + } + + if (!resolvedPath.endsWith('.js')) { + return false; + } + + const packageJsonPath = findNearestPackageJson(path.dirname(resolvedPath)); + if (!packageJsonPath) { + return false; + } + + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return pkg.type === 'module'; + } catch { + return false; + } +} + +/** + * Finds the nearest package.json by walking up from a starting directory. + * @param {string} startDir - Directory to begin searching from. + * @returns {string|null} Absolute package.json path, or null when none is found. + */ +function findNearestPackageJson(startDir) { + let currentDir = startDir; + + while (currentDir && currentDir !== path.dirname(currentDir)) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + return packageJsonPath; + } + currentDir = path.dirname(currentDir); + } + + return null; +} + +/** + * Builds the export names exposed by a SyntheticModule wrapper. + * @param {*} exportsValue - CJS, JSON, or builtin export value to wrap. + * @param {boolean} defaultOnly - Whether to expose only the default export. + * @returns {string[]} Export names for the synthetic namespace. + */ +function getSyntheticExportNames(exportsValue, defaultOnly) { + const exportNames = new Set(['default']); + + if (!defaultOnly && exportsValue && (typeof exportsValue === 'object' || typeof exportsValue === 'function')) { + Object.keys(exportsValue).forEach((key) => { + exportNames.add(key); + }); + } + + return Array.from(exportNames); +} + +/** + * Evaluates a VM module when it has not already been evaluated. + * @param {vm.Module} moduleInstance - SourceTextModule or SyntheticModule instance. + * @returns {Promise} Resolves after evaluation completes. + */ +async function evaluateModuleIfNeeded(moduleInstance) { + if (moduleInstance.status !== 'evaluated') { + await moduleInstance.evaluate(); + } +} + +/** + * Creates the ESM cache key for a module identifier. + * @param {string} identifier - Module path or synthetic identifier. + * @returns {string} Cache key namespaced for ESM entries. + */ +function getEsmCacheKey(identifier) { + return ESM_CACHE_PREFIX + identifier; +} + +module.exports = { + createCustomImport +}; diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index a140b784aa3..9ea1a6ad673 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -5,6 +5,7 @@ const lodash = require('lodash'); const { wrapConsoleWithSerializers } = require('./console'); const { ScriptError, resolveVmFilename } = require('./utils'); const { createCustomRequire } = require('./cjs-loader'); +const { createCustomImport } = require('./esm-loader'); const { safeGlobals } = require('./constants'); const { mixinTypedArrays } = require('../mixins/typed-arrays'); const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox'); @@ -66,15 +67,26 @@ async function runScriptInNodeVm({ additionalContextRootsAbsolute }); + const customImport = createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: collectionPath, + localModuleCache, + additionalContextRootsAbsolute + }); + const vmFilename = resolveVmFilename(scriptPath, collectionPath); // Execute the script in the isolated context const wrappedScript = wrapScriptInClosure(script, SANDBOX.NODEVM); let compiledScript; try { - compiledScript = new vm.Script(wrappedScript, { - filename: vmFilename - }); + const scriptOptions = { + filename: vmFilename, + importModuleDynamically: customImport + }; + + compiledScript = new vm.Script(wrappedScript, scriptOptions); } catch (error) { // V8 puts "filename:line" as the first line of syntax error stacks. // Parse it so the error formatter can map to the correct source location. diff --git a/packages/bruno-js/src/sandbox/node-vm/index.spec.js b/packages/bruno-js/src/sandbox/node-vm/index.spec.js index bf74ae19e14..2ec1ef81b1a 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.spec.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.spec.js @@ -2,6 +2,7 @@ const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals') const fs = require('fs'); const path = require('path'); const os = require('os'); +const vm = require('node:vm'); const { runScriptInNodeVm } = require('./index'); describe('node-vm sandbox', () => { @@ -777,7 +778,7 @@ describe('node-vm sandbox', () => { expect(context.bru.setVar).toHaveBeenCalledWith('result', 'cjs-100'); }); - it('should fail when loading .mjs files (ES modules)', async () => { + it('should fail when requiring .mjs files (ES modules)', async () => { const nodeModulesDir = path.join(collectionPath, 'node_modules', 'mjs-ext-module'); fs.mkdirSync(nodeModulesDir, { recursive: true }); fs.writeFileSync( @@ -800,6 +801,123 @@ describe('node-vm sandbox', () => { ).rejects.toThrow(); }); + it('should dynamically import .mjs files from node-vm scripts', async () => { + fs.writeFileSync( + path.join(collectionPath, 'esm-helper.mjs'), + 'export function makeGreeting(name) { return `hello ${name} from esm`; }' + ); + + const script = ` + const helper = await import('./esm-helper.mjs'); + bru.setVar('result', helper.makeGreeting('bruno')); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'hello bruno from esm'); + }); + + it('should resolve static imports inside dynamically imported .mjs files', async () => { + fs.writeFileSync( + path.join(collectionPath, 'message.mjs'), + 'export const message = "nested-esm";' + ); + fs.writeFileSync( + path.join(collectionPath, 'esm-helper.mjs'), + 'import { message } from "./message.mjs"; export function getMessage() { return message; }' + ); + + const script = ` + const helper = await import('./esm-helper.mjs'); + bru.setVar('result', helper.getMessage()); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'nested-esm'); + }); + + it('should dynamically import collection npm dependencies as synthetic modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'esm-cjs-dependency'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "esm-cjs-dependency", "main": "index.js"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'exports.message = "from-cjs-dependency";' + ); + + const script = ` + const dependency = await import('esm-cjs-dependency'); + bru.setVar('result', dependency.message); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-cjs-dependency'); + }); + + it('should dynamically import collection ESM npm dependencies', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'esm-package-dependency'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "esm-package-dependency", "type": "module", "main": "index.js"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'export const message = "from-esm-dependency"; export default { message };' + ); + + const script = ` + const dependency = await import('esm-package-dependency'); + bru.setVar('result', dependency.message); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-esm-dependency'); + }); + + it('should block dynamic imports outside allowed roots', async () => { + fs.writeFileSync( + path.join(testDir, 'outside.mjs'), + 'export const secret = "nope";' + ); + + const script = ` + const outside = await import('../outside.mjs'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('Access to files outside of the allowed context roots is not allowed'); + }); + it('should load module with package.json main field', async () => { const nodeModulesDir = path.join(collectionPath, 'node_modules', 'custom-main'); fs.mkdirSync(path.join(nodeModulesDir, 'lib'), { recursive: true }); diff --git a/packages/bruno-js/src/sandbox/node-vm/utils.js b/packages/bruno-js/src/sandbox/node-vm/utils.js index 7480e918217..a28f1b11c53 100644 --- a/packages/bruno-js/src/sandbox/node-vm/utils.js +++ b/packages/bruno-js/src/sandbox/node-vm/utils.js @@ -1,3 +1,4 @@ +const fs = require('node:fs'); const path = require('node:path'); const nodeModule = require('node:module'); @@ -25,6 +26,60 @@ function isPathWithinAllowedRoots(normalizedPath, additionalContextRootsAbsolute }); } +/** + * Resolve a local module path, handling files and directories + * Follows Node.js resolution algorithm: + * 1. Exact path (with extension) + * 2. Path + .js extension + * 3. Directory with package.json (main field) + * 4. Directory with index.js + * @param {string} fromDir - Directory to resolve from + * @param {string} moduleName - Module name/path + * @returns {string} Resolved absolute path + */ +function resolveLocalModulePath(fromDir, moduleName) { + const basePath = path.resolve(fromDir, moduleName); + + // 1. If has extension, use as-is + if (path.extname(moduleName)) { + return path.normalize(basePath); + } + + // 2. Try with .js extension + const withJs = basePath + '.js'; + if (fs.existsSync(withJs)) { + return path.normalize(withJs); + } + + // 3. Check if it's a directory + if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { + // 3a. Check for package.json with main field + const pkgPath = path.join(basePath, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.main) { + const mainPath = path.resolve(basePath, pkg.main); + if (fs.existsSync(mainPath)) { + return path.normalize(mainPath); + } + } + } catch { + // Ignore JSON parse errors, fall through to index.js + } + } + + // 3b. Check for index.js + const indexPath = path.join(basePath, 'index.js'); + if (fs.existsSync(indexPath)) { + return path.normalize(indexPath); + } + } + + // 4. Fall back to original path (will likely fail with file not found) + return path.normalize(basePath); +} + /** * Resolve the VM filename for the script * @param {string|null} scriptPath - Path to the source file @@ -52,6 +107,7 @@ class ScriptError extends Error { module.exports = { isBuiltinModule, isPathWithinAllowedRoots, + resolveLocalModulePath, resolveVmFilename, ScriptError };