Skip to content

Commit d6dbbe3

Browse files
authored
Merge pull request #209 from HarperFast/vm-load-plugins
Use the VM loader for loading plugin modules as well resource modules
2 parents ae63ede + 6588350 commit d6dbbe3

File tree

6 files changed

+135
-75
lines changed

6 files changed

+135
-75
lines changed

components/ApplicationScope.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Resources } from '../resources/Resources.ts';
2+
import { type Server } from '../server/Server.ts';
3+
import { loggerWithTag } from '../utility/logging/harper_logger.js';
4+
import { scopedImport } from '../security/jsLoader.ts';
5+
import * as env from '../utility/environment/environmentManager.js';
6+
import { CONFIG_PARAMS } from '../utility/hdbTerms.ts';
7+
8+
export class MissingDefaultFilesOptionError extends Error {
9+
constructor() {
10+
super('No default files option exists. Ensure `files` is specified in config.yaml');
11+
this.name = 'MissingDefaultFilesOptionError';
12+
}
13+
}
14+
15+
/**
16+
* This class is used to represent the application scope for the VM context used for loading modules within an application
17+
*/
18+
export class ApplicationScope {
19+
logger: any;
20+
resources: Resources;
21+
server: Server;
22+
mode?: 'none' | 'vm' | 'compartment'; // option to set this from the scope
23+
dependencyContainment?: boolean; // option to set this from the scope
24+
verifyPath?: string;
25+
config: any;
26+
constructor(name: string, resources: Resources, server: Server, verifyPath?: string) {
27+
this.logger = loggerWithTag(name);
28+
29+
this.resources = resources;
30+
this.server = server;
31+
32+
this.mode = env.get(CONFIG_PARAMS.APPLICATIONS_CONTAINMENT) ?? 'vm';
33+
this.dependencyContainment = Boolean(env.get(CONFIG_PARAMS.APPLICATIONS_DEPENDENCYCONTAINMENT));
34+
this.verifyPath = verifyPath;
35+
}
36+
37+
/**
38+
* The compartment that is used for this scope and any imports that it makes
39+
*/
40+
compartment?: Promise<any>;
41+
/**
42+
* Import a file into the scope's sandbox.
43+
* @param filePath - The path of the file to import.
44+
* @returns A promise that resolves with the imported module or value.
45+
*/
46+
async import(filePath: string): Promise<unknown> {
47+
return scopedImport(filePath, this);
48+
}
49+
}

components/Scope.ts

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
import { type Logger } from '../utility/logging/logger.ts';
22
import { loggerWithTag } from '../utility/logging/harper_logger.js';
33
import { EventEmitter, once } from 'node:events';
4-
import { type Server } from '../server/Server.ts';
4+
import { server, type Server } from '../server/Server.ts';
55
import { EntryHandler, type EntryHandlerEventMap, type onEntryEventHandler } from './EntryHandler.ts';
66
import { OptionsWatcher, OptionsWatcherEventMap } from './OptionsWatcher.ts';
7-
import type { Resources } from '../resources/Resources.ts';
7+
import { resources, type Resources } from '../resources/Resources.ts';
88
import type { FileAndURLPathConfig } from './Component.ts';
99
import { FilesOption } from './deriveGlobOptions.ts';
1010
import { requestRestart } from './requestRestart.ts';
11-
import { scopedImport } from '../security/jsLoader.ts';
12-
import * as env from '../utility/environment/environmentManager.js';
13-
import { CONFIG_PARAMS } from '../utility/hdbTerms';
11+
import { ApplicationScope } from './ApplicationScope.ts';
1412

1513
export class MissingDefaultFilesOptionError extends Error {
1614
constructor() {
@@ -19,12 +17,6 @@ export class MissingDefaultFilesOptionError extends Error {
1917
}
2018
}
2119

22-
export interface ApplicationContainment {
23-
mode?: 'none' | 'vm' | 'compartment'; // option to set this from the scope
24-
dependencyContainment?: boolean; // option to set this from the scope
25-
verifyPath?: string;
26-
}
27-
2820
/**
2921
* This class is what is passed to the `handleApplication` function of an extension.
3022
*
@@ -40,22 +32,23 @@ export class Scope extends EventEmitter {
4032
#entryHandlers: EntryHandler[];
4133
#logger: Logger;
4234
#pendingInitialLoads: Set<Promise<void>>;
35+
applicationScope?: ApplicationScope;
4336

4437
options: OptionsWatcher;
45-
resources: Resources;
46-
server: Server;
38+
resources?: Resources;
39+
server?: Server;
4740
ready: Promise<any[]>;
48-
applicationContainment?: ApplicationContainment;
49-
constructor(name: string, directory: string, configFilePath: string, resources: Resources, server: Server) {
41+
constructor(name: string, directory: string, configFilePath: string, applicationScope: ApplicationScope) {
5042
super();
5143

5244
this.#name = name;
5345
this.#directory = directory;
5446
this.#configFilePath = configFilePath;
5547
this.#logger = loggerWithTag(this.#name);
5648

57-
this.resources = resources;
58-
this.server = server;
49+
this.applicationScope = applicationScope;
50+
this.resources = applicationScope?.resources ?? resources;
51+
this.server = applicationScope?.server ?? server;
5952

6053
this.#entryHandlers = [];
6154
this.#pendingInitialLoads = new Set();
@@ -67,12 +60,6 @@ export class Scope extends EventEmitter {
6760
.on('error', this.#handleError.bind(this))
6861
.on('change', this.#optionsWatcherChangeListener.bind(this)())
6962
.on('ready', this.#handleOptionsWatcherReady.bind(this));
70-
71-
this.applicationContainment = {
72-
mode: env.get(CONFIG_PARAMS.APPLICATIONS_CONTAINMENT) ?? 'vm',
73-
dependencyContainment: Boolean(env.get(CONFIG_PARAMS.APPLICATIONS_DEPENDENCYCONTAINMENT)),
74-
verifyPath: directory,
75-
};
7663
}
7764

7865
get logger(): Logger {
@@ -307,16 +294,12 @@ export class Scope extends EventEmitter {
307294
}
308295
}
309296

310-
/**
311-
* The compartment that is used for this scope and any imports that it makes
312-
*/
313-
compartment?: Promise<any>;
314297
/**
315298
* Import a file into the scope's sandbox.
316299
* @param filePath - The path of the file to import.
317300
* @returns A promise that resolves with the imported module or value.
318301
*/
319302
async import(filePath: string): Promise<unknown> {
320-
return scopedImport(filePath, this);
303+
return this.applicationScope ? this.applicationScope.import(filePath) : import(filePath);
321304
}
322305
}

components/componentLoader.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ import * as mqtt from '../server/mqtt.ts';
2929
import { getConfigObj, resolvePath } from '../config/configUtils.js';
3030
import { createReuseportFd } from '../server/serverHelpers/Request.ts';
3131
import { ErrorResource } from '../resources/ErrorResource.ts';
32-
import { Scope, type ApplicationContainment } from './Scope.ts';
32+
import { Scope } from './Scope.ts';
33+
import { ApplicationScope } from './ApplicationScope.ts';
3334
import { ComponentV1, processResourceExtensionComponent } from './ComponentV1.ts';
3435
import * as httpComponent from '../server/http.ts';
3536
import { Status } from '../server/status/index.ts';
@@ -219,6 +220,7 @@ function sequentiallyHandleApplication(scope: Scope, plugin: PluginModule) {
219220

220221
export interface LoadComponentOptions {
221222
isRoot?: boolean;
223+
applicationScope?: ApplicationScope;
222224
autoReload?: boolean;
223225
applicationContainment?: ApplicationContainment;
224226
providedLoadedComponents?: Map<any, any>;
@@ -241,7 +243,14 @@ export async function loadComponent(
241243
const resolvedFolder = realpathSync(componentDirectory);
242244
if (loadedPaths.has(resolvedFolder)) return loadedPaths.get(resolvedFolder);
243245
loadedPaths.set(resolvedFolder, true);
244-
const { providedLoadedComponents, isRoot, autoReload } = options;
246+
247+
const {
248+
providedLoadedComponents,
249+
applicationScope = new ApplicationScope(basename(componentDirectory), resources, server),
250+
isRoot,
251+
autoReload,
252+
} = options;
253+
applicationScope.verifyPath ??= componentDirectory;
245254
if (providedLoadedComponents) loadedComponents = providedLoadedComponents;
246255
try {
247256
let config;
@@ -257,6 +266,7 @@ export async function loadComponent(
257266
} else {
258267
config = DEFAULT_CONFIG;
259268
}
269+
applicationScope.config ??= config;
260270

261271
if (!isRoot) {
262272
try {
@@ -286,6 +296,8 @@ export async function loadComponent(
286296
// Initialize loading status for all components (applications and extensions)
287297
componentLifecycle.loading(componentStatusName);
288298

299+
const subApplicationScope = isRoot ? new ApplicationScope(componentName, resources, server) : applicationScope;
300+
289301
let extensionModule: any;
290302
const pkg = componentConfig.package;
291303
try {
@@ -306,8 +318,11 @@ export async function loadComponent(
306318
}
307319
}
308320
if (componentPath) {
321+
subApplicationScope.verifyPath = componentPath;
309322
if (!process.env.HARPER_SAFE_MODE) {
310-
extensionModule = await loadComponent(componentPath, resources, origin);
323+
extensionModule = await loadComponent(componentPath, resources, origin, {
324+
applicationScope: subApplicationScope,
325+
});
311326
componentFunctionality[componentName] = true;
312327
}
313328
} else {
@@ -317,7 +332,7 @@ export async function loadComponent(
317332
const plugin = TRUSTED_RESOURCE_PLUGINS[componentName];
318333
extensionModule =
319334
typeof plugin === 'string'
320-
? await scopedImport(plugin.startsWith('@/') ? join(PACKAGE_ROOT, plugin.slice(1)) : plugin)
335+
? await import(plugin.startsWith('@/') ? join(PACKAGE_ROOT, plugin.slice(1)) : plugin)
321336
: plugin;
322337
}
323338

@@ -358,8 +373,7 @@ export async function loadComponent(
358373

359374
// New Plugin API (`handleApplication`)
360375
if (resources.isWorker && extensionModule.handleApplication) {
361-
const scope = new Scope(componentName, componentDirectory, configPath, resources, server);
362-
if (options.applicationContainment) scope.applicationContainment = options.applicationContainment;
376+
const scope = new Scope(componentName, componentDirectory, configPath, applicationScope);
363377

364378
await sequentiallyHandleApplication(scope, extensionModule);
365379

@@ -464,7 +478,10 @@ export async function loadComponent(
464478
});
465479
}
466480
if (config.extensionModule || config.pluginModule) {
467-
const extensionModule = await import(join(componentDirectory, config.extensionModule || config.pluginModule));
481+
const extensionModule = await scopedImport(
482+
join(componentDirectory, config.extensionModule || config.pluginModule),
483+
applicationScope
484+
);
468485
loadedPaths.set(resolvedFolder, extensionModule);
469486
return extensionModule;
470487
}

security/jsLoader.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs';
77
import { dirname, isAbsolute } from 'node:path';
88
import { pathToFileURL, fileURLToPath } from 'node:url';
99
import { SourceTextModule, SyntheticModule, createContext, runInContext } from 'node:vm';
10-
import { Scope } from '../components/Scope.ts';
10+
import { ApplicationScope } from '../components/ApplicationScope.ts';
1111
import logger from '../utility/logging/harper_logger.js';
1212
import { createRequire } from 'node:module';
1313
import * as env from '../utility/environment/environmentManager';
@@ -25,7 +25,7 @@ let lockedDown = false;
2525
* @param moduleUrl
2626
* @param scope
2727
*/
28-
export async function scopedImport(filePath: string | URL, scope?: Scope) {
28+
export async function scopedImport(filePath: string | URL, scope?: ApplicationScope) {
2929
if (!lockedDown && APPLICATIONS_LOCKDOWN && APPLICATIONS_LOCKDOWN !== 'none') {
3030
lockedDown = true;
3131
if (APPLICATIONS_LOCKDOWN === 'ses') {
@@ -76,7 +76,7 @@ export async function scopedImport(filePath: string | URL, scope?: Scope) {
7676
}
7777
const moduleUrl = (filePath instanceof URL ? filePath : pathToFileURL(filePath)).toString();
7878
try {
79-
const containmentMode = scope?.applicationContainment.mode;
79+
const containmentMode = scope?.mode;
8080
if (scope && containmentMode !== 'none') {
8181
if (containmentMode === 'compartment') {
8282
// use SES Compartments
@@ -110,7 +110,7 @@ export async function scopedImport(filePath: string | URL, scope?: Scope) {
110110
/**
111111
* Load a module using Node's vm.Module API with (not really secure) sandboxing
112112
*/
113-
async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
113+
async function loadModuleWithVM(moduleUrl: string, scope: ApplicationScope) {
114114
const moduleCache = new Map<string, Promise<SourceTextModule | SyntheticModule>>();
115115

116116
// Create a secure context with limited globals
@@ -167,7 +167,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
167167
filename: url,
168168
async importModuleDynamically(specifier: string, script) {
169169
const resolvedUrl = resolveModule(specifier, script.sourceURL);
170-
const useContainment = specifier.startsWith('.') || scope.applicationContainment.dependencyContainment;
170+
const useContainment = specifier.startsWith('.') || scope.dependencyContainment;
171171
const dynamicModule = await loadModuleWithCache(resolvedUrl, useContainment);
172172
return dynamicModule;
173173
},
@@ -213,7 +213,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
213213
return moduleCache.get(resolvedUrl)!;
214214
}
215215

216-
const useContainment = specifier.startsWith('.') || scope.applicationContainment.dependencyContainment;
216+
const useContainment = specifier.startsWith('.') || scope.dependencyContainment;
217217
// Load the module
218218
return await loadModuleWithCache(resolvedUrl, useContainment);
219219
}
@@ -246,7 +246,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
246246
{ identifier: url, context }
247247
);
248248
} else if (usePrivateGlobal && url.startsWith('file://')) {
249-
checkAllowedModulePath(url, scope.applicationContainment.verifyPath);
249+
checkAllowedModulePath(url, scope.verifyPath);
250250
// Load source text from file
251251
const source = await readFile(new URL(url), { encoding: 'utf-8' });
252252

@@ -286,7 +286,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
286286
}
287287
}
288288
} else {
289-
checkAllowedModulePath(url, scope.applicationContainment.verifyPath);
289+
checkAllowedModulePath(url, scope.verifyPath);
290290
// For Node.js built-in modules (node:) and npm packages, use dynamic import
291291
const importedModule = await import(url);
292292
const exportNames = Object.keys(importedModule);
@@ -318,7 +318,7 @@ async function loadModuleWithVM(moduleUrl: string, scope: Scope) {
318318
return entryModule.namespace;
319319
}
320320

321-
async function getCompartment(scope: Scope, globals) {
321+
async function getCompartment(scope: ApplicationScope, globals) {
322322
const { StaticModuleRecord } = await import('@endo/static-module-record');
323323
require('ses');
324324
const compartment: CompartmentOptions = new (Compartment as typeof CompartmentOptions)(
@@ -351,7 +351,7 @@ async function getCompartment(scope: Scope, globals) {
351351
const moduleText = await readFile(new URL(moduleSpecifier), { encoding: 'utf-8' });
352352
return new StaticModuleRecord(moduleText, moduleSpecifier);
353353
} else {
354-
checkAllowedModulePath(moduleSpecifier, scope.applicationContainment.verifyPath);
354+
checkAllowedModulePath(moduleSpecifier, scope.verifyPath);
355355
const moduleExports = await import(moduleSpecifier);
356356
return {
357357
imports: [],
@@ -398,7 +398,7 @@ function getDefaultJSGlobalNames() {
398398
/**
399399
* Get the set of global variables that should be available to modules that run in scoped compartments/contexts.
400400
*/
401-
function getGlobalObject(scope: Scope) {
401+
function getGlobalObject(scope: ApplicationScope) {
402402
const appGlobal = {};
403403
// create the new global object, assigning all the global variables from this global
404404
// except those that will be natural intrinsics of the new VM
@@ -411,20 +411,20 @@ function getGlobalObject(scope: Scope) {
411411
server: scope.server ?? server,
412412
logger: scope.logger ?? logger,
413413
resources: scope.resources,
414-
config: scope.options.getRoot() ?? {},
414+
config: scope.config ?? {},
415415
fetch: APPLICATIONS_LOCKDOWN === 'ses' ? secureOnlyFetch : fetch,
416416
console,
417417
global: appGlobal,
418418
harper: getHarperExports(scope),
419419
});
420420
return appGlobal;
421421
}
422-
function getHarperExports(scope: Scope) {
422+
function getHarperExports(scope: ApplicationScope) {
423423
return {
424424
server: scope.server ?? server,
425425
logger: scope.logger ?? logger,
426426
resources: scope.resources,
427-
config: scope.options.getRoot() ?? {},
427+
config: scope.config ?? {},
428428
Resource,
429429
tables,
430430
databases,
@@ -465,10 +465,10 @@ const ALLOWED_NODE_BUILTIN_MODULES = new Set([
465465
function checkAllowedModulePath(moduleUrl: string, containingFolder: string): boolean {
466466
if (moduleUrl.startsWith('file:')) {
467467
const path = moduleUrl.slice(7);
468-
if (path.startsWith(containingFolder)) {
468+
if (!containingFolder || path.startsWith(containingFolder)) {
469469
return true;
470470
}
471-
throw new Error(`Can not load module outside of application folder`);
471+
throw new Error(`Can not load module outside of application folder ${containingFolder}`);
472472
}
473473
let simpleName = moduleUrl.startsWith('node:') ? moduleUrl.slice(5) : moduleUrl;
474474
simpleName = simpleName.split('/')[0];

0 commit comments

Comments
 (0)