Remove the pre-execution validation in string templates to double performance and prevent JS binding issues when mutating context

This commit is contained in:
Andrew Kingston 2021-12-06 17:58:43 +00:00
parent 7b20aa31d1
commit 28557a3f96
5 changed files with 58 additions and 57 deletions

View File

@ -1,4 +1,5 @@
const { atob } = require("../utilities")
const { cloneDeep } = require("lodash/fp")
// The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.cjs or index.mjs).
@ -38,8 +39,12 @@ module.exports.processJS = (handlebars, context) => {
// This is required to allow the final `return` statement to be valid.
const js = `function run(){${atob(handlebars)}};run();`
// Our $ context function gets a value from context
const sandboxContext = { $: path => getContextValue(path, context) }
// Our $ context function gets a value from context.
// We clone the context to avoid mutation in the binding affecting real
// app context.
const sandboxContext = {
$: path => getContextValue(path, cloneDeep(context)),
}
// Create a sandbox with out context and run the JS
return runJS(js, sandboxContext)

View File

@ -1,12 +1,13 @@
const handlebars = require("handlebars")
const { registerAll } = require("./helpers/index")
const processors = require("./processors")
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
const { atob, btoa } = require("./utilities")
const manifest = require("../manifest.json")
const hbsInstance = handlebars.create()
registerAll(hbsInstance)
const hbsInstanceNoHelpers = handlebars.create()
const defaultOpts = { noHelpers: false }
/**
* utility function to check if the object is valid
@ -28,11 +29,7 @@ function testObject(object) {
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
*/
module.exports.processObject = async (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObject = async (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
if (object[key] != null) {
@ -63,11 +60,7 @@ module.exports.processObject = async (
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processString = async (
string,
context,
opts = { noHelpers: false }
) => {
module.exports.processString = async (string, context, opts) => {
// TODO: carry out any async calls before carrying out async call
return module.exports.processStringSync(string, context, opts)
}
@ -81,11 +74,7 @@ module.exports.processString = async (
* @param {object} opts optional - specify some options for processing.
* @returns {object|array} The structure input, as fully updated as possible.
*/
module.exports.processObjectSync = (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObjectSync = (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
let val = object[key]
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
* @param {object} opts optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processStringSync = (
string,
context,
opts = { noHelpers: false }
) => {
if (!exports.isValid(string)) {
return string
}
// take a copy of input incase error
module.exports.processStringSync = (string, context, opts) => {
opts = { ...defaultOpts, ...opts }
// take a copy of input in case of error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
}
try {
const noHelpers = opts && opts.noHelpers
// finalising adds a helper, can't do this with no helpers
const shouldFinalise = !noHelpers
const shouldFinalise = !opts.noHelpers
string = processors.preprocess(string, shouldFinalise)
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
const instance = noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const template = instance.compile(string, {
strict: false,
})
@ -136,7 +119,7 @@ module.exports.processStringSync = (
})
)
} catch (err) {
return removeHandlebarsStatements(input)
return input
}
}
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
* @param opts optional - specify some options for processing.
* @returns {boolean} Whether or not the input string is valid.
*/
module.exports.isValid = (string, opts = { noHelpers: false }) => {
module.exports.isValid = (string, opts) => {
opts = { ...defaultOpts, ...opts }
const validCases = [
"string",
"number",
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
// don't really need a real context to check if its valid
const context = {}
try {
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
instance.compile(processors.preprocess(string, false))(context)
return true
} catch (err) {

View File

@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
return string.slice(0, start) + swap + string.slice(start + length)
}
module.exports.removeHandlebarsStatements = string => {
module.exports.removeHandlebarsStatements = (
string,
replacement = "Invalid binding"
) => {
let regexp = new RegExp(exports.FIND_HBS_REGEX)
let matches = string.match(regexp)
if (matches == null) {
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
}
for (let match of matches) {
const idx = string.indexOf(match)
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
string = exports.swapStrings(string, idx, match.length, replacement)
}
return string
}

View File

@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
describe("test that it can run without helpers", () => {
it("should be able to run without helpers", async () => {
const output = await processString("{{ avg 1 1 1 }}", {}, { noHelpers: true })
const output = await processString(
"{{ avg 1 1 1 }}",
{},
{ noHelpers: true }
)
const valid = await processString("{{ avg 1 1 1 }}", {})
expect(valid).toBe("1")
expect(output).toBe("Invalid Binding")
expect(output).toBe("{{ avg 1 1 1 }}")
})
})
@ -185,17 +189,22 @@ describe("test the date helpers", () => {
it("should test the timezone capabilities", async () => {
const date = new Date(1611577535000)
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
time: date.toUTCString(),
})
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
const output = await processString(
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
{
time: date.toUTCString(),
}
)
const formatted = new dayjs(date)
.tz("America/New_York")
.format("HH-mm-ss Z")
expect(output).toBe(formatted)
})
it("should guess the users timezone when not specified", async () => {
const date = new Date()
const output = await processString("{{ date time 'Z' }}", {
time: date.toUTCString()
time: date.toUTCString(),
})
const timezone = dayjs.tz.guess()
const offset = new dayjs(date).tz(timezone).format("Z")
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
describe("Test the object/array helper", () => {
it("should allow plucking from an array of objects", async () => {
const context = {
items: [
{ price: 20 },
{ price: 30 },
]
items: [{ price: 20 }, { price: 30 }],
}
const output = await processString("{{ literal ( sum ( pluck items 'price' ) ) }}", context)
const output = await processString(
"{{ literal ( sum ( pluck items 'price' ) ) }}",
context
)
expect(output).toBe(50)
})
@ -442,15 +451,15 @@ describe("Cover a few complex use cases", () => {
it("should only invalidate a single string in an object", async () => {
const input = {
dataProvider:"{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
theme:"spectrum--lightest",
showAutoColumns:false,
quiet:true,
size:"spectrum--medium",
rowCount:8,
dataProvider: "{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
theme: "spectrum--lightest",
showAutoColumns: false,
quiet: true,
size: "spectrum--medium",
rowCount: 8,
}
const output = await processObject(input, tableJson)
expect(output.dataProvider).not.toBe("Invalid Binding")
expect(output.dataProvider).not.toBe("Invalid binding")
})
it("should be able to handle external ids", async () => {

View File

@ -8,7 +8,7 @@ jest.mock("nodemailer")
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
verify: jest.fn(),
})
describe("/api/global/email", () => {
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
expect(emailCall.subject).toBe("Hello!")
expect(emailCall.html).not.toContain("Invalid Binding")
expect(emailCall.html).not.toContain("Invalid binding")
})
})