Merge pull request #12611 from Budibase/limit-js-execution-per-request

Implement per-request JS execution limiting.
This commit is contained in:
Sam Rose 2023-12-19 14:24:47 +00:00 committed by GitHub
commit 2524c531e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 208 additions and 2 deletions

View File

@ -335,3 +335,11 @@ export function isScim(): boolean {
const scimCall = context?.isScim const scimCall = context?.isScim
return !!scimCall return !!scimCall
} }
export function getCurrentContext(): ContextMap | undefined {
try {
return Context.get()
} catch (e) {
return undefined
}
}

View File

@ -1,4 +1,5 @@
import { IdentityContext } from "@budibase/types" import { IdentityContext } from "@budibase/types"
import { ExecutionTimeTracker } from "../timers"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -9,4 +10,5 @@ export type ContextMap = {
isScim?: boolean isScim?: boolean
automationId?: string automationId?: string
isMigrating?: boolean isMigrating?: boolean
jsExecutionTracker?: ExecutionTimeTracker
} }

View File

@ -20,3 +20,37 @@ export function cleanup() {
} }
intervals = [] intervals = []
} }
export class ExecutionTimeoutError extends Error {
public readonly name = "ExecutionTimeoutError"
}
export class ExecutionTimeTracker {
static withLimit(limitMs: number) {
return new ExecutionTimeTracker(limitMs)
}
constructor(private limitMs: number) {}
private totalTimeMs = 0
track<T>(f: () => T): T {
this.checkLimit()
const start = process.hrtime.bigint()
try {
return f()
} finally {
const end = process.hrtime.bigint()
this.totalTimeMs += Number(end - start) / 1e6
this.checkLimit()
}
}
private checkLimit() {
if (this.totalTimeMs > this.limitMs) {
throw new ExecutionTimeoutError(
`Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms`
)
}
}
}

View File

@ -49,4 +49,8 @@ export class Duration {
static fromDays(duration: number) { static fromDays(duration: number) {
return Duration.from(DurationType.DAYS, duration) return Duration.from(DurationType.DAYS, duration)
} }
static fromMilliseconds(duration: number) {
return Duration.from(DurationType.MILLISECONDS, duration)
}
} }

View File

@ -2086,4 +2086,112 @@ describe.each([
expect(row.formula).toBe(relatedRow.name) expect(row.formula).toBe(relatedRow.name)
}) })
}) })
describe("Formula JS protection", () => {
it("should time out JS execution if a single cell takes too long", async () => {
await config.withEnv({ JS_PER_EXECUTION_TIME_LIMIT_MS: 20 }, async () => {
const js = Buffer.from(
`
let i = 0;
while (true) {
i++;
}
return i;
`
).toString("base64")
const table = await config.createTable({
name: "table",
type: "table",
schema: {
text: {
name: "text",
type: FieldType.STRING,
},
formula: {
name: "formula",
type: FieldType.FORMULA,
formula: `{{ js "${js}"}}`,
formulaType: FormulaTypes.DYNAMIC,
},
},
})
await config.api.row.save(table._id!, { text: "foo" })
const { rows } = await config.api.row.search(table._id!)
expect(rows).toHaveLength(1)
const row = rows[0]
expect(row.text).toBe("foo")
expect(row.formula).toBe("Timed out while executing JS")
})
})
it("should time out JS execution if a multiple cells take too long", async () => {
await config.withEnv(
{
JS_PER_EXECUTION_TIME_LIMIT_MS: 20,
JS_PER_REQUEST_TIME_LIMIT_MS: 40,
},
async () => {
const js = Buffer.from(
`
let i = 0;
while (true) {
i++;
}
return i;
`
).toString("base64")
const table = await config.createTable({
name: "table",
type: "table",
schema: {
text: {
name: "text",
type: FieldType.STRING,
},
formula: {
name: "formula",
type: FieldType.FORMULA,
formula: `{{ js "${js}"}}`,
formulaType: FormulaTypes.DYNAMIC,
},
},
})
for (let i = 0; i < 10; i++) {
await config.api.row.save(table._id!, { text: "foo" })
}
// Run this test 3 times to make sure that there's no cross-request
// pollution of the execution time tracking.
for (let reqs = 0; reqs < 3; reqs++) {
const { rows } = await config.api.row.search(table._id!)
expect(rows).toHaveLength(10)
let i = 0
for (; i < 10; i++) {
const row = rows[i]
if (row.formula !== "Timed out while executing JS") {
break
}
}
// Given the execution times are not deterministic, we can't be sure
// of the exact number of rows that were executed before the timeout
// but it should absolutely be at least 1.
expect(i).toBeGreaterThan(0)
expect(i).toBeLessThan(5)
for (; i < 10; i++) {
const row = rows[i]
expect(row.text).toBe("foo")
expect(row.formula).toBe("Request JS execution limit hit")
}
}
}
)
})
})
}) })

View File

@ -70,6 +70,11 @@ const environment = {
SELF_HOSTED: process.env.SELF_HOSTED, SELF_HOSTED: process.env.SELF_HOSTED,
HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT, HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT,
FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main", FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main",
JS_PER_EXECUTION_TIME_LIMIT_MS:
parseIntSafe(process.env.JS_PER_EXECUTION_TIME_LIMIT_MS) || 1000,
JS_PER_REQUEST_TIME_LIMIT_MS: parseIntSafe(
process.env.JS_PER_REQUEST_TIME_LIMIT_MS
),
// old // old
CLIENT_ID: process.env.CLIENT_ID, CLIENT_ID: process.env.CLIENT_ID,
_set(key: string, value: any) { _set(key: string, value: any) {

View File

@ -0,0 +1,36 @@
import vm from "vm"
import env from "./environment"
import { setJSRunner } from "@budibase/string-templates"
import { context, timers } from "@budibase/backend-core"
type TrackerFn = <T>(f: () => T) => T
export function init() {
setJSRunner((js: string, ctx: vm.Context) => {
const perRequestLimit = env.JS_PER_REQUEST_TIME_LIMIT_MS
let track: TrackerFn = f => f()
if (perRequestLimit) {
const bbCtx = context.getCurrentContext()
if (bbCtx) {
if (!bbCtx.jsExecutionTracker) {
bbCtx.jsExecutionTracker =
timers.ExecutionTimeTracker.withLimit(perRequestLimit)
}
track = bbCtx.jsExecutionTracker.track.bind(bbCtx.jsExecutionTracker)
}
}
ctx = {
...ctx,
alert: undefined,
setInterval: undefined,
setTimeout: undefined,
}
vm.createContext(ctx)
return track(() =>
vm.runInNewContext(js, ctx, {
timeout: env.JS_PER_EXECUTION_TIME_LIMIT_MS,
})
)
})
}

View File

@ -23,6 +23,7 @@ import { automationsEnabled, printFeatures } from "./features"
import Koa from "koa" import Koa from "koa"
import { Server } from "http" import { Server } from "http"
import { AddressInfo } from "net" import { AddressInfo } from "net"
import * as jsRunner from "./jsRunner"
let STARTUP_RAN = false let STARTUP_RAN = false
@ -152,4 +153,6 @@ export async function startup(app?: Koa, server?: Server) {
} }
}) })
} }
jsRunner.init()
} }

View File

@ -56,10 +56,12 @@ module.exports.processJS = (handlebars, context) => {
const res = { data: runJS(js, sandboxContext) } const res = { data: runJS(js, sandboxContext) }
return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}` return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}`
} catch (error) { } catch (error) {
console.log(`JS error: ${typeof error} ${JSON.stringify(error)}`)
if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") { if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") {
return "Timed out while executing JS" return "Timed out while executing JS"
} }
if (error.name === "ExecutionTimeoutError") {
return "Request JS execution limit hit"
}
return "Error while executing JS" return "Error while executing JS"
} }
} }

View File

@ -18,6 +18,7 @@ module.exports.doesContainString = templates.doesContainString
module.exports.disableEscaping = templates.disableEscaping module.exports.disableEscaping = templates.disableEscaping
module.exports.findHBSBlocks = templates.findHBSBlocks module.exports.findHBSBlocks = templates.findHBSBlocks
module.exports.convertToJS = templates.convertToJS module.exports.convertToJS = templates.convertToJS
module.exports.setJSRunner = templates.setJSRunner
module.exports.FIND_ANY_HBS_REGEX = templates.FIND_ANY_HBS_REGEX module.exports.FIND_ANY_HBS_REGEX = templates.FIND_ANY_HBS_REGEX
if (!process.env.NO_JS) { if (!process.env.NO_JS) {

View File

@ -9,6 +9,7 @@ const {
findDoubleHbsInstances, findDoubleHbsInstances,
} = require("./utilities") } = require("./utilities")
const { convertHBSBlock } = require("./conversion") const { convertHBSBlock } = require("./conversion")
const javascript = require("./helpers/javascript")
const hbsInstance = handlebars.create() const hbsInstance = handlebars.create()
registerAll(hbsInstance) registerAll(hbsInstance)
@ -362,6 +363,8 @@ module.exports.doesContainString = (template, string) => {
return exports.doesContainStrings(template, [string]) return exports.doesContainStrings(template, [string])
} }
module.exports.setJSRunner = javascript.setJSRunner
module.exports.convertToJS = hbs => { module.exports.convertToJS = hbs => {
const blocks = exports.findHBSBlocks(hbs) const blocks = exports.findHBSBlocks(hbs)
let js = "return `", let js = "return `",

View File

@ -1,6 +1,5 @@
import vm from "vm" import vm from "vm"
import templates from "./index.js" import templates from "./index.js"
import { setJSRunner } from "./helpers/javascript"
/** /**
* ES6 entrypoint for rollup * ES6 entrypoint for rollup
@ -20,6 +19,7 @@ export const doesContainString = templates.doesContainString
export const disableEscaping = templates.disableEscaping export const disableEscaping = templates.disableEscaping
export const findHBSBlocks = templates.findHBSBlocks export const findHBSBlocks = templates.findHBSBlocks
export const convertToJS = templates.convertToJS export const convertToJS = templates.convertToJS
export const setJSRunner = templates.setJSRunner
export const FIND_ANY_HBS_REGEX = templates.FIND_ANY_HBS_REGEX export const FIND_ANY_HBS_REGEX = templates.FIND_ANY_HBS_REGEX
if (process && !process.env.NO_JS) { if (process && !process.env.NO_JS) {