Merge branch 'isolated-vm-wrapper' into fix-bson

This commit is contained in:
Adria Navarro 2024-02-08 13:54:25 +01:00
commit a55e75ae18
3 changed files with 69 additions and 60 deletions

View File

@ -13,11 +13,16 @@ export function init() {
let { vm } = bbCtx let { vm } = bbCtx
if (!vm) { if (!vm) {
// Can't copy the native helpers into the isolate. We just ignore them as they are handled properly from the helpersSource
const { helpers, ...ctxToPass } = ctx
vm = new IsolatedVM({ vm = new IsolatedVM({
memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, memoryLimit: env.JS_RUNNER_MEMORY_LIMIT,
timeout: env.JS_PER_EXECUTION_TIME_LIMIT_MS, timeout: env.JS_PER_EXECUTION_TIME_LIMIT_MS,
perRequestLimit: env.JS_PER_REQUEST_TIME_LIMIT_MS, perRequestLimit: env.JS_PER_REQUEST_TIME_LIMIT_MS,
}).withHelpers() })
.withContext(ctxToPass)
.withHelpers()
bbCtx.vm = vm bbCtx.vm = vm
} }

View File

@ -16,47 +16,47 @@ class ExecutionTimeoutError extends Error {
} }
class ModuleHandler { class ModuleHandler {
#modules: { private modules: {
import: string import: string
moduleKey: string moduleKey: string
module: ivm.Module module: ivm.Module
}[] = [] }[] = []
#generateRandomKey = () => `i${crypto.randomUUID().replace(/-/g, "")}` private generateRandomKey = () => `i${crypto.randomUUID().replace(/-/g, "")}`
registerModule(module: ivm.Module, imports: string) { registerModule(module: ivm.Module, imports: string) {
this.#modules.push({ this.modules.push({
moduleKey: this.#generateRandomKey(), moduleKey: this.generateRandomKey(),
import: imports, import: imports,
module: module, module: module,
}) })
} }
generateImports() { generateImports() {
return this.#modules return this.modules
.map(m => `import ${m.import} from "${m.moduleKey}"`) .map(m => `import ${m.import} from "${m.moduleKey}"`)
.join(";") .join(";")
} }
getModule(key: string) { getModule(key: string) {
const module = this.#modules.find(m => m.moduleKey === key) const module = this.modules.find(m => m.moduleKey === key)
return module?.module return module?.module
} }
} }
export class IsolatedVM implements VM { export class IsolatedVM implements VM {
#isolate: ivm.Isolate private isolate: ivm.Isolate
#vm: ivm.Context private vm: ivm.Context
#jail: ivm.Reference private jail: ivm.Reference
#timeout: number private timeout: number
#perRequestLimit?: number private perRequestLimit?: number
// By default the wrapper returns itself // By default the wrapper returns itself
#codeWrapper: (code: string) => string = code => code private codeWrapper: (code: string) => string = code => code
#moduleHandler = new ModuleHandler() private moduleHandler = new ModuleHandler()
readonly #resultKey = "results" private readonly resultKey = "results"
constructor({ constructor({
memoryLimit, memoryLimit,
@ -67,30 +67,30 @@ export class IsolatedVM implements VM {
timeout: number timeout: number
perRequestLimit?: number perRequestLimit?: number
}) { }) {
this.#isolate = new ivm.Isolate({ memoryLimit }) this.isolate = new ivm.Isolate({ memoryLimit })
this.#vm = this.#isolate.createContextSync() this.vm = this.isolate.createContextSync()
this.#jail = this.#vm.global this.jail = this.vm.global
this.#jail.setSync("global", this.#jail.derefInto()) this.jail.setSync("global", this.jail.derefInto())
this.#addToContext({ this.addToContext({
[this.#resultKey]: { out: "" }, [this.resultKey]: { out: "" },
}) })
this.#timeout = timeout this.timeout = timeout
this.#perRequestLimit = perRequestLimit this.perRequestLimit = perRequestLimit
} }
withHelpers() { withHelpers() {
const urlModule = this.#registerCallbacks({ const urlModule = this.registerCallbacks({
resolve: url.resolve, resolve: url.resolve,
parse: url.parse, parse: url.parse,
}) })
const querystringModule = this.#registerCallbacks({ const querystringModule = this.registerCallbacks({
escape: querystring.escape, escape: querystring.escape,
}) })
this.#addToContext({ this.addToContext({
helpersStripProtocol: new ivm.Callback((str: string) => { helpersStripProtocol: new ivm.Callback((str: string) => {
var parsed = url.parse(str) as any var parsed = url.parse(str) as any
parsed.protocol = "" parsed.protocol = ""
@ -105,19 +105,19 @@ export class IsolatedVM implements VM {
} }
}` }`
const helpersSource = loadBundle(BundleType.HELPERS) const helpersSource = loadBundle(BundleType.HELPERS)
const helpersModule = this.#isolate.compileModuleSync( const helpersModule = this.isolate.compileModuleSync(
`${injectedRequire};${helpersSource}` `${injectedRequire};${helpersSource}`
) )
helpersModule.instantiateSync(this.#vm, specifier => { helpersModule.instantiateSync(this.vm, specifier => {
if (specifier === "crypto") { if (specifier === "crypto") {
const cryptoModule = this.#registerCallbacks({ const cryptoModule = this.registerCallbacks({
randomUUID: crypto.randomUUID, randomUUID: crypto.randomUUID,
}) })
const module = this.#isolate.compileModuleSync( const module = this.isolate.compileModuleSync(
`export default ${cryptoModule}` `export default ${cryptoModule}`
) )
module.instantiateSync(this.#vm, specifier => { module.instantiateSync(this.vm, specifier => {
throw new Error(`No imports allowed. Required: ${specifier}`) throw new Error(`No imports allowed. Required: ${specifier}`)
}) })
return module return module
@ -125,18 +125,18 @@ export class IsolatedVM implements VM {
throw new Error(`No imports allowed. Required: ${specifier}`) throw new Error(`No imports allowed. Required: ${specifier}`)
}) })
this.#moduleHandler.registerModule(helpersModule, "helpers") this.moduleHandler.registerModule(helpersModule, "helpers")
return this return this
} }
withContext(context: Record<string, any>) { withContext(context: Record<string, any>) {
this.#addToContext(context) this.addToContext(context)
return this return this
} }
withParsingBson(data: any) { withParsingBson(data: any) {
this.#addToContext({ this.addToContext({
bsonData: bson.BSON.serialize({ data }), bsonData: bson.BSON.serialize({ data }),
}) })
@ -145,7 +145,7 @@ export class IsolatedVM implements VM {
// 2. Deserialise the data within the isolate, to get the original data // 2. Deserialise the data within the isolate, to get the original data
// 3. Process script // 3. Process script
// 4. Stringify the result in order to convert the result from BSON to json // 4. Stringify the result in order to convert the result from BSON to json
this.#codeWrapper = code => this.codeWrapper = code =>
`(function(){ `(function(){
const data = deserialize(bsonData, { validation: { utf8: false } }).data; const data = deserialize(bsonData, { validation: { utf8: false } }).data;
const result = ${code} const result = ${code}
@ -154,7 +154,7 @@ export class IsolatedVM implements VM {
const bsonSource = loadBundle(BundleType.BSON) const bsonSource = loadBundle(BundleType.BSON)
this.#addToContext({ this.addToContext({
textDecoderCb: new ivm.Callback( textDecoderCb: new ivm.Callback(
(args: { (args: {
constructorArgs: any constructorArgs: any
@ -184,23 +184,23 @@ export class IsolatedVM implements VM {
}) })
} }
}.toString() }.toString()
const bsonModule = this.#isolate.compileModuleSync( const bsonModule = this.isolate.compileModuleSync(
`${textDecoderPolyfill};${bsonSource}` `${textDecoderPolyfill};${bsonSource}`
) )
bsonModule.instantiateSync(this.#vm, specifier => { bsonModule.instantiateSync(this.vm, specifier => {
throw new Error(`No imports allowed. Required: ${specifier}`) throw new Error(`No imports allowed. Required: ${specifier}`)
}) })
this.#moduleHandler.registerModule(bsonModule, "{deserialize, toJson}") this.moduleHandler.registerModule(bsonModule, "{deserialize, toJson}")
return this return this
} }
execute(code: string): string { execute(code: string): string {
const perRequestLimit = this.#perRequestLimit const perRequestLimit = this.perRequestLimit
if (perRequestLimit) { if (perRequestLimit) {
const cpuMs = Number(this.#isolate.cpuTime) / 1e6 const cpuMs = Number(this.isolate.cpuTime) / 1e6
if (cpuMs > perRequestLimit) { if (cpuMs > perRequestLimit) {
throw new ExecutionTimeoutError( throw new ExecutionTimeoutError(
`CPU time limit exceeded (${cpuMs}ms > ${perRequestLimit}ms)` `CPU time limit exceeded (${cpuMs}ms > ${perRequestLimit}ms)`
@ -208,14 +208,14 @@ export class IsolatedVM implements VM {
} }
} }
code = `${this.#moduleHandler.generateImports()};results.out=${this.#codeWrapper( code = `${this.moduleHandler.generateImports()};results.out=${this.codeWrapper(
code code
)};` )};`
const script = this.#isolate.compileModuleSync(code) const script = this.isolate.compileModuleSync(code)
script.instantiateSync(this.#vm, specifier => { script.instantiateSync(this.vm, specifier => {
const module = this.#moduleHandler.getModule(specifier) const module = this.moduleHandler.getModule(specifier)
if (module) { if (module) {
return module return module
} }
@ -223,13 +223,13 @@ export class IsolatedVM implements VM {
throw new Error(`"${specifier}" import not allowed`) throw new Error(`"${specifier}" import not allowed`)
}) })
script.evaluateSync({ timeout: this.#timeout }) script.evaluateSync({ timeout: this.timeout })
const result = this.#getFromContext(this.#resultKey) const result = this.getFromContext(this.resultKey)
return result.out return result.out
} }
#registerCallbacks(functions: Record<string, any>) { private registerCallbacks(functions: Record<string, any>) {
const libId = crypto.randomUUID().replace(/-/g, "") const libId = crypto.randomUUID().replace(/-/g, "")
const x: Record<string, string> = {} const x: Record<string, string> = {}
@ -237,7 +237,7 @@ export class IsolatedVM implements VM {
const key = `f${libId}${funcName}cb` const key = `f${libId}${funcName}cb`
x[funcName] = key x[funcName] = key
this.#addToContext({ this.addToContext({
[key]: new ivm.Callback((...params: any[]) => (func as any)(...params)), [key]: new ivm.Callback((...params: any[]) => (func as any)(...params)),
}) })
} }
@ -251,17 +251,20 @@ export class IsolatedVM implements VM {
return mod return mod
} }
#addToContext(context: Record<string, any>) { private addToContext(context: Record<string, any>) {
for (let key in context) { for (let key in context) {
this.#jail.setSync( const value = context[key]
this.jail.setSync(
key, key,
new ivm.ExternalCopy(context[key]).copyInto({ release: true }) typeof value === "function"
? value
: new ivm.ExternalCopy(value).copyInto({ release: true })
) )
} }
} }
#getFromContext(key: string) { private getFromContext(key: string) {
const ref = this.#vm.global.getSync(key, { reference: true }) const ref = this.vm.global.getSync(key, { reference: true })
const result = ref.copySync() const result = ref.copySync()
ref.release() ref.release()
return result return result

View File

@ -2,24 +2,25 @@ import env from "../environment"
import { IsolatedVM } from "../jsRunner/vm" import { IsolatedVM } from "../jsRunner/vm"
const JS_TIMEOUT_MS = 1000 const JS_TIMEOUT_MS = 1000
class ScriptRunner { class ScriptRunner {
#code: string private code: string
#vm: IsolatedVM private vm: IsolatedVM
constructor(script: string, context: any, { parseBson = false } = {}) { constructor(script: string, context: any, { parseBson = false } = {}) {
this.#code = `(() => {${script}})();` this.code = `(() => {${script}})();`
this.#vm = new IsolatedVM({ this.vm = new IsolatedVM({
memoryLimit: env.JS_RUNNER_MEMORY_LIMIT, memoryLimit: env.JS_RUNNER_MEMORY_LIMIT,
timeout: JS_TIMEOUT_MS, timeout: JS_TIMEOUT_MS,
}).withContext(context) }).withContext(context)
if (parseBson && context.data) { if (parseBson && context.data) {
this.#vm = this.#vm.withParsingBson(context.data) this.vm = this.vm.withParsingBson(context.data)
} }
} }
execute() { execute() {
const result = this.#vm.execute(this.#code) const result = this.vm.execute(this.code)
return result return result
} }
} }