@usebruno/js 0.44.0 → 0.45.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Constants for the Node.js VM sandbox.
3
+ *
4
+ * ECMAScript built-ins (Object, Array, Function, etc.)
5
+ * are NOT passed from the host. The VM provides its own versions, ensuring
6
+ * consistent prototype chains for libraries that use introspection.
7
+ *
8
+ * Handled separately in index.js:
9
+ * - global/globalThis: Points to isolated context (not host)
10
+ * - require: createCustomRequire() (custom module loader)
11
+ */
12
+
13
+ /**
14
+ * Safe globals to pass from host to VM context.
15
+ *
16
+ * ECMAScript built-ins (Object, Array, Function, String, Number,
17
+ * Boolean, Symbol, Date, RegExp, Map, Set, Promise, JSON, Math,
18
+ * parseInt, etc.) are intentionally NOT included here.
19
+ *
20
+ * The VM context provides its own versions of these, which ensures consistent
21
+ * prototype chains. Passing host versions causes prototype mismatches.
22
+ *
23
+ * Only Node.js-specific and Web APIs that the VM doesn't provide are listed.
24
+ */
25
+ const safeGlobals = [
26
+ 'process',
27
+
28
+ // Node.js timers (not part of ECMAScript)
29
+ 'setTimeout',
30
+ 'setInterval',
31
+ 'clearTimeout',
32
+ 'clearInterval',
33
+ 'setImmediate',
34
+ 'clearImmediate',
35
+ 'queueMicrotask',
36
+
37
+ // Node.js globals
38
+ 'Buffer',
39
+
40
+ // Error types - needed for instanceof checks with errors from host APIs/modules
41
+ 'Error',
42
+ 'TypeError',
43
+ 'ReferenceError',
44
+ 'SyntaxError',
45
+ 'RangeError',
46
+ 'URIError',
47
+ 'EvalError',
48
+ 'AggregateError',
49
+
50
+ // URL APIs (WHATWG - not ECMAScript)
51
+ 'URL',
52
+ 'URLSearchParams',
53
+
54
+ // Encoding APIs
55
+ 'TextEncoder',
56
+ 'TextDecoder',
57
+ 'atob',
58
+ 'btoa',
59
+
60
+ // Fetch API (Node 18+)
61
+ 'fetch',
62
+ 'Request',
63
+ 'Response',
64
+ 'Headers',
65
+ 'FormData',
66
+ 'AbortController',
67
+ 'AbortSignal',
68
+ 'Blob',
69
+
70
+ // Streams API
71
+ 'ReadableStream',
72
+ 'WritableStream',
73
+ 'TransformStream',
74
+
75
+ // Internationalization (needs host's locale data)
76
+ 'Intl',
77
+
78
+ // Web Crypto API
79
+ 'crypto',
80
+
81
+ // WebAssembly
82
+ 'WebAssembly',
83
+
84
+ // Performance API
85
+ 'performance',
86
+
87
+ // Events API
88
+ 'Event',
89
+ 'EventTarget',
90
+ 'CustomEvent',
91
+
92
+ // Message passing
93
+ 'MessageChannel',
94
+ 'MessagePort'
95
+ ];
96
+
97
+ module.exports = {
98
+ safeGlobals
99
+ };
@@ -1,44 +1,30 @@
1
1
  const vm = require('node:vm');
2
- const fs = require('node:fs');
3
2
  const path = require('node:path');
4
3
  const { get } = require('lodash');
5
4
  const lodash = require('lodash');
5
+ const { ScriptError } = require('./utils');
6
+ const { createCustomRequire } = require('./cjs-loader');
7
+ const { safeGlobals } = require('./constants');
6
8
  const { mixinTypedArrays } = require('../mixins/typed-arrays');
7
9
 
8
- class ScriptError extends Error {
9
- constructor(error, script) {
10
- super(error.message);
11
- this.name = 'ScriptError';
12
- this.originalError = error;
13
- this.script = script;
14
- this.stack = error.stack;
15
- }
16
- }
17
-
18
10
  /**
19
11
  * Executes a script in a Node.js VM context with enhanced security and module loading
12
+ *
20
13
  * @param {Object} options - Configuration options
21
14
  * @param {string} options.script - The script code to execute
22
15
  * @param {Object} options.context - The execution context with Bruno objects
23
16
  * @param {string} options.collectionPath - Path to the collection directory
24
17
  * @param {Object} options.scriptingConfig - Scripting configuration options
25
- * @returns {Promise<Object>} Execution results including variables and test results
18
+ * @returns {Promise<void>}
26
19
  * @throws {ScriptError} When script execution fails
27
20
  */
28
- async function runScriptInNodeVm({
29
- script,
30
- context,
31
- collectionPath,
32
- scriptingConfig
33
- }) {
21
+ async function runScriptInNodeVm({ script, context, collectionPath, scriptingConfig }) {
34
22
  if (script.trim().length === 0) {
35
23
  return;
36
24
  }
37
25
 
38
26
  try {
39
- const allowScriptFilesystemAccess = get(scriptingConfig, 'filesystemAccess.allow', false);
40
-
41
- // Compute additional context roots
27
+ // Compute allowed context roots for security validation
42
28
  const additionalContextRoots = get(scriptingConfig, 'additionalContextRoots', []);
43
29
  const additionalContextRootsAbsolute = lodash
44
30
  .chain(additionalContextRoots)
@@ -47,229 +33,67 @@ async function runScriptInNodeVm({
47
33
  .value();
48
34
  additionalContextRootsAbsolute.push(path.normalize(collectionPath));
49
35
 
50
- // Create script context with all necessary variables
51
- const scriptContext = {
52
- // Bruno context
53
- console: context.console,
54
- req: context.req,
55
- res: context.res,
56
- bru: context.bru,
57
- expect: context.expect,
58
- assert: context.assert,
59
- __brunoTestResults: context.__brunoTestResults,
60
- test: context.test,
61
- // Configuration for nested module loading
62
- scriptingConfig: scriptingConfig,
63
- // Global objects
64
- Buffer: global.Buffer,
65
- process: global.process,
66
- setTimeout: global.setTimeout,
67
- setInterval: global.setInterval,
68
- clearTimeout: global.clearTimeout,
69
- clearInterval: global.clearInterval,
70
- setImmediate: global.setImmediate,
71
- clearImmediate: global.clearImmediate,
72
- Error: global.Error,
73
- TypeError: global.TypeError,
74
- ReferenceError: global.ReferenceError,
75
- SyntaxError: global.SyntaxError,
76
- RangeError: global.RangeError
77
- };
36
+ // Build the script context with Bruno objects and globals
37
+ const scriptContext = buildScriptContext(context, scriptingConfig);
38
+
39
+ // Create truly isolated context - scriptContext becomes the global object
40
+ // Scripts can ONLY access what's explicitly in scriptContext
41
+ const isolatedContext = vm.createContext(scriptContext);
78
42
 
79
- mixinTypedArrays(scriptContext);
43
+ // Add global/globalThis pointing to the isolated context (not host global)
44
+ // This allows libraries that reference 'global' to work while maintaining isolation
45
+ scriptContext.global = scriptContext;
46
+ scriptContext.globalThis = scriptContext;
80
47
 
81
- // Create shared cache for local modules
48
+ // Create module cache for CJS modules
82
49
  const localModuleCache = new Map();
83
50
 
84
- // Create a custom require function and add it to the context
51
+ // Add require() function for CJS module loading
85
52
  scriptContext.require = createCustomRequire({
86
- scriptingConfig,
87
53
  collectionPath,
88
- scriptContext,
54
+ isolatedContext,
89
55
  currentModuleDir: collectionPath,
90
56
  localModuleCache,
91
- allowScriptFilesystemAccess,
92
57
  additionalContextRootsAbsolute
93
58
  });
94
59
 
95
- // Execute the script in an isolated VM context
96
- await vm.runInNewContext(`
97
- (async function(){
98
- ${script}
99
- })();
100
- `, scriptContext, {
101
- filename: path.join(collectionPath, 'script.js'),
102
- displayErrors: true
60
+ // Execute the script in the isolated context
61
+ const wrappedScript = `(async function(){ ${script} \n})();`;
62
+ const compiledScript = new vm.Script(wrappedScript, {
63
+ filename: path.join(collectionPath, 'script.js')
103
64
  });
65
+
66
+ await compiledScript.runInContext(isolatedContext);
104
67
  } catch (error) {
105
68
  throw new ScriptError(error, script);
106
69
  }
107
-
108
- return;
109
70
  }
110
71
 
111
72
  /**
112
- * Creates a custom require function with enhanced security and local module support
113
- * @param {Object} options - Configuration options
114
- * @param {Object} options.scriptingConfig - Scripting configuration with additional context roots
115
- * @param {string} options.collectionPath - Base collection path for security checks
116
- * @param {Object} options.scriptContext - Script execution context
117
- * @param {string} options.currentModuleDir - Current module directory for relative imports
118
- * @param {Map} options.localModuleCache - Cache for loaded local modules
119
- * @param {boolean} options.allowScriptFilesystemAccess - Whether to allow fs module access
120
- * @param {Array<string>} options.additionalContextRootsAbsolute - Pre-computed absolute context roots
121
- * @returns {Function} Custom require function
73
+ * Build the script context with Bruno objects and necessary globals
74
+ * @param {Object} context - Bruno context (bru, req, res, etc.)
75
+ * @param {Object} scriptingConfig - Scripting configuration
76
+ * @returns {Object} Script context object
122
77
  */
123
- function createCustomRequire({
124
- scriptingConfig,
125
- collectionPath,
126
- scriptContext,
127
- currentModuleDir = collectionPath,
128
- localModuleCache = new Map(),
129
- allowScriptFilesystemAccess = false,
130
- additionalContextRootsAbsolute = []
131
- }) {
132
- return (moduleName) => {
133
- // Check if it's a local module (starts with ./ or ../ or .\ or ..\)
134
- // Normalize backslashes to forward slashes for cross-platform compatibility
135
- const normalizedModuleName = moduleName.replace(/\\/g, '/');
136
- if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) {
137
- return loadLocalModule({ moduleName: normalizedModuleName, collectionPath, scriptContext, localModuleCache, currentModuleDir, additionalContextRootsAbsolute });
138
- }
139
-
140
- // Helper function to check if a module is the fs module or a submodule
141
- const isFsModule = (module) => {
142
- if (!module) return false;
143
- const fsModule = require('fs');
144
- // Check if it's the fs module itself
145
- if (module === fsModule) return true;
146
- // Check if it's fs/promises submodule
147
- if (module === fsModule.promises) return true;
148
- // Check if it's fs/promises by comparing with require('fs/promises')
149
- try {
150
- if (module === require('fs/promises')) return true;
151
- } catch {
152
- // fs/promises might not be available in all Node versions
153
- }
154
- return false;
155
- };
156
-
157
- // First try to require as a native/npm module
158
- try {
159
- const requiredModulePath = require.resolve(moduleName, { paths: [...additionalContextRootsAbsolute, ...module.paths] });
160
- const requiredModule = require(requiredModulePath);
161
-
162
- // Block filesystem module access if filesystem access is not allowed
163
- if (!allowScriptFilesystemAccess && isFsModule(requiredModule)) {
164
- throw new Error('Filesystem access is not allowed. Enable "filesystemAccess.allow" in scripting config to use the fs module.');
165
- }
166
-
167
- return requiredModule;
168
- } catch (requireError) {
169
- // Re-throw if it's our filesystem access error
170
- if (requireError.message && requireError.message.includes('Enable "filesystemAccess.allow"')) {
171
- throw requireError;
172
- }
173
-
174
- // If that fails, try to resolve from additionalContextRoots
175
- throw new Error(`Could not resolve module "${moduleName}": ${requireError.message}\n\nThis most likely means you did not install the module under the collection or the "additionalContextRoots" using a package manager like npm.\n\nThese are your current "additionalContextRoots":\n${additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n') || ' - No "additionalContextRoots" defined'}`);
176
- }
78
+ function buildScriptContext(context, scriptingConfig) {
79
+ const scriptContext = {
80
+ ...context,
81
+
82
+ // Configuration for nested module loading
83
+ scriptingConfig: scriptingConfig,
84
+
85
+ // Safe globals from allowlist (Node.js/Web APIs only, not ECMAScript built-ins)
86
+ ...Object.fromEntries(
87
+ safeGlobals
88
+ .filter((key) => global[key] !== undefined)
89
+ .map((key) => [key, global[key]])
90
+ )
177
91
  };
178
- }
179
-
180
- /**
181
- * Loads a local module from the filesystem with security checks and caching
182
- * @param {Object} options - Configuration options
183
- * @param {string} options.moduleName - Name/path of the module to load
184
- * @param {string} options.collectionPath - Base collection path for security validation
185
- * @param {Object} options.scriptContext - Script execution context to inherit
186
- * @param {Map} options.localModuleCache - Cache for loaded modules
187
- * @param {string} options.currentModuleDir - Directory of the current module for relative resolution
188
- * @param {Array<string>} options.additionalContextRootsAbsolute - Additional allowed context root paths
189
- * @returns {*} The exported content of the loaded module
190
- * @throws {Error} When module is outside collection path or cannot be loaded
191
- */
192
- function loadLocalModule({
193
- moduleName,
194
- collectionPath,
195
- scriptContext,
196
- localModuleCache,
197
- currentModuleDir,
198
- additionalContextRootsAbsolute = []
199
- }) {
200
- // Check if the filename has an extension
201
- const hasExtension = path.extname(moduleName) !== '';
202
- const resolvedFilename = hasExtension ? moduleName : `${moduleName}.js`;
203
92
 
204
- // Resolve the file path relative to the current module's directory
205
- const filePath = path.resolve(currentModuleDir, resolvedFilename);
206
- const normalizedFilePath = path.normalize(filePath);
93
+ // Add TypedArrays from host for compatibility with host APIs (TextEncoder, crypto, etc.)
94
+ mixinTypedArrays(scriptContext);
207
95
 
208
- const isWithinAllowedRoot = additionalContextRootsAbsolute.some((allowedRoot) => {
209
- const normalizedAllowedRoot = path.normalize(allowedRoot);
210
- const relativePath = path.relative(normalizedAllowedRoot, normalizedFilePath);
211
- return !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
212
- });
213
-
214
- if (!isWithinAllowedRoot) {
215
- const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n');
216
- throw new Error(
217
- `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n`
218
- + `Allowed context roots:\n${allowedRootsDisplay}`
219
- );
220
- }
221
-
222
- // Check cache first (use normalized path as key)
223
- if (localModuleCache.has(normalizedFilePath)) {
224
- return localModuleCache.get(normalizedFilePath);
225
- }
226
-
227
- if (!fs.existsSync(normalizedFilePath)) {
228
- throw new Error(`Cannot find module ${moduleName}`);
229
- }
230
-
231
- // Read and execute the local module
232
- const moduleCode = fs.readFileSync(normalizedFilePath, 'utf8');
233
-
234
- // Create module object
235
- const moduleObj = { exports: {} };
236
-
237
- // Get the directory of this module for nested imports
238
- const moduleDir = path.dirname(normalizedFilePath);
239
-
240
- // Create a new context that inherits from the script context
241
- const moduleContext = {
242
- ...scriptContext,
243
- module: moduleObj,
244
- exports: moduleObj.exports,
245
- __filename: normalizedFilePath,
246
- __dirname: moduleDir,
247
- // Create a custom require function for this module that resolves relative to its directory
248
- require: createCustomRequire({
249
- scriptingConfig: scriptContext.scriptingConfig || {},
250
- collectionPath,
251
- scriptContext,
252
- currentModuleDir: moduleDir,
253
- localModuleCache,
254
- allowScriptFilesystemAccess: get(scriptContext.scriptingConfig, 'filesystemAccess.allow', false),
255
- additionalContextRootsAbsolute
256
- })
257
- };
258
-
259
- try {
260
- // Execute the module code in the shared context
261
- vm.runInNewContext(moduleCode, moduleContext, {
262
- filename: normalizedFilePath,
263
- displayErrors: true
264
- });
265
-
266
- // Cache the result using normalized path
267
- localModuleCache.set(normalizedFilePath, moduleObj.exports);
268
-
269
- return moduleObj.exports;
270
- } catch (error) {
271
- throw new Error(`Error loading local module ${moduleName}: ${error.message}`);
272
- }
96
+ return scriptContext;
273
97
  }
274
98
 
275
99
  module.exports = {