@jsreport/jsreport-core
Version:
javascript based business reporting
867 lines (707 loc) • 24.7 kB
JavaScript
const os = require('os')
const util = require('util')
const path = require('path')
const extend = require('node.extend.without.arrays')
const get = require('lodash.get')
const set = require('lodash.set')
const hasOwn = require('has-own-deep')
const unsetValue = require('unset-value')
const groupBy = require('lodash.groupby')
const { VM, VMScript } = require('vm2')
const originalVM = require('vm')
const stackTrace = require('stack-trace')
const { codeFrameColumns } = require('@babel/code-frame')
module.exports = (_sandbox, options = {}) => {
const {
onLog,
formatError,
propertiesConfig = {},
globalModules = [],
allowedModules = [],
safeExecution,
requireMap
} = options
const modulesCache = options.modulesCache != null ? options.modulesCache : Object.create(null)
const _console = {}
let requirePaths = options.requirePaths || []
requirePaths = requirePaths.filter((p) => p != null).map((p) => {
if (p.endsWith('/') || p.endsWith('\\')) {
return p.slice(0, -1)
}
return p
})
// remove duplicates in paths
requirePaths = requirePaths.filter((v, i) => requirePaths.indexOf(v) === i)
function addConsoleMethod (consoleMethod, level) {
_console[consoleMethod] = function () {
if (onLog == null) {
return
}
onLog({
timestamp: new Date().getTime(),
level: level,
message: util.format.apply(util, arguments)
})
}
}
addConsoleMethod('log', 'debug')
addConsoleMethod('warn', 'warn')
addConsoleMethod('error', 'error')
const _require = function (moduleName, { context, allowAllModules = false } = {}) {
if (requireMap) {
const mapResult = requireMap(moduleName, { context })
if (mapResult != null) {
return mapResult
}
}
if (!safeExecution || allowAllModules || allowedModules === '*') {
return doRequire(moduleName, requirePaths, modulesCache)
}
const m = allowedModules.find(mod => (mod.id || mod) === moduleName)
if (m) {
return doRequire(m.path || moduleName, requirePaths, modulesCache)
}
const error = new Error(
`require of "${moduleName}" module has been blocked.`
)
if (formatError) {
formatError(error, moduleName)
}
throw error
}
for (const info of globalModules) {
// it is important to use "doRequire" function here to avoid
// getting hit by the allowed modules restriction
_sandbox[info.globalVariableName] = doRequire(info.module, requirePaths, modulesCache)
}
const propsConfig = normalizePropertiesConfigInHierarchy(propertiesConfig)
const originalValues = {}
const proxiesInVM = new WeakMap()
const customProxies = new WeakMap()
// we copy the object based on config to avoid sharing same context
// (with getters/setters) in the rest of request pipeline
const sandbox = copyBasedOnPropertiesConfig(_sandbox, propertiesConfig)
applyPropertiesConfig(sandbox, propsConfig, {
original: originalValues,
customProxies
})
Object.assign(sandbox, {
console: _console,
require: (m) => _require(m, { context: _sandbox })
})
let safeVM
let vmSandbox
if (safeExecution) {
safeVM = new VM()
// delete the vm.sandbox.global because it introduces json stringify issues
// and we don't need such global in context
delete safeVM.sandbox.global
for (const name in sandbox) {
safeVM.setGlobal(name, sandbox[name])
}
vmSandbox = safeVM.sandbox
} else {
vmSandbox = originalVM.createContext(undefined)
vmSandbox.Buffer = Buffer
for (const name in sandbox) {
vmSandbox[name] = sandbox[name]
}
}
// processing top level props because getter/setter descriptors
// for top level properties will only work after VM instantiation
Object.keys(propsConfig).forEach((key) => {
const currentConfig = propsConfig[key]
if (currentConfig.root && currentConfig.root.sandboxReadOnly) {
readOnlyProp(vmSandbox, key, [], customProxies, { onlyTopLevel: true })
}
})
const sourceFilesInfo = new Map()
return {
sandbox: vmSandbox,
console: _console,
sourceFilesInfo,
compileScript: (code, filename) => {
return doCompileScript(code, filename, safeExecution)
},
restore: () => {
return restoreProperties(vmSandbox, originalValues, proxiesInVM, customProxies)
},
sandboxRequire: (modulePath) => _require(modulePath, { context: _sandbox, allowAllModules: true }),
run: async (codeOrScript, { filename, errorLineNumberOffset = 0, source, entity, entitySet } = {}) => {
let run
if (filename != null && source != null) {
sourceFilesInfo.set(filename, { filename, source, entity, entitySet, errorLineNumberOffset })
}
const script = typeof codeOrScript !== 'string' ? codeOrScript : doCompileScript(codeOrScript, filename, safeExecution)
if (safeExecution) {
run = async () => {
return safeVM.run(script)
}
} else {
run = async () => {
return script.runInContext(vmSandbox, {
displayErrors: true
})
}
}
try {
const result = await run()
return result
} catch (e) {
decorateErrorMessage(e, sourceFilesInfo)
throw e
}
}
}
}
function doCompileScript (code, filename, safeExecution) {
let script
if (safeExecution) {
script = new VMScript(code, filename)
// NOTE: if we need to upgrade vm2 we will need to check the source of this function
// in vm2 repo and see if we need to change this,
// we needed to override this method because we want "displayErrors" to be true in order
// to show nice error when the compile of a script fails
script._compile = function (prefix, suffix) {
return new originalVM.Script(prefix + this.getCompiledCode() + suffix, {
__proto__: null,
filename: this.filename,
displayErrors: true,
lineOffset: this.lineOffset,
columnOffset: this.columnOffset,
// THIS FN WAS TAKEN FROM vm2 source, nothing special here
importModuleDynamically: () => {
// We can't throw an error object here because since vm.Script doesn't store a context, we can't properly contextify that error object.
// eslint-disable-next-line no-throw-literal
throw 'Dynamic imports are not allowed.'
}
})
}
// do the compilation
script._compileVM()
} else {
script = new originalVM.Script(code, {
filename,
displayErrors: true,
importModuleDynamically: () => {
// We can't throw an error object here because since vm.Script doesn't store a context, we can't properly contextify that error object.
// eslint-disable-next-line no-throw-literal
throw 'Dynamic imports are not allowed.'
}
})
}
return script
}
function doRequire (moduleName, requirePaths = [], modulesCache) {
const searchedPaths = []
function optimizedRequire (require, modulePath) {
// save the current module cache, we will use this to restore the cache to the
// original values after the require finish
const originalModuleCache = Object.assign(Object.create(null), require.cache)
// clean/empty the current module cache
for (const cacheKey of Object.keys(require.cache)) {
delete require.cache[cacheKey]
}
// restore any previous cache generated in the sandbox
for (const cacheKey of Object.keys(modulesCache)) {
require.cache[cacheKey] = modulesCache[cacheKey]
}
try {
const moduleExport = require.main ? require.main.require(modulePath) : require(modulePath)
require.main.children.splice(require.main.children.indexOf(m => m.id === require.resolve(modulePath)), 1)
// save the current module cache generated after the require into the internal cache,
// and clean the current module cache again
for (const cacheKey of Object.keys(require.cache)) {
modulesCache[cacheKey] = require.cache[cacheKey]
delete require.cache[cacheKey]
}
// restore the current module cache to the original cache values
for (const [oldCacheKey, value] of Object.entries(originalModuleCache)) {
require.cache[oldCacheKey] = value
}
return moduleExport
} catch (e) {
// clean the current module cache again
for (const cacheKey of Object.keys(require.cache)) {
delete require.cache[cacheKey]
}
// restore the current module cache to the original cache values
for (const [oldCacheKey, value] of Object.entries(originalModuleCache)) {
require.cache[oldCacheKey] = value
}
if (e.code && e.code === 'MODULE_NOT_FOUND') {
if (!searchedPaths.includes(modulePath)) {
searchedPaths.push(modulePath)
}
return false
} else {
throw new Error(`Unable to require module ${moduleName}. ${e.message}${os.EOL}${e.stack}`)
}
}
}
let result = optimizedRequire(require, moduleName)
if (!result) {
let pathsSearched = 0
while (!result && pathsSearched < requirePaths.length) {
result = optimizedRequire(require, path.join(requirePaths[pathsSearched], moduleName))
pathsSearched++
}
}
if (!result) {
throw new Error(`Unable to find module ${moduleName}${os.EOL}The require calls:${os.EOL}${searchedPaths.map(p => `require('${p}')`).join(os.EOL)}${os.EOL}`)
}
return result
}
function decorateErrorMessage (e, sourceFilesInfo) {
const filesCount = sourceFilesInfo.size
if (filesCount > 0) {
const trace = stackTrace.parse(e)
let suffix = ''
for (let i = 0; i < trace.length; i++) {
const current = trace[i]
if (
current.getLineNumber() == null &&
current.getColumnNumber() == null
) {
continue
}
if (
sourceFilesInfo.has(current.getFileName()) &&
current.getLineNumber() != null
) {
const { entity: entityAtFile, errorLineNumberOffset: errorLineNumberOffsetForFile } = sourceFilesInfo.get(current.getFileName())
const ln = current.getLineNumber() - errorLineNumberOffsetForFile
if (i === 0) {
if (entityAtFile != null) {
e.entity = {
shortid: entityAtFile.shortid,
name: entityAtFile.name,
content: entityAtFile.content
}
e.property = 'content'
}
e.lineNumber = ln < 0 ? null : ln
}
if (ln < 0) {
suffix += `(${current.getFileName()})`
} else {
suffix += `(${current.getFileName()} line ${ln}:${current.getColumnNumber()})`
}
}
if (
sourceFilesInfo.has(current.getFileName()) &&
current.getLineNumber() != null
) {
const source = sourceFilesInfo.get(current.getFileName()).source
const codeFrame = codeFrameColumns(source, {
// we don't check if there is column because if it returns empty value then
// the code frame is still generated normally, just without column mark
start: { line: current.getLineNumber(), column: current.getColumnNumber() }
})
if (codeFrame !== '') {
suffix += `\n\n${codeFrame}\n\n`
}
}
}
if (suffix !== '') {
suffix = `\n\n${suffix}`
e.message = `${e.message}${suffix}`
// we store the suffix we added to the message so we can use it later
// to detect if we need to strip this from the stack or not
e.decoratedSuffix = suffix
}
}
e.message = `${e.message}`
}
function getOriginalFromProxy (proxiesInVM, customProxies, value) {
let newValue
if (customProxies.has(value)) {
newValue = getOriginalFromProxy(proxiesInVM, customProxies, customProxies.get(value))
} else if (proxiesInVM.has(value)) {
newValue = getOriginalFromProxy(proxiesInVM, customProxies, proxiesInVM.get(value))
} else {
newValue = value
}
return newValue
}
function copyBasedOnPropertiesConfig (context, propertiesMap) {
const copied = []
const newContext = Object.assign({}, context)
Object.keys(propertiesMap).sort(sortPropertiesByLevel).forEach((prop) => {
const parts = prop.split('.')
const lastPartsIndex = parts.length - 1
for (let i = 0; i <= lastPartsIndex; i++) {
let currentContext = newContext
const propName = parts[i]
const parentPath = parts.slice(0, i).join('.')
const fullPropName = parts.slice(0, i + 1).join('.')
let value
if (copied.indexOf(fullPropName) !== -1) {
continue
}
if (parentPath !== '') {
currentContext = get(newContext, parentPath)
}
if (currentContext) {
value = currentContext[propName]
if (typeof value === 'object') {
if (value === null) {
value = null
} else if (Array.isArray(value)) {
value = Object.assign([], value)
} else {
value = Object.assign({}, value)
}
currentContext[propName] = value
copied.push(fullPropName)
}
}
}
})
return newContext
}
function applyPropertiesConfig (context, config, {
original,
customProxies,
isRoot = true,
isGrouped = true,
onlyReadOnlyTopLevel = false,
parentOpts,
prop
} = {}, readOnlyConfigured = []) {
let isHidden
let isReadOnly
let standalonePropertiesHandled = false
let innerPropertiesHandled = false
if (isRoot) {
return Object.keys(config).forEach((key) => {
applyPropertiesConfig(context, config[key], {
original,
customProxies,
prop: key,
isRoot: false,
isGrouped: true,
onlyReadOnlyTopLevel,
parentOpts
}, readOnlyConfigured)
})
}
if (parentOpts && parentOpts.sandboxHidden === true) {
return
}
if (isGrouped) {
isHidden = config.root ? config.root.sandboxHidden === true : false
isReadOnly = config.root ? config.root.sandboxReadOnly === true : false
} else {
isHidden = config ? config.sandboxHidden === true : false
isReadOnly = config ? config.sandboxReadOnly === true : false
}
let shouldStoreOriginal = isHidden || isReadOnly
// prevent storing original value if there is config some child prop
if (
shouldStoreOriginal &&
isGrouped &&
(config.inner != null || config.standalone != null)
) {
shouldStoreOriginal = false
}
// saving original value
if (shouldStoreOriginal) {
let exists = true
let newValue
if (hasOwn(context, prop)) {
const originalPropValue = get(context, prop)
if (typeof originalPropValue === 'object' && originalPropValue != null) {
if (Array.isArray(originalPropValue)) {
newValue = extend(true, [], originalPropValue)
} else {
newValue = extend(true, {}, originalPropValue)
}
} else {
newValue = originalPropValue
}
} else {
exists = false
}
original[prop] = {
exists,
value: newValue
}
}
const processStandAloneProperties = (c) => {
Object.keys(c.standalone).forEach((skey) => {
const sconfig = c.standalone[skey]
applyPropertiesConfig(context, sconfig, {
original,
customProxies,
prop: skey,
isRoot: false,
isGrouped: false,
onlyReadOnlyTopLevel,
parentOpts: { sandboxHidden: isHidden, sandboxReadOnly: isReadOnly }
}, readOnlyConfigured)
})
}
const processInnerProperties = (c) => {
Object.keys(c.inner).forEach((ikey) => {
const iconfig = c.inner[ikey]
applyPropertiesConfig(context, iconfig, {
original,
customProxies,
prop: ikey,
isRoot: false,
isGrouped: true,
parentOpts: { sandboxHidden: isHidden, sandboxReadOnly: isReadOnly }
}, readOnlyConfigured)
})
}
if (isHidden) {
omitProp(context, prop)
} else if (isReadOnly) {
readOnlyProp(context, prop, readOnlyConfigured, customProxies, {
onlyTopLevel: false,
onBeforeProxy: () => {
if (isGrouped && config.standalone != null) {
processStandAloneProperties(config)
standalonePropertiesHandled = true
}
if (isGrouped && config.inner != null) {
processInnerProperties(config)
innerPropertiesHandled = true
}
}
})
}
if (!isGrouped) {
return
}
// don't process inner config when the value in context is empty
if (get(context, prop) == null) {
return
}
if (!standalonePropertiesHandled && config.standalone != null) {
processStandAloneProperties(config)
}
if (!innerPropertiesHandled && config.inner != null) {
processInnerProperties(config)
}
}
function restoreProperties (context, originalValues, proxiesInVM, customProxies) {
const restored = []
const newContext = Object.assign({}, context)
Object.keys(originalValues).sort(sortPropertiesByLevel).forEach((prop) => {
const confValue = originalValues[prop]
const parts = prop.split('.')
const lastPartsIndex = parts.length - 1
for (let i = 0; i <= lastPartsIndex; i++) {
let currentContext = newContext
const propName = parts[i]
const parentPath = parts.slice(0, i).join('.')
const fullPropName = parts.slice(0, i + 1).join('.')
let value
if (restored.indexOf(fullPropName) !== -1) {
continue
}
if (parentPath !== '') {
currentContext = get(newContext, parentPath)
}
if (currentContext) {
value = currentContext[propName]
// unwrapping proxies
value = getOriginalFromProxy(proxiesInVM, customProxies, value)
if (typeof value === 'object') {
// we call object assign to be able to get rid of
// previous properties descriptors (hide/readOnly) configured
if (value === null) {
value = null
} else if (Array.isArray(value)) {
value = Object.assign([], value)
} else {
value = Object.assign({}, value)
}
currentContext[propName] = value
restored.push(fullPropName)
}
if (i === lastPartsIndex) {
if (confValue.exists) {
currentContext[propName] = confValue.value
} else {
delete currentContext[propName]
}
}
}
}
})
// unwrapping proxies for top level properties
Object.keys(newContext).forEach((prop) => {
newContext[prop] = getOriginalFromProxy(proxiesInVM, customProxies, newContext[prop])
})
return newContext
}
function omitProp (context, prop) {
// if property has value, then set it to undefined first,
// unsetValue expects that property has some non empty value to remove the property
// so we set to "true" to ensure it works for all cases,
// we use unsetValue instead of lodash.omit because
// it supports object paths x.y.z and does not copy the object for each call
if (hasOwn(context, prop)) {
set(context, prop, true)
unsetValue(context, prop)
}
}
function readOnlyProp (context, prop, configured, customProxies, { onlyTopLevel = false, onBeforeProxy } = {}) {
const parts = prop.split('.')
const lastPartsIndex = parts.length - 1
const throwError = (fullPropName) => {
throw new Error(`Can't modify read only property "${fullPropName}" inside sandbox`)
}
for (let i = 0; i <= lastPartsIndex; i++) {
let currentContext = context
const isTopLevelProp = i === 0
const propName = parts[i]
const parentPath = parts.slice(0, i).join('.')
const fullPropName = parts.slice(0, i + 1).join('.')
let value
if (configured.indexOf(fullPropName) !== -1) {
continue
}
if (parentPath !== '') {
currentContext = get(context, parentPath)
}
if (currentContext) {
value = currentContext[propName]
if (
i === lastPartsIndex &&
typeof value === 'object' &&
value != null
) {
const valueType = Array.isArray(value) ? 'array' : 'object'
const rawValue = value
if (onBeforeProxy) {
onBeforeProxy()
}
value = new Proxy(rawValue, {
set: (target, prop) => {
throw new Error(`Can't add or modify property "${prop}" to read only ${valueType} "${fullPropName}" inside sandbox`)
},
deleteProperty: (target, prop) => {
throw new Error(`Can't delete property "${prop}" in read only ${valueType} "${fullPropName}" inside sandbox`)
}
})
customProxies.set(value, rawValue)
}
// only create the getter/setter wrapper if the property is defined,
// this prevents getting errors about proxy traps and descriptors differences
// when calling `JSON.stringify(req.context)` from a script
if (Object.prototype.hasOwnProperty.call(currentContext, propName)) {
if (!configured.includes(fullPropName)) {
configured.push(fullPropName)
}
Object.defineProperty(currentContext, propName, {
get: () => value,
set: () => { throwError(fullPropName) },
enumerable: true
})
}
if (isTopLevelProp && onlyTopLevel) {
break
}
}
}
}
function sortPropertiesByLevel (a, b) {
const parts = a.split('.')
const parts2 = b.split('.')
return parts.length - parts2.length
}
function normalizePropertiesConfigInHierarchy (configMap) {
const configMapKeys = Object.keys(configMap)
const groupedKeys = groupBy(configMapKeys, (key) => {
const parts = key.split('.')
if (parts.length === 1) {
return ''
}
return parts.slice(0, -1).join('.')
})
const hierarchy = []
const hierarchyLevels = {}
// we sort to ensure that top level properties names are processed first
Object.keys(groupedKeys).sort(sortPropertiesByLevel).forEach((key) => {
if (key === '') {
hierarchy.push('')
return
}
const parts = key.split('.')
const lastIndexParts = parts.length - 1
if (parts.length === 1) {
hierarchy.push(parts[0])
hierarchyLevels[key] = {}
return
}
for (let i = 0; i < parts.length; i++) {
const currentKey = parts.slice(0, i + 1).join('.')
const indexInHierarchy = hierarchy.indexOf(currentKey)
let parentHierarchy = hierarchyLevels
if (indexInHierarchy === -1 && i === lastIndexParts) {
let parentExistsInTopLevel = false
for (let j = 0; j < i; j++) {
const segmentedKey = parts.slice(0, j + 1).join('.')
if (parentExistsInTopLevel !== true) {
parentExistsInTopLevel = hierarchy.indexOf(segmentedKey) !== -1
}
if (parentHierarchy[segmentedKey] != null) {
parentHierarchy = parentHierarchy[segmentedKey]
}
}
if (!parentExistsInTopLevel) {
hierarchy.push(key)
}
parentHierarchy[key] = {}
}
}
})
const toHierarchyConfigMap = (parentLevels) => {
return (acu, key) => {
if (key === '') {
groupedKeys[key].forEach((g) => {
acu[g] = {}
if (configMap[g] != null) {
acu[g].root = configMap[g]
}
})
return acu
}
const currentLevel = parentLevels[key]
if (acu[key] == null) {
acu[key] = {}
if (configMap[key] != null) {
// root is config that was defined in the same property
// that it is grouped
acu[key].root = configMap[key]
}
}
// standalone are properties that are direct, no groups
acu[key].standalone = groupedKeys[key].reduce((obj, stdProp) => {
// only add the property is not already grouped
if (groupedKeys[stdProp] == null) {
obj[stdProp] = configMap[stdProp]
}
return obj
}, {})
if (Object.keys(acu[key].standalone).length === 0) {
delete acu[key].standalone
}
const levelKeys = Object.keys(currentLevel)
if (levelKeys.length === 0) {
return acu
}
// inner are properties which contains other properties, groups
acu[key].inner = levelKeys.reduce(toHierarchyConfigMap(currentLevel), {})
if (Object.keys(acu[key].inner).length === 0) {
delete acu[key].inner
}
return acu
}
}
return hierarchy.reduce(toHierarchyConfigMap(hierarchyLevels), {})
}