Add tests for per-request execution timeout.

This commit is contained in:
Sam Rose 2023-12-18 17:01:56 +00:00
parent bd324f3225
commit 1c34147357
No known key found for this signature in database
4 changed files with 125 additions and 11 deletions

View File

@ -21,6 +21,10 @@ export function cleanup() {
intervals = [] intervals = []
} }
export class ExecutionTimeoutError extends Error {
public readonly name = "ExecutionTimeoutError"
}
export class ExecutionTimeTracker { export class ExecutionTimeTracker {
static withLimit(limitMs: number) { static withLimit(limitMs: number) {
return new ExecutionTimeTracker(limitMs) return new ExecutionTimeTracker(limitMs)
@ -31,6 +35,7 @@ export class ExecutionTimeTracker {
private totalTimeMs = 0 private totalTimeMs = 0
track<T>(f: () => T): T { track<T>(f: () => T): T {
this.checkLimit()
const [startSeconds, startNanoseconds] = process.hrtime() const [startSeconds, startNanoseconds] = process.hrtime()
const startMs = startSeconds * 1000 + startNanoseconds / 1e6 const startMs = startSeconds * 1000 + startNanoseconds / 1e6
try { try {
@ -38,15 +43,14 @@ export class ExecutionTimeTracker {
} finally { } finally {
const [endSeconds, endNanoseconds] = process.hrtime() const [endSeconds, endNanoseconds] = process.hrtime()
const endMs = endSeconds * 1000 + endNanoseconds / 1e6 const endMs = endSeconds * 1000 + endNanoseconds / 1e6
this.increment(endMs - startMs) this.totalTimeMs += endMs - startMs
this.checkLimit()
} }
} }
private increment(byMs: number) { private checkLimit() {
this.totalTimeMs += byMs
if (this.totalTimeMs > this.limitMs) { if (this.totalTimeMs > this.limitMs) {
throw new Error( throw new ExecutionTimeoutError(
`Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms` `Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms`
) )
} }

View File

@ -2086,4 +2086,108 @@ 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: 100 },
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: 100,
JS_PER_REQUEST_TIME_LIMIT_MS: 200,
},
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" })
}
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
}
}
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

@ -8,12 +8,16 @@ type TrackerFn = <T>(f: () => T) => T
export function init() { export function init() {
setJSRunner((js: string, ctx: vm.Context) => { setJSRunner((js: string, ctx: vm.Context) => {
const perRequestLimit = env.JS_PER_REQUEST_TIME_LIMIT_MS const perRequestLimit = env.JS_PER_REQUEST_TIME_LIMIT_MS
const bbCtx = context.getCurrentContext()
let track: TrackerFn = f => f() let track: TrackerFn = f => f()
if (perRequestLimit && bbCtx && !bbCtx.jsExecutionTracker) { if (perRequestLimit) {
bbCtx.jsExecutionTracker = const bbCtx = context.getCurrentContext()
timers.ExecutionTimeTracker.withLimit(perRequestLimit) if (bbCtx) {
track = bbCtx.jsExecutionTracker.track if (!bbCtx.jsExecutionTracker) {
bbCtx.jsExecutionTracker =
timers.ExecutionTimeTracker.withLimit(perRequestLimit)
}
track = bbCtx.jsExecutionTracker.track.bind(bbCtx.jsExecutionTracker)
}
} }
ctx = { ctx = {

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"
} }
} }