Use vm2 for JS execution in node, and a vm polyfill for the browser. Use 2 standalone entrypoints for string-templates depending on env

This commit is contained in:
Andrew Kingston 2021-10-14 11:51:05 +01:00
parent 29659361fe
commit 01dfef735f
9 changed files with 283 additions and 252 deletions

View File

@ -24,7 +24,8 @@
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"handlebars-utils": "^1.0.6", "handlebars-utils": "^1.0.6",
"lodash": "^4.17.20" "lodash": "^4.17.20",
"vm2": "^3.9.4"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-commonjs": "^17.1.0", "@rollup/plugin-commonjs": "^17.1.0",

View File

@ -7,7 +7,15 @@ import globals from "rollup-plugin-node-globals"
const production = !process.env.ROLLUP_WATCH const production = !process.env.ROLLUP_WATCH
const plugins = [ export default [
{
input: "src/index.mjs",
output: {
sourcemap: !production,
format: "esm",
file: "./dist/bundle.mjs",
},
plugins: [
resolve({ resolve({
preferBuiltins: true, preferBuiltins: true,
browser: true, browser: true,
@ -17,28 +25,6 @@ const plugins = [
builtins(), builtins(),
json(), json(),
production && terser(), production && terser(),
] ],
export default [
{
input: "src/index.mjs",
output: {
sourcemap: !production,
format: "esm",
file: "./dist/bundle.mjs",
}, },
plugins,
},
// This is the valid configuration for a CommonJS bundle, but since we have
// no use for this, it's better to leave it out.
// {
// input: "src/index.cjs",
// output: {
// sourcemap: !production,
// format: "cjs",
// file: "./dist/bundle.cjs",
// exports: "named",
// },
// plugins,
// },
] ]

View File

@ -1,5 +1,9 @@
const CAPTURE_JS = new RegExp(/{{ js "(.*)" }}/) const { atob } = require("../utilities")
const vm = require("vm")
// 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).
let runJS
module.exports.setJSRunner = runner => (runJS = runner)
// Helper utility to strip square brackets from a value // Helper utility to strip square brackets from a value
const removeSquareBrackets = value => { const removeSquareBrackets = value => {
@ -27,23 +31,8 @@ const getContextValue = (path, context) => {
return data return data
} }
// Node polyfill for base64 encoding
const btoa = plainText => {
return Buffer.from(plainText, "utf-8").toString("base64")
}
// Node polyfill for base64 decoding
const atob = base64 => {
return Buffer.from(base64, "base64").toString("utf-8")
}
// Evaluates JS code against a certain context // Evaluates JS code against a certain context
module.exports.processJS = (handlebars, context) => { module.exports.processJS = (handlebars, context) => {
// Do not evaluate JS in a node environment
if (typeof window === "undefined") {
return "JS bindings are not executed in a Node environment"
}
try { try {
// Wrap JS in a function and immediately invoke it. // Wrap JS in a function and immediately invoke it.
// This is required to allow the final `return` statement to be valid. // This is required to allow the final `return` statement to be valid.
@ -58,37 +47,8 @@ module.exports.processJS = (handlebars, context) => {
} }
// Create a sandbox with out context and run the JS // Create a sandbox with out context and run the JS
vm.createContext(sandboxContext) return runJS(js, sandboxContext)
return vm.runInNewContext(js, sandboxContext, { timeout: 1000 })
} catch (error) { } catch (error) {
return "Error while executing JS" return "Error while executing JS"
} }
} }
// Checks if a HBS expression is a valid JS HBS expression
module.exports.isJSBinding = handlebars => {
return module.exports.decodeJSBinding(handlebars) != null
}
// Encodes a raw JS string as a JS HBS expression
module.exports.encodeJSBinding = javascript => {
return `{{ js "${btoa(javascript)}" }}`
}
// Decodes a JS HBS expression to the raw JS string
module.exports.decodeJSBinding = handlebars => {
if (!handlebars || typeof handlebars !== "string") {
return null
}
// JS is only valid if it is the only HBS expression
if (!handlebars.trim().startsWith("{{ js ")) {
return null
}
const match = handlebars.match(CAPTURE_JS)
if (!match || match.length < 2) {
return null
}
return atob(match[1])
}

View File

@ -1,169 +1,28 @@
const handlebars = require("handlebars") const { VM } = require("vm2")
const { registerAll } = require("./helpers/index") const templates = require("./index.js")
const processors = require("./processors") const { setJSRunner } = require("./helpers/javascript")
const { removeHandlebarsStatements } = require("./utilities")
const manifest = require("../manifest.json")
const JS = require("./helpers/javascript")
const hbsInstance = handlebars.create()
registerAll(hbsInstance)
/** /**
* utility function to check if the object is valid * CJS entrypoint for rollup
*/ */
function testObject(object) { module.exports.isValid = templates.isValid
// JSON stringify will fail if there are any cycles, stops infinite recursion module.exports.makePropSafe = templates.makePropSafe
try { module.exports.getManifest = templates.getManifest
JSON.stringify(object) module.exports.isJSBinding = templates.isJSBinding
} catch (err) { module.exports.encodeJSBinding = templates.encodeJSBinding
throw "Unable to process inputs to JSON, cannot recurse" module.exports.decodeJSBinding = templates.decodeJSBinding
} module.exports.processStringSync = templates.processStringSync
} module.exports.processObjectSync = templates.processObjectSync
module.exports.processString = templates.processString
module.exports.processObject = templates.processObject
/** /**
* Given an input object this will recurse through all props to try and update any handlebars statements within. * Use vm2 to run JS scripts in a node env
* @param {object|array} object The input structure which is to be recursed, it is important to note that
* if the structure contains any cycles then this will fail.
* @param {object} context The context that handlebars should fill data from.
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
*/ */
module.exports.processObject = async (object, context) => { setJSRunner((js, context) => {
testObject(object) const vm = new VM({
for (let key of Object.keys(object || {})) { sandbox: context,
if (object[key] != null) { timeout: 1000
let val = object[key] })
if (typeof val === "string") { return vm.run(js)
object[key] = await module.exports.processString(object[key], context)
} else if (typeof val === "object") {
object[key] = await module.exports.processObject(object[key], context)
}
}
}
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processString = async (string, context) => {
// TODO: carry out any async calls before carrying out async call
return module.exports.processStringSync(string, context)
}
/**
* Given an input object this will recurse through all props to try and update any handlebars statements within. This is
* a pure sync call and therefore does not have the full functionality of the async call.
* @param {object|array} object The input structure which is to be recursed, it is important to note that
* if the structure contains any cycles then this will fail.
* @param {object} context The context that handlebars should fill data from.
* @returns {object|array} The structure input, as fully updated as possible.
*/
module.exports.processObjectSync = (object, context) => {
testObject(object)
for (let key of Object.keys(object || {})) {
let val = object[key]
if (typeof val === "string") {
object[key] = module.exports.processStringSync(object[key], context)
} else if (typeof val === "object") {
object[key] = module.exports.processObjectSync(object[key], context)
}
}
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processStringSync = (string, context) => {
if (!exports.isValid(string)) {
return string
}
// take a copy of input incase error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
}
try {
string = processors.preprocess(string)
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
const template = hbsInstance.compile(string, {
strict: false,
}) })
return processors.postprocess(template({
now: new Date().toISOString(),
...context,
}))
} catch (err) {
return removeHandlebarsStatements(input)
}
}
/**
* Simple utility function which makes sure that a templating property has been wrapped in literal specifiers correctly.
* @param {string} property The property which is to be wrapped.
* @returns {string} The wrapped property ready to be added to a templating string.
*/
module.exports.makePropSafe = property => {
return `[${property}]`.replace("[[", "[").replace("]]", "]")
}
/**
* Checks whether or not a template string contains totally valid syntax (simply tries running it)
* @param string The string to test for valid syntax - this may contain no templates and will be considered valid.
* @returns {boolean} Whether or not the input string is valid.
*/
module.exports.isValid = string => {
const validCases = [
"string",
"number",
"object",
"array",
"cannot read property",
"undefined",
]
// this is a portion of a specific string always output by handlebars in the case of a syntax error
const invalidCases = [`expecting '`]
// don't really need a real context to check if its valid
const context = {}
try {
hbsInstance.compile(processors.preprocess(string, false))(context)
return true
} catch (err) {
const msg = err && err.message ? err.message : err
if (!msg) {
return false
}
const invalidCase = invalidCases.some(invalidCase =>
msg.toLowerCase().includes(invalidCase)
)
const validCase = validCases.some(validCase =>
msg.toLowerCase().includes(validCase)
)
// special case for maths functions - don't have inputs yet
return validCase && !invalidCase
}
}
/**
* We have generated a static manifest file from the helpers that this string templating package makes use of.
* This manifest provides information about each of the helpers and how it can be used.
* @returns The manifest JSON which has been generated from the helpers.
*/
module.exports.getManifest = () => {
return manifest
}
/**
* Export utilities for working with JS bindings
*/
module.exports.isJSBinding = JS.isJSBinding
module.exports.decodeJSBinding = JS.decodeJSBinding
module.exports.encodeJSBinding = JS.encodeJSBinding

View File

@ -0,0 +1,204 @@
const handlebars = require("handlebars")
const { registerAll } = require("./helpers/index")
const processors = require("./processors")
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
const manifest = require("../manifest.json")
const hbsInstance = handlebars.create()
registerAll(hbsInstance)
/**
* utility function to check if the object is valid
*/
function testObject(object) {
// JSON stringify will fail if there are any cycles, stops infinite recursion
try {
JSON.stringify(object)
} catch (err) {
throw "Unable to process inputs to JSON, cannot recurse"
}
}
/**
* Given an input object this will recurse through all props to try and update any handlebars statements within.
* @param {object|array} object The input structure which is to be recursed, it is important to note that
* if the structure contains any cycles then this will fail.
* @param {object} context The context that handlebars should fill data from.
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
*/
module.exports.processObject = async (object, context) => {
testObject(object)
for (let key of Object.keys(object || {})) {
if (object[key] != null) {
let val = object[key]
if (typeof val === "string") {
object[key] = await module.exports.processString(object[key], context)
} else if (typeof val === "object") {
object[key] = await module.exports.processObject(object[key], context)
}
}
}
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processString = async (string, context) => {
// TODO: carry out any async calls before carrying out async call
return module.exports.processStringSync(string, context)
}
/**
* Given an input object this will recurse through all props to try and update any handlebars statements within. This is
* a pure sync call and therefore does not have the full functionality of the async call.
* @param {object|array} object The input structure which is to be recursed, it is important to note that
* if the structure contains any cycles then this will fail.
* @param {object} context The context that handlebars should fill data from.
* @returns {object|array} The structure input, as fully updated as possible.
*/
module.exports.processObjectSync = (object, context) => {
testObject(object)
for (let key of Object.keys(object || {})) {
let val = object[key]
if (typeof val === "string") {
object[key] = module.exports.processStringSync(object[key], context)
} else if (typeof val === "object") {
object[key] = module.exports.processObjectSync(object[key], context)
}
}
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processStringSync = (string, context) => {
if (!exports.isValid(string)) {
return string
}
// take a copy of input incase error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
}
try {
string = processors.preprocess(string)
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
const template = hbsInstance.compile(string, {
strict: false,
})
return processors.postprocess(
template({
now: new Date().toISOString(),
...context,
})
)
} catch (err) {
return removeHandlebarsStatements(input)
}
}
/**
* Simple utility function which makes sure that a templating property has been wrapped in literal specifiers correctly.
* @param {string} property The property which is to be wrapped.
* @returns {string} The wrapped property ready to be added to a templating string.
*/
module.exports.makePropSafe = property => {
return `[${property}]`.replace("[[", "[").replace("]]", "]")
}
/**
* Checks whether or not a template string contains totally valid syntax (simply tries running it)
* @param string The string to test for valid syntax - this may contain no templates and will be considered valid.
* @returns {boolean} Whether or not the input string is valid.
*/
module.exports.isValid = string => {
const validCases = [
"string",
"number",
"object",
"array",
"cannot read property",
"undefined",
]
// this is a portion of a specific string always output by handlebars in the case of a syntax error
const invalidCases = [`expecting '`]
// don't really need a real context to check if its valid
const context = {}
try {
hbsInstance.compile(processors.preprocess(string, false))(context)
return true
} catch (err) {
const msg = err && err.message ? err.message : err
if (!msg) {
return false
}
const invalidCase = invalidCases.some(invalidCase =>
msg.toLowerCase().includes(invalidCase)
)
const validCase = validCases.some(validCase =>
msg.toLowerCase().includes(validCase)
)
// special case for maths functions - don't have inputs yet
return validCase && !invalidCase
}
}
/**
* We have generated a static manifest file from the helpers that this string templating package makes use of.
* This manifest provides information about each of the helpers and how it can be used.
* @returns The manifest JSON which has been generated from the helpers.
*/
module.exports.getManifest = () => {
return manifest
}
/**
* Checks if a HBS expression is a valid JS HBS expression
* @param handlebars the HBS expression to check
* @returns {boolean} whether the expression is JS or not
*/
module.exports.isJSBinding = handlebars => {
return module.exports.decodeJSBinding(handlebars) != null
}
/**
* Encodes a raw JS string as a JS HBS expression
* @param javascript the JS code to encode
* @returns {string} the JS HBS expression
*/
module.exports.encodeJSBinding = javascript => {
return `{{ js "${btoa(javascript)}" }}`
}
/**
* Decodes a JS HBS expression to the raw JS code
* @param handlebars the JS HBS expression
* @returns {string|null} the raw JS code
*/
module.exports.decodeJSBinding = handlebars => {
if (!handlebars || typeof handlebars !== "string") {
return null
}
// JS is only valid if it is the only HBS expression
if (!handlebars.trim().startsWith("{{ js ")) {
return null
}
const captureJSRegex = new RegExp(/{{ js "(.*)" }}/)
const match = handlebars.match(captureJSRegex)
if (!match || match.length < 2) {
return null
}
return atob(match[1])
}

View File

@ -1,7 +1,9 @@
import templates from "./index.cjs" import vm from "vm"
import templates from "./index.js"
import { setJSRunner } from "./helpers/javascript"
/** /**
* This file is simply an entrypoint for rollup - makes a lot of cjs problems go away * ES6 entrypoint for rollup
*/ */
export const isValid = templates.isValid export const isValid = templates.isValid
export const makePropSafe = templates.makePropSafe export const makePropSafe = templates.makePropSafe
@ -13,3 +15,11 @@ export const processStringSync = templates.processStringSync
export const processObjectSync = templates.processObjectSync export const processObjectSync = templates.processObjectSync
export const processString = templates.processString export const processString = templates.processString
export const processObject = templates.processObject export const processObject = templates.processObject
/**
* Use polyfilled vm to run JS scripts in a browser Env
*/
setJSRunner((js, context) => {
vm.createContext(context)
return vm.runInNewContext(js, context, { timeout: 1000 })
})

View File

@ -22,3 +22,11 @@ module.exports.removeHandlebarsStatements = string => {
} }
return string return string
} }
module.exports.btoa = plainText => {
return Buffer.from(plainText, "utf-8").toString("base64")
}
module.exports.atob = base64 => {
return Buffer.from(base64, "base64").toString("utf-8")
}

View File

@ -4,21 +4,7 @@ const processJS = (js, context) => {
return processStringSync(encodeJSBinding(js), context) return processStringSync(encodeJSBinding(js), context)
} }
describe("Test the JavaScript helper in Node", () => {
it("should not execute JS in Node", () => {
const output = processJS(`return 1`)
expect(output).toBe("JS bindings are not executed in a Node environment")
})
})
describe("Test the JavaScript helper", () => { describe("Test the JavaScript helper", () => {
// JS bindings do not get evaluated on the server for safety.
// Since we want to run SJ for tests, we fake a window object to make
// it think that we're in the browser
beforeEach(() => {
window = {}
})
it("should execute a simple expression", () => { it("should execute a simple expression", () => {
const output = processJS(`return 1 + 2`) const output = processJS(`return 1 + 2`)
expect(output).toBe("3") expect(output).toBe("3")
@ -84,4 +70,16 @@ describe("Test the JavaScript helper", () => {
const output = processJS(`while (true) {}`) const output = processJS(`while (true) {}`)
expect(output).toBe("Error while executing JS") expect(output).toBe("Error while executing JS")
}) })
it("should prevent access to the process global", () => {
const output = processJS(`return process`)
expect(output).toBe("Error while executing JS")
})
it("should prevent sandbox escape", () => {
const output = processJS(
`return this.constructor.constructor("return process")()`
)
expect(output).toBe("Error while executing JS")
})
}) })

View File

@ -4572,6 +4572,11 @@ vlq@^0.2.2:
resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26" resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow== integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
vm2@^3.9.4:
version "3.9.4"
resolved "https://registry.yarnpkg.com/vm2/-/vm2-3.9.4.tgz#2e118290fefe7bd8ea09ebe2f5faf53730dbddaa"
integrity sha512-sOdharrJ7KEePIpHekiWaY1DwgueuiBeX/ZBJUPgETsVlJsXuEx0K0/naATq2haFvJrvZnRiORQRubR0b7Ye6g==
w3c-hr-time@^1.0.2: w3c-hr-time@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"