lavamoat-core
Version:
LavaMoat kernel and utils
802 lines (762 loc) • 25.8 kB
JavaScript
// @ts-check
/**
* Utilities for generating the endowments object based on a `globalRef` and a
* {@link LMPolicy.PackagePolicy}.
*
* The contents of this file will be copied into the prelude template this
* module has been written so that it required directly or copied and added to
* the template with a small wrapper.
*
* The `PackagePolicy` uses a period-deliminated path notation to pull out deep
* values from objects. These utilities help create an object populated with
* only the deep properties specified in the `PackagePolicy`.
*
* @packageDocumentation
*/
/**
* WARNING: This module is used directly by the runtime in webpack plugin which
* uses simple shimming to assemble modules. It doesn't bundle properly. This
* file cannot reqire any files or packages.
*/
module.exports = endowmentsToolkit
// Exports for testing
module.exports._test = { instrumentDynamicValueAtPath }
/**
* Returns a compy of endowmentsToolkit initialized on provided configuration.
*
* @param {object} opts
* @param {DefaultWrapperFn} [opts.createFunctionWrapper]
* @param {boolean} [opts.handleGlobalWrite]
* @param {Set<string>} [opts.knownWritableFields] - List of globals that can be
* mutated later
*/
function endowmentsToolkit({
createFunctionWrapper = defaultCreateFunctionWrapper,
handleGlobalWrite = false,
knownWritableFields = new Set(),
} = {}) {
return {
// public API
getEndowmentsForConfig,
copyWrappedGlobals,
getBuiltinForConfig,
createFunctionWrapper,
// internals exposed for core
// TODO: hide eventually?
makeMinimalViewOfRef,
copyValueAtPath,
applyGetSetPropDescTransforms,
applyEndowmentPropDescTransforms,
}
/**
* Creates an object populated with only the deep properties specified in the
* packagePolicy
*
* @template {object} T Deep properties specified in the packagePolicy
* @param {T} sourceRef - Object from which to copy properties
* @param {LMPolicy.PackagePolicy} packagePolicy - LavaMoat policy item
* representing a package
* @param {object} unwrapTo - For getters and setters, when the this-value is
* unwrapFrom, is replaced as unwrapTo
* @param {object} unwrapFrom - For getters and setters, the this-value to
* replace (default: targetRef)
* @returns {Partial<T>} - The targetRef
*/
function getEndowmentsForConfig(
sourceRef,
packagePolicy,
unwrapTo,
unwrapFrom
) {
if (!packagePolicy.globals) {
return {}
}
// validate read access from packagePolicy
/** @type {string[]} */
const whitelistedReads = []
/** @type {Set<string>} */
const allowedWriteFields = new Set()
/** @type {string[]} */
const explicitlyBanned = []
Object.entries(packagePolicy.globals).forEach(
([path, packagePolicyValue]) => {
const pathParts = path.split('.')
// disallow dunder proto in path
const pathContainsDunderProto = pathParts.some(
(pathPart) => pathPart === '__proto__'
)
if (pathContainsDunderProto) {
throw new Error(
`Lavamoat - "__proto__" disallowed when creating minimal view. saw "${path}"`
)
}
// false means no access. It's necessary so that overrides can also be used to tighten the policy
if (packagePolicyValue === false) {
explicitlyBanned.push(path)
return
}
// write access handled elsewhere
if (packagePolicyValue === 'write') {
if (!handleGlobalWrite) {
return
}
if (pathParts.length > 1) {
throw new Error(
`LavaMoat - write access is only allowed at the top level, saw "${path}"`
)
}
allowedWriteFields.add(path)
whitelistedReads.push(path)
return
}
if (packagePolicyValue !== true) {
throw new Error(
`LavaMoat - unrecognizable policy value (${typeof packagePolicyValue}) for path "${path}"`
)
}
whitelistedReads.push(path)
}
)
// sort by length to optimize further steps
whitelistedReads.sort((a, b) => a.length - b.length)
return makeMinimalViewOfRef(
sourceRef,
whitelistedReads,
unwrapTo,
unwrapFrom,
explicitlyBanned,
allowedWriteFields
)
}
/**
* Creates an object populated with only the deep properties specified by the
* paths array.
*
* @template {object} T
* @param {T} sourceRef
* @param {string[]} paths
* @param {object} [unwrapTo]
* @param {object} [unwrapFrom]
* @param {string[]} [explicitlyBanned]
* @param {Set<string>} [allowedWriteFields]
* @returns {Partial<T>}
*/
function makeMinimalViewOfRef(
sourceRef,
paths,
unwrapTo,
unwrapFrom,
explicitlyBanned = [],
allowedWriteFields = new Set()
) {
/** @type {object} */
const targetRef = {}
paths.forEach((path) => {
const pathParts = path.split('.')
if (knownWritableFields.has(pathParts[0])) {
if (allowedWriteFields.has(pathParts[0])) {
makeWritableValueAtPath(pathParts[0], sourceRef, targetRef)
} else {
instrumentDynamicValueAtPath(pathParts, sourceRef, targetRef)
}
} else {
copyValueAtPath(
'',
pathParts,
explicitlyBanned,
sourceRef,
targetRef,
unwrapTo,
unwrapFrom
)
}
})
return targetRef
}
/**
* Creates an object populated with only the deep properties specified in the
* packagePolicy for builtins.
*
* @template {object} T
* @param {T} moduleNamespace
* @param {string} moduleId
* @param {LMPolicy.BuiltinPolicy} policyBuiltin
* @returns {Partial<T>}
*/
function getBuiltinForConfig(moduleNamespace, moduleId, policyBuiltin) {
/** @type {string[]} */
const builtinPaths = []
/** @type {string[]} */
const explicitlyBanned = []
// Collect the same paths information as getEndowmentsForConfig to enable
// matching behavior of policy between globals and builtins
Object.entries(policyBuiltin).forEach(([packagePath, allowed]) => {
const packagePathParts = packagePath.split('.')
if (moduleId === packagePathParts[0]) {
const packagePathWithoutPackage = packagePathParts.slice(1).join('.')
if (allowed === true) {
builtinPaths.push(packagePathWithoutPackage)
} else if (allowed === false) {
explicitlyBanned.push(packagePathWithoutPackage)
}
}
})
const moduleNamespaceView = makeMinimalViewOfRef(
moduleNamespace,
builtinPaths.sort(),
undefined,
undefined,
explicitlyBanned
)
return moduleNamespaceView
}
/**
* @type {CopyValueAtPath}
*/
function copyValueAtPath(
visitedPath,
pathParts,
explicitlyBanned,
sourceRef,
targetRef,
unwrapTo = sourceRef,
unwrapFrom = targetRef
) {
if (pathParts.length === 0) {
throw new Error('unable to copy, must have pathParts, was empty')
}
const [nextPart, ...remainingParts] = pathParts
const currentPath = extendPath(visitedPath, nextPart)
// get the property from any depth in the property chain
const { prop: sourcePropDesc } = getPropertyDescriptorDeep(
sourceRef,
nextPart
)
// if source missing the value to copy, just skip it
if (isEmpty(sourcePropDesc)) {
return
}
// if target already has a value, it must be extensible
const targetPropDesc = Reflect.getOwnPropertyDescriptor(targetRef, nextPart)
if (targetPropDesc) {
// dont attempt to extend a getter or trigger a setter
if (!('value' in targetPropDesc)) {
throw new Error(
`unable to copy on to targetRef, targetRef has a getter at "${nextPart}"`
)
}
// value must be extensible (cant write properties onto it)
const targetValue = targetPropDesc.value
const valueType = typeof targetValue
if (valueType !== 'object' && valueType !== 'function') {
throw new Error(
`unable to copy on to targetRef, targetRef value is not an obj or func "${nextPart}"`
)
}
}
// if this is not the last path in the assignment, walk into the containing reference
if (remainingParts.length > 0) {
const { sourceValue, sourceWritable } = getSourceValue(sourcePropDesc)
const nextSourceRef = sourceValue
let nextTargetRef
// check if value exists on target and does not need selective treatment
if (targetPropDesc && !explicitlyBanned.includes(currentPath)) {
// a value already exists, we should walk into it
nextTargetRef = targetPropDesc.value
} else {
// its not populated so lets write to it
// put an object to serve as a container
const containerRef = {}
const newPropDesc = {
value: containerRef,
writable: sourceWritable,
enumerable: sourcePropDesc.enumerable,
configurable: sourcePropDesc.configurable,
}
Reflect.defineProperty(targetRef, nextPart, newPropDesc)
// the newly created container will be the next target
nextTargetRef = containerRef
}
copyValueAtPath(
currentPath,
remainingParts,
explicitlyBanned,
nextSourceRef,
nextTargetRef
)
return
}
// If conflicting rules exist, opt for the negative one. This should never happen
if (explicitlyBanned.includes(currentPath)) {
console.warn(`LavaMoat - conflicting rules exist for "${currentPath}"`)
return
}
// this is the last part of the path, the value we're trying to actually copy
// if has getter/setter - apply this-value unwrapping
if (!('value' in sourcePropDesc)) {
// wrapper setter/getter with correct receiver
const wrapperPropDesc = applyGetSetPropDescTransforms(
sourcePropDesc,
unwrapFrom,
unwrapTo
)
Reflect.defineProperty(targetRef, nextPart, wrapperPropDesc)
return
}
// need to determine the value type in order to copy it with
// this-value unwrapping support
const { sourceValue, sourceWritable } = getSourceValue(sourcePropDesc)
// not a function - copy as is
if (typeof sourceValue !== 'function') {
Reflect.defineProperty(targetRef, nextPart, sourcePropDesc)
return
}
// otherwise add workaround for functions to swap back to the sourceal "this" reference
/**
* @template T
* @param {T} thisValue
* @returns {thisValue is typeof unwrapFrom}
*/
const unwrapTest = (thisValue) => thisValue === unwrapFrom
const newValue = createFunctionWrapper(sourceValue, unwrapTest, unwrapTo)
const newPropDesc = {
value: newValue,
writable: sourceWritable,
enumerable: sourcePropDesc.enumerable,
configurable: sourcePropDesc.configurable,
}
Reflect.defineProperty(targetRef, nextPart, newPropDesc)
/**
* @param {TypedPropertyDescriptor<any>} sourcePropDesc
* @returns {{ sourceValue: any; sourceWritable: boolean | undefined }}
*/
function getSourceValue(sourcePropDesc) {
// determine the source value, this coerces getters to values
// im deeply sorry, respecting getters was complicated and
// my brain is not very good
let sourceValue, sourceWritable
if ('value' in sourcePropDesc) {
sourceValue = sourcePropDesc.value
sourceWritable = sourcePropDesc.writable
} else if ('get' in sourcePropDesc && sourcePropDesc.get) {
sourceValue = sourcePropDesc.get.call(unwrapTo)
sourceWritable = 'set' in sourcePropDesc
} else {
throw new Error(
'getEndowmentsForConfig - property descriptor missing a getter'
)
}
return { sourceValue, sourceWritable }
}
}
/**
* @type {ApplyEndowmentPropDescTransforms}
*/
function applyEndowmentPropDescTransforms(
propDesc,
unwrapFromCompartmentGlobalThis,
unwrapToGlobalThis
) {
let newPropDesc = propDesc
newPropDesc = applyFunctionPropDescTransform(
newPropDesc,
unwrapFromCompartmentGlobalThis,
unwrapToGlobalThis
)
newPropDesc = applyGetSetPropDescTransforms(
newPropDesc,
unwrapFromCompartmentGlobalThis,
unwrapToGlobalThis
)
return newPropDesc
}
/**
* @type {ApplyGetSetPropDescTransforms}
*/
function applyGetSetPropDescTransforms(
sourcePropDesc,
unwrapFromGlobalThis,
unwrapToGlobalThis
) {
const wrappedPropDesc = { ...sourcePropDesc }
if (sourcePropDesc.get) {
wrappedPropDesc.get = function () {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const receiver = this
// replace the "receiver" value if it points to fake parent
const receiverRef =
receiver === unwrapFromGlobalThis ? unwrapToGlobalThis : receiver
// sometimes getters replace themselves with static properties, as seen wih the FireFox runtime
const result = Reflect.apply(
/** @type {NonNullable<typeof sourcePropDesc.get>} */ (
sourcePropDesc.get
),
receiverRef,
[]
)
if (typeof result === 'function') {
// functions must be wrapped to ensure a good this-value.
// lockdown causes some propDescs to go to value -> getter,
// eg "Function.prototype.bind". we need to wrap getter results
// as well in order to ensure they have their this-value wrapped correctly
// if this ends up being problematic we can maybe take advantage of lockdown's
// "getter.originalValue" property being available
return createFunctionWrapper(
result,
/**
* @param {any} thisValue
* @returns {thisValue is typeof unwrapFromGlobalThis}
*/
(thisValue) => thisValue === unwrapFromGlobalThis,
unwrapToGlobalThis
)
} else {
return result
}
}
}
if (sourcePropDesc.set) {
wrappedPropDesc.set = function (value) {
// replace the "receiver" value if it points to fake parent
// eslint-disable-next-line @typescript-eslint/no-this-alias
const receiver = this
const receiverRef =
receiver === unwrapFromGlobalThis ? unwrapToGlobalThis : receiver
return Reflect.apply(
/** @type {(v: any) => void} */ (sourcePropDesc.set),
receiverRef,
[value]
)
}
}
return wrappedPropDesc
}
/**
* Utility function used by copyWrappedGlobals to wrap a function.
*
* @param {PropertyDescriptor} propDesc
* @param {object} unwrapFromCompartmentGlobalThis
* @param {object} unwrapToGlobalThis
* @returns {PropertyDescriptor}
*/
function applyFunctionPropDescTransform(
propDesc,
unwrapFromCompartmentGlobalThis,
unwrapToGlobalThis
) {
if (!('value' in propDesc && typeof propDesc.value === 'function')) {
return propDesc
}
/**
* @param {any} thisValue
* @returns {thisValue is typeof unwrapFromCompartmentGlobalThis}
*/
const unwrapTest = (thisValue) => {
// unwrap function calls this-value to unwrapToGlobalThis when:
// this value is globalThis ex. globalThis.abc()
// scope proxy leak workaround ex. abc()
return thisValue === unwrapFromCompartmentGlobalThis
}
const newFn = createFunctionWrapper(
propDesc.value,
unwrapTest,
unwrapToGlobalThis
)
return { ...propDesc, value: newFn }
}
/**
* @param {object | null} target
* @param {PropertyKey} key
* @returns {{ prop: PropertyDescriptor | null; receiver: object | null }}
*/
function getPropertyDescriptorDeep(target, key) {
/** @type {object | null} */
let receiver = target
// eslint-disable-next-line no-constant-condition
while (true) {
// abort if this is the end of the prototype chain.
if (!receiver) {
return { prop: null, receiver: null }
}
// support lookup on objects and primitives
const typeofReceiver = typeof receiver
if (typeofReceiver === 'object' || typeofReceiver === 'function') {
const prop = Reflect.getOwnPropertyDescriptor(receiver, key)
if (prop) {
return { receiver, prop }
}
// try next in the prototype chain
receiver = Reflect.getPrototypeOf(receiver)
} else {
// prototype lookup for primitives
// eslint-disable-next-line no-proto
receiver = /** @type {any} */ (receiver).__proto__
}
}
}
/**
* @type {CopyWrappedGlobals}
*/
function copyWrappedGlobals(
globalRef,
target,
globalThisRefs = ['globalThis']
) {
// find the relevant endowment sources
const globalProtoChain = getPrototypeChain(globalRef)
// the index for the common prototypal ancestor, Object.prototype
// this should always be the last index, but we check just in case
const commonPrototypeIndex = globalProtoChain.findIndex(
(globalProtoChainEntry) => globalProtoChainEntry === Object.prototype
)
if (commonPrototypeIndex === -1) {
// TODO: fix this error message
throw new Error(
'Lavamoat - unable to find common prototype between Compartment and globalRef'
)
}
// we will copy endowments from all entries in the prototype chain, excluding Object.prototype
const endowmentSources = globalProtoChain.slice(0, commonPrototypeIndex)
// call all getters, in case of behavior change (such as with FireFox lazy getters)
// call on contents of endowmentsSources directly instead of in new array instances. If there is a lazy getter it only changes the original prop desc.
endowmentSources.forEach((source) => {
const descriptors = Object.getOwnPropertyDescriptors(source)
Object.values(descriptors).forEach((desc) => {
if ('get' in desc && desc.get) {
try {
// calling getters can potentially throw (e.g. localStorage inside a sandboxed iframe)
Reflect.apply(desc.get, globalRef, [])
} catch {}
}
})
})
const endowmentSourceDescriptors = endowmentSources.map(
(globalProtoChainEntry) =>
Object.getOwnPropertyDescriptors(globalProtoChainEntry)
)
// flatten propDesc collections with precedence for globalThis-end of the prototype chain
const endowmentDescriptorsFlat = Object.assign(
Object.create(null),
...endowmentSourceDescriptors.reverse()
)
// expose all own properties of globalRef, including non-enumerable
Object.entries(endowmentDescriptorsFlat)
// ignore properties already defined on compartment global
.filter(([key]) => !(key in target))
// ignore circular globalThis refs
.filter(([key]) => !globalThisRefs.includes(key))
// define property on compartment global
.forEach(([key, desc]) => {
// unwrap functions, setters/getters & apply scope proxy workaround
const wrappedPropDesc = applyEndowmentPropDescTransforms(
desc,
target,
globalRef
)
Reflect.defineProperty(target, key, wrappedPropDesc)
})
// global circular references otherwise added by prepareCompartmentGlobalFromConfig
// Add all circular refs to root package compartment globalThis
for (const ref of globalThisRefs) {
if (ref in target) {
continue
}
target[ref] = target
}
return target
}
}
/**
* Util for getting the prototype chain as an array includes the provided value
* in the result
*
* @param {any} value
* @returns {any[]}
*/
function getPrototypeChain(value) {
const protoChain = []
let current = value
while (
current &&
(typeof current === 'object' || typeof current === 'function')
) {
protoChain.push(current)
current = Reflect.getPrototypeOf(current)
}
return protoChain
}
/**
* @param {string} visited
* @param {string} next
*/
function extendPath(visited, next) {
// FIXME: second part of this conditional should be unnecessary
if (!visited || visited.length === 0) {
return next
}
return `${visited}.${next}`
}
/**
* @param {object | null} value
* @returns {value is null}
*/
function isEmpty(value) {
return !value
}
/**
* Sets up the getter and setter pair so that the specific targetRef field is
* effectively writeable and the value propagates to sourceRef. This implements
* the `'write'` permission for a global in a specific resource.
*
* @param {string} key
* @param {Record<string, any>} sourceRef
* @param {Record<string, any>} targetRef
*/
function makeWritableValueAtPath(key, sourceRef, targetRef) {
const enumerable = Reflect.getOwnPropertyDescriptor(
sourceRef,
key
)?.enumerable
Reflect.defineProperty(targetRef, key, {
configurable: false,
enumerable,
set(newValue) {
sourceRef[key] = newValue
},
get() {
return sourceRef[key]
},
})
}
/**
* Puts a getter at the end of the path that returns the nested values from a
* top-level field that might change at runtime.
*
* @param {string[]} pathParts
* @param {Record<string, any>} sourceRef
* @param {Record<string, any>} targetRef
*/
function instrumentDynamicValueAtPath(pathParts, sourceRef, targetRef) {
const enumerable = Reflect.getOwnPropertyDescriptor(
sourceRef,
pathParts[0]
)?.enumerable
const dynamicGetterDesc = {
get: () => {
const dynamicValue = sourceRef[pathParts[0]]
let leaf = dynamicValue,
parent = sourceRef
for (let i = 1; i < pathParts.length; i++) {
parent = leaf
leaf = leaf[pathParts[i]]
}
if (typeof leaf === 'function') {
leaf = leaf.bind(parent) // TODO: consider the risks, should not differ from unwrapping
}
return leaf
},
writeable: false,
enumerable, // Initial value will have to suffice. Change will not propagate dynamically.
configurable: false,
}
let currentTarget = targetRef
let currentPath = ''
for (let depth = 0; depth < pathParts.length - 1; depth++) {
currentPath = extendPath(currentPath, pathParts[depth])
const nextPart = pathParts[depth]
if (Reflect.getOwnPropertyDescriptor(currentTarget, nextPart)?.get) {
// We could silently ignore this, but it could introduce a false sense of security in the policy file
throw Error(
`LavaMoat - "${pathParts[0]}" is writeable elsewhere and both "${currentPath}" and "${pathParts.join('.')}" are allowed for one package. One of these entries is redundant.`
)
}
if (typeof currentTarget[nextPart] !== 'object') {
currentTarget[nextPart] = {}
}
currentTarget = currentTarget[nextPart]
}
const lastPart = pathParts[pathParts.length - 1]
Reflect.defineProperty(currentTarget, lastPart, dynamicGetterDesc)
}
/**
* @type {DefaultWrapperFn}
*/
function defaultCreateFunctionWrapper(sourceValue, unwrapTest, unwrapTo) {
/**
* @param {...any[]} args
* @returns {any}
* @this {object}
*/
const newValue = function (...args) {
if (new.target) {
// handle constructor calls
return Reflect.construct(sourceValue, args, new.target)
} else {
// handle function calls
// unwrap to target value if this value is the source package compartment's globalThis
const thisRef = unwrapTest(this) ? unwrapTo : this
return Reflect.apply(sourceValue, thisRef, args)
}
}
Object.defineProperties(
newValue,
Object.getOwnPropertyDescriptors(sourceValue)
)
return newValue
}
/**
* The default implementation of the utility for wrapping endowed function to
* set `this` to a correct reference.
*
* @callback DefaultWrapperFn
* @param {(...args: any[]) => any} sourceValue
* @param {(value: any) => boolean} unwrapTest
* @param {object} unwrapTo
* @returns {(...args: any[]) => any}
*/
/**
* Makes a copy of all globals from the global ref to a target and wraps them
* with the wrapper this endowmentsToolkit was configured to use. It also copies
* all circular references to the root package compartment globalThis.
*
* @callback CopyWrappedGlobals
* @param {object} globalRef
* @param {Record<PropertyKey, any>} target - The object to copy the properties
* to, recursively (hence any not unknown type)
* @param {string[]} globalThisRefs
* @returns {Record<PropertyKey, any>}
*/
/**
* A recursive function to copy a single (nested) property located at the
* provided path from a sourceRef to targetRef.
*
* @callback CopyValueAtPath
* @param {string} visitedPath
* @param {string[]} pathParts
* @param {string[]} explicitlyBanned
* @param {object} sourceRef
* @param {object} targetRef
* @param {object} [unwrapTo]
* @param {object} [unwrapFrom]
* @returns {void}
*/
/**
* Utility function used by copyWrappedGlobals to wrap a property with a getter
* and/or setter.
*
* @callback ApplyGetSetPropDescTransforms
* @param {PropertyDescriptor} sourcePropDesc
* @param {object} unwrapFromGlobalThis
* @param {object} unwrapToGlobalThis
* @returns {PropertyDescriptor}
*/
/**
* Utility function used by copyWrappedGlobals to choose a wrapping strategy for
* a property.
*
* @callback ApplyEndowmentPropDescTransforms
* @param {PropertyDescriptor} propDesc
* @param {object} unwrapFromCompartmentGlobalThis
* @param {object} unwrapToGlobalThis
* @returns {PropertyDescriptor}
*/