181 lines
4.3 KiB
TypeScript
181 lines
4.3 KiB
TypeScript
import ivm from "isolated-vm"
|
|
|
|
import url from "url"
|
|
import crypto from "crypto"
|
|
import querystring from "querystring"
|
|
|
|
import { BundleType, loadBundle } from "../bundles"
|
|
import { VM } from "@budibase/types"
|
|
|
|
class ExecutionTimeoutError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = "ExecutionTimeoutError"
|
|
}
|
|
}
|
|
|
|
export class IsolatedVM implements VM {
|
|
#isolate: ivm.Isolate
|
|
#vm: ivm.Context
|
|
#jail: ivm.Reference
|
|
#timeout: number
|
|
#perRequestLimit?: number
|
|
|
|
#modules: Record<
|
|
string,
|
|
{
|
|
headCode: string
|
|
module: ivm.Module
|
|
}
|
|
> = {}
|
|
|
|
readonly #resultKey = "results"
|
|
|
|
constructor({
|
|
memoryLimit,
|
|
timeout,
|
|
perRequestLimit,
|
|
}: {
|
|
memoryLimit: number
|
|
timeout: number
|
|
perRequestLimit?: number
|
|
}) {
|
|
this.#isolate = new ivm.Isolate({ memoryLimit })
|
|
this.#vm = this.#isolate.createContextSync()
|
|
this.#jail = this.#vm.global
|
|
this.#jail.setSync("global", this.#jail.derefInto())
|
|
|
|
this.#addToContext({
|
|
[this.#resultKey]: { out: "" },
|
|
})
|
|
|
|
this.#timeout = timeout
|
|
this.#perRequestLimit = perRequestLimit
|
|
}
|
|
|
|
withHelpers() {
|
|
const urlModule = this.#registerCallbacks({
|
|
resolve: url.resolve,
|
|
parse: url.parse,
|
|
})
|
|
|
|
const querystringModule = this.#registerCallbacks({
|
|
escape: querystring.escape,
|
|
})
|
|
|
|
const injectedRequire = `const require=function req(val) {
|
|
switch (val) {
|
|
case "url": return ${urlModule};
|
|
case "querystring": return ${querystringModule};
|
|
}
|
|
}`
|
|
|
|
this.#addToContext({
|
|
helpersStripProtocol: new ivm.Callback((str: string) => {
|
|
var parsed = url.parse(str) as any
|
|
parsed.protocol = ""
|
|
return parsed.format()
|
|
}),
|
|
})
|
|
|
|
const helpersSource = loadBundle(BundleType.HELPERS)
|
|
const helpersModule = this.#isolate.compileModuleSync(
|
|
`${injectedRequire};${helpersSource}`
|
|
)
|
|
|
|
const cryptoModule = this.#registerCallbacks({
|
|
randomUUID: crypto.randomUUID,
|
|
})
|
|
|
|
helpersModule.instantiateSync(this.#vm, specifier => {
|
|
if (specifier === "crypto") {
|
|
const module = this.#isolate.compileModuleSync(
|
|
`export default ${cryptoModule}`
|
|
)
|
|
module.instantiateSync(this.#vm, specifier => {
|
|
throw new Error(`No imports allowed. Required: ${specifier}`)
|
|
})
|
|
return module
|
|
}
|
|
throw new Error(`No imports allowed. Required: ${specifier}`)
|
|
})
|
|
|
|
this.#modules["compiled_module"] = {
|
|
headCode: 'import helpers from "compiled_module"',
|
|
module: helpersModule,
|
|
}
|
|
return this
|
|
}
|
|
|
|
execute(code: string): string {
|
|
const perRequestLimit = this.#perRequestLimit
|
|
|
|
if (perRequestLimit) {
|
|
const cpuMs = Number(this.#isolate.cpuTime) / 1e6
|
|
if (cpuMs > perRequestLimit) {
|
|
throw new ExecutionTimeoutError(
|
|
`CPU time limit exceeded (${cpuMs}ms > ${perRequestLimit}ms)`
|
|
)
|
|
}
|
|
}
|
|
|
|
code = [
|
|
...Object.values(this.#modules).map(m => m.headCode),
|
|
`results.out=${code};`,
|
|
].join(";")
|
|
|
|
const script = this.#isolate.compileModuleSync(code)
|
|
|
|
script.instantiateSync(this.#vm, specifier => {
|
|
if (specifier === "compiled_module") {
|
|
return this.#modules[specifier].module
|
|
}
|
|
|
|
throw new Error(`"${specifier}" import not allowed`)
|
|
})
|
|
|
|
script.evaluateSync({ timeout: this.#timeout })
|
|
|
|
const result = this.#getResult()
|
|
return result
|
|
}
|
|
|
|
#registerCallbacks(functions: Record<string, any>) {
|
|
const libId = crypto.randomUUID().replace(/-/g, "")
|
|
|
|
const x: Record<string, string> = {}
|
|
for (const [funcName, func] of Object.entries(functions)) {
|
|
const key = `f${libId}${funcName}cb`
|
|
x[funcName] = key
|
|
|
|
this.#addToContext({
|
|
[key]: new ivm.Callback((...params: any[]) => (func as any)(...params)),
|
|
})
|
|
}
|
|
|
|
const mod =
|
|
`{` +
|
|
Object.entries(x)
|
|
.map(([key, func]) => `${key}: ${func}`)
|
|
.join() +
|
|
"}"
|
|
return mod
|
|
}
|
|
|
|
#addToContext(context: Record<string, any>) {
|
|
for (let key in context) {
|
|
this.#jail.setSync(
|
|
key,
|
|
new ivm.ExternalCopy(context[key]).copyInto({ release: true })
|
|
)
|
|
}
|
|
}
|
|
|
|
#getResult() {
|
|
const ref = this.#vm.global.getSync(this.#resultKey, { reference: true })
|
|
const result = ref.copySync()
|
|
ref.release()
|
|
return result.out
|
|
}
|
|
}
|