@lavamoat/webpack
Version:
LavaMoat Webpack plugin for running dependencies in Compartments without eval
609 lines (542 loc) • 24.4 kB
JavaScript
const { runtimeBuilder } = require('./runtime/runtimeBuilder.js')
const { analyzeModules } = require('./buildtime/modulesData.js')
const path = require('node:path')
const assert = require('node:assert')
const {
WebpackError,
Compilation,
sources: { RawSource },
} = require('webpack')
const browserResolve = require('browser-resolve')
const { generateIdentifierLookup } = require('./buildtime/aa.js')
const diag = require('./buildtime/diagnostics.js')
const { assertFields, progress } = require('./buildtime/utils.js')
const {
generatePolicy,
loadPolicy,
stringifyPolicyReliably,
} = require('./buildtime/policyGenerator.js')
const { loadCanonicalNameMap } = require('@lavamoat/aa')
/**
* This is jsdoc for reexport
*
* @typedef {import('./buildtime/types').LavaMoatPluginOptions} LavaMoatPluginOptions
*/
/**
* Just import
*
* @import {LockdownOptions} from 'ses'
* @import {CompleteLavaMoatPluginOptions} from './buildtime/types'
* @import {CanonicalNameMap} from '@lavamoat/aa'
* @import {LavaMoatPolicy} from '@lavamoat/types'
*/
// TODO: upcoming version of webpack may expose these constants, but we want to support more versions
// https://github.com/webpack/webpack/blob/07ac43333654280c5bc6014a3a69eda4c3b80273/lib/ModuleTypeConstants.js
// const {
// JAVASCRIPT_MODULE_TYPE_AUTO,
// JAVASCRIPT_MODULE_TYPE_DYNAMIC,
// JAVASCRIPT_MODULE_TYPE_ESM,
// } = require("webpack/lib/ModuleTypeConstants");
const JAVASCRIPT_MODULE_TYPE_AUTO = 'javascript/auto'
const JAVASCRIPT_MODULE_TYPE_DYNAMIC = 'javascript/dynamic'
const JAVASCRIPT_MODULE_TYPE_ESM = 'javascript/esm'
const COVERED_MODULE_TYPES = /** @type {const} */ ([
JAVASCRIPT_MODULE_TYPE_AUTO,
JAVASCRIPT_MODULE_TYPE_DYNAMIC,
JAVASCRIPT_MODULE_TYPE_ESM,
])
const POLICY_SNAPSHOT_FILENAME = 'policy-snapshot.json'
const { wrapGenerator } = require('./buildtime/generator.js')
const { sesEmitHook, sesPrefixFiles } = require('./buildtime/emitSes.js')
const EXCLUDE_LOADER = path.join(__dirname, './excludeLoader.js')
// =================================================================
// Plugin code
// =================================================================
const PLUGIN_NAME = 'LavaMoatPlugin'
/** @satisfies {LockdownOptions} */
const lockdownDefaults = /** @type {const} */ ({
// lets code observe call stack, but easier debuggability
errorTaming: 'unsafe',
// shows the full call stack
stackFiltering: 'verbose',
// prevents most common override mistake cases from tripping up users
overrideTaming: 'severe',
// preserves JS locale methods, to avoid confusing users
// prevents aliasing: toLocaleString() to toString(), etc
localeTaming: 'unsafe',
})
class LavaMoatPlugin {
/**
* @param {LavaMoatPluginOptions} [options]
*/
constructor(options = {}) {
if (typeof options.scuttleGlobalThis === 'object') {
options.scuttleGlobalThis = { ...options.scuttleGlobalThis }
} else {
options.scuttleGlobalThis = { enabled: false }
}
/** @type {CompleteLavaMoatPluginOptions} */
this.options = {
policyLocation: path.join('lavamoat', 'webpack'),
lockdown: lockdownDefaults,
isBuiltin: () => false,
runChecks: true,
diagnosticsVerbosity: 0,
...options,
}
if (this.options.generatePolicyOnly) {
this.options.generatePolicy = true
}
diag.level = this.options.diagnosticsVerbosity
}
/**
* @param {import('webpack').Compiler} compiler The compiler instance
* @returns {void}
*/
apply(compiler) {
/**
* @typedef {Object} Store
* @property {CompleteLavaMoatPluginOptions} options
* @property {Error[]} [mainCompilationWarnings]
* @property {(string | number)[]} chunkIds Array of chunk ids that have
* been processed.
* @property {string[]} excludes Array of module rawResource names that were
* excluded from wrapping
* @property {string[]} tooEarly Array of module rawResource names that were
* not wrapped because they were generated before chunks were known
* @property {CanonicalNameMap} [canonicalNameMap]
* @property {LavaMoatPolicy} [runtimeOptimizedPolicy]
* @property {string} [root]
* @property {[string, (string | number)[]][]} [identifiersForModuleIds]
* @property {(string | number)[]} [unenforceableModuleIds]
* @property {(string | number)[]} [contextModuleIds]
* @property {(path: string) => string | undefined} [pathToResourceId]
* @property {Record<string, any>} [externals]
*/
/** @type {Store} */
const STORE = {
options: this.options,
chunkIds: [],
excludes: [],
tooEarly: [],
}
const PROGRESS = progress({
steps: [
'start',
'canonicalNameMap',
'pathsCollected',
'pathsProcessed',
'generatorCalled:repeats',
'runtimeAdded:repeats',
'finish',
],
})
diag.run(2, () => {
// Log stack traces for all errors on higher verbosity because webpack won't
compiler.hooks.done.tap(PLUGIN_NAME, (stats) => {
if (stats.hasErrors()) {
stats.compilation.errors.forEach((error) => {
console.error(error)
})
}
})
})
// ========================================
// finalize options
if (typeof STORE.options.readableResourceIds === 'undefined') {
// default options.readableResourceIds to true if webpack configuration sets development mode
STORE.options.readableResourceIds = compiler.options.mode !== 'production'
}
/** @type {string[]} */
const FORCED_CONFIG = []
if (compiler.options.optimization.concatenateModules) {
FORCED_CONFIG.push('concatenateModules=true->false')
}
// Concatenation won't work with wrapped modules. Have to disable it.
compiler.options.optimization.concatenateModules = false
// This setting creates a bit of confusion when it removes a reexport between packages and collapses the dependency tree, but it's got a potential to optimize some bundles a lot. We can support it by making sure we use the optimized connections when generating policy.
// compiler.options.optimization.sideEffects;
// TODO: Research. If we fiddle a little with how we wrap the module, it might be possible to get inlining to work eventually by adding a closure that returns the module namespace. I just don't want to get into the compatibility of it all yet.
// TODO: explore how these settings affect the Compartment wrapping etc.
// compiler.options.optimization.mangleExports = false;
// compiler.options.optimization.usedExports = false;
// compiler.options.optimization.providedExports = false;
Object.freeze(STORE.options)
// =======================================
// loadCanonicalNameMap depends on having a resolver. It'd be best to use webpack's own, but it's problematic and the discrepancies between resolvers are not on the package level, but individual exports, which has not tripped us up yet.
// sadly regular webpack compilation doesn't allow for synchronous resolver.
// Error: Cannot 'resolveSync' because the fileSystem is not sync. Use 'resolve'!
// function adapterFunction(resolver) {
// return function(id, options) {
// // Extract the directory and module name from the id
// const dir = path.dirname(id);
// const moduleName = path.basename(id);
// // Call the resolver with the appropriatewindows arguments
// return resolver(options, dir, moduleName);
// };
// }
// resolve = { sync: adapterFunction(compilation.resolverFactory.get('normal').resolveSync.bind(compilation.resolverFactory.get('normal'))) }
// =================================================================
// run long asynchronous processing ahead of all compilations
compiler.hooks.beforeRun.tapAsync(PLUGIN_NAME, (_, callback) => {
assertFields(STORE, ['options'])
loadCanonicalNameMap({
rootDir: STORE.options.rootDir || compiler.context,
includeDevDeps: true, // even the most proper projects end up including devDeps in their bundles :(
resolve: browserResolve,
})
.then((map) => {
STORE.canonicalNameMap = map
PROGRESS.report('canonicalNameMap')
callback()
})
.catch((err) => {
callback(err)
})
})
compiler.hooks.thisCompilation.tap(
PLUGIN_NAME,
(compilation, { normalModuleFactory }) => {
// Wire up error and warning collection
PROGRESS.reportErrorsTo(compilation.errors)
STORE.mainCompilationWarnings = compilation.warnings
assertFields(STORE, ['mainCompilationWarnings', 'options'])
if (STORE.options.generatePolicyOnly) {
compiler.options.devtool = false // source maps are expensive to make and unnecessary
compiler.hooks.shouldEmit.tap(PLUGIN_NAME, () => false)
compilation.hooks.shouldGenerateChunkAssets.tap(
PLUGIN_NAME,
() => false
)
// replacing generator with something returning an empty string could shave off some extra time, but is too invasive to seem worth it
}
if (FORCED_CONFIG.length > 0) {
STORE.mainCompilationWarnings.push(
new WebpackError(
'LavaMoatPlugin: Following options had to be overriden for security: ' +
FORCED_CONFIG.join(', ')
)
)
}
compilation.hooks.optimizeAssets.tap(PLUGIN_NAME, () => {
if (!PROGRESS.isCancelled()) {
if (!PROGRESS.done('generatorCalled')) {
compilation.errors.push(
new WebpackError(
'LavaMoatPlugin: Not a single module was wrapped in a compartment. Must be a configuration error.'
)
)
}
if (!PROGRESS.done('runtimeAdded')) {
compilation.errors.push(
new WebpackError(
'LavaMoatPlugin: Not a single copy of LavaMoat runtime was added in the compilation. Must be a configuration error.'
)
)
}
}
// By the time assets are being optimized we should have finished.
// This will ensure all previous steps have been done.
PROGRESS.report('finish')
})
// =================================================================
// javascript modules generator tweaks installation
const generatorWrapper = wrapGenerator({
excludes: STORE.excludes,
runChecks: STORE.options.runChecks,
PROGRESS,
})
for (const moduleType of COVERED_MODULE_TYPES) {
normalModuleFactory.hooks.generator
.for(moduleType)
.tap(PLUGIN_NAME, generatorWrapper.generatorHookHandler)
}
diag.run(1, () => {
// Report on excluded modules as late as possible.
// This hook happens after all module generators have been executed.
compilation.hooks.afterProcessAssets.tap(PLUGIN_NAME, () => {
assertFields(STORE, ['excludes', 'mainCompilationWarnings'])
if (STORE.excludes.length > 0) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: Following modules were excluded by use of excludeLoader: \n ${STORE.excludes.join('\n ')}`
)
)
}
})
})
// =================================================================
// END OF javascript modules generator tweaks installation
// =================================================================
// afterOptimizeChunkIds hook for processing all identified modules
compilation.hooks.afterOptimizeChunkIds.tap(PLUGIN_NAME, (chunks) => {
try {
assertFields(STORE, [
'options',
'mainCompilationWarnings',
'chunkIds',
'canonicalNameMap',
])
const chunkGraph = compilation.chunkGraph
/**
* @type {{
* module: import('webpack').Module
* moduleId: string | number | null
* }[]}
*/
const allIdentifiedModules = []
Array.from(chunks).forEach((chunk) => {
// Collect chunk IDs and info while we're here
if (chunk.id !== null) {
STORE.chunkIds.push(chunk.id)
}
chunkGraph.getChunkModules(chunk).forEach((module) => {
const moduleId = chunkGraph.getModuleId(module)
allIdentifiedModules.push({ module, moduleId })
})
})
const moduleData = analyzeModules({
mainCompilationWarnings: STORE.mainCompilationWarnings,
allIdentifiedModules,
})
diag.rawDebug(
3,
JSON.stringify({ knownPaths: moduleData.knownPaths })
)
PROGRESS.report('pathsCollected')
if (moduleData.inspectable.length === 0) {
throw Error(
'LavaMoatPlugin: No modules to run under policy found in the compilation, must be a misconfiguration.'
)
}
const policyToApply = STORE.options.generatePolicy
? generatePolicy({
location: STORE.options.policyLocation,
canonicalNameMap: STORE.canonicalNameMap,
isBuiltin: STORE.options.isBuiltin,
modulesToInspect: moduleData.inspectable.map((module) => ({
module,
connections:
compilation.moduleGraph.getOutgoingConnections(module),
})),
})
: loadPolicy({
policyFromOptions: STORE.options.policy,
location: STORE.options.policyLocation,
})
if (STORE.options.generatePolicyOnly) {
PROGRESS.cancel() // prevents progress errors
compilation.clearAssets() // causes most further compilation work to be skipped
return
}
if (STORE.options.emitPolicySnapshot) {
compilation.emitAsset(
POLICY_SNAPSHOT_FILENAME,
new RawSource(stringifyPolicyReliably(policyToApply))
)
}
assert(
policyToApply !== undefined,
'Policy was not specified nor generated.'
)
const identifierLookup = generateIdentifierLookup({
readableResourceIds: STORE.options.readableResourceIds,
contextModules: moduleData.contextModules,
externals: moduleData.externals,
paths: moduleData.knownPaths,
policy: policyToApply,
canonicalNameMap: STORE.canonicalNameMap,
})
const { tooEarly } = generatorWrapper.enableGeneratorWrapping({
getIdentifierForPath: identifierLookup.pathToResourceId,
})
const suspiciousTooEarly = tooEarly.filter(
identifierLookup.isKnownPath
)
if (suspiciousTooEarly.length > 0) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: sources generated for modules before all paths were known. The following modules in the bundle might not be wrapped in a Compartment: \n ${suspiciousTooEarly.join('\n ')}`
)
)
}
diag.run(1, () => {
if (tooEarly.length > 0) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: All modules with 'generate' step executed before all paths were known \n ${tooEarly.join('\n ')}`
)
)
}
})
STORE.root = identifierLookup.root
STORE.identifiersForModuleIds =
identifierLookup.identifiersForModuleIds
STORE.unenforceableModuleIds = moduleData.unenforceableModuleIds
STORE.contextModuleIds = moduleData.contextModules.map(
({ moduleId }) => moduleId
)
STORE.externals = moduleData.externals
// narrow down the policy and map to module identifiers
// TODO: theoretically policy could be optimized per chunk, but configuring webpack to emit a runtime chunk or have separate builds seems better for most usecases.
STORE.runtimeOptimizedPolicy =
identifierLookup.getTranslatedPolicy()
diag.run(1, () => {
assertFields(STORE, ['runtimeOptimizedPolicy'])
const originalKeys = Object.keys(policyToApply.resources)
const optimizedKeys = Object.keys(
STORE.runtimeOptimizedPolicy?.resources
)
const optimizedKeysSet = new Set(optimizedKeys)
const policyKeyDiff = originalKeys.filter(
(k) => !optimizedKeysSet.has(k)
)
if (policyKeyDiff.length > 0) {
diag.rawDebug(
1,
`policy.json contained ${policyKeyDiff.length} resources that did not match anything in the bundle. `
)
diag.rawDebug(
2,
`policy.json unused resources: \n${policyKeyDiff.join(', ')}`
)
}
})
// =================================================================
if (moduleData.unenforceableModuleIds.length > 0) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: the following module ids can't be controlled by policy and must be ignored at runtime: \n ${moduleData.unenforceableModuleIds.join()}`
)
)
}
PROGRESS.report('pathsProcessed')
} catch (/** @type {any} */ error) {
// NOTE: Webpack is handling regular errors pushed to compilation.errors just fine despite the type being set to WebpackError[]. I'd be convinced to wrap them in WebpackError if it didn't lack support of `cause`.
compilation.errors.push(error)
}
})
// =================================================================
// END OF afterOptimizeChunkIds hook for processing all identified modules
// =================================================================
// This part adds LavaMoat runtime to webpack runtime for every chunk that needs runtime.
const onceForChunkSet = new WeakSet()
const chunkRuntimeWarningsDedupe = new Set()
const { getLavaMoatRuntimeModules } = runtimeBuilder({
options: STORE.options,
})
// Define a handler function to be called for each chunk in the compilation.
compilation.hooks.additionalChunkRuntimeRequirements.tap(
PLUGIN_NAME + '_runtime',
(chunk /*, set*/) => {
if (chunk.hasRuntime()) {
if (!PROGRESS.done('generatorCalled')) {
assertFields(STORE, ['options', 'mainCompilationWarnings'])
if (!chunkRuntimeWarningsDedupe.has(chunk.id)) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: Something was generating runtime before all modules were identified. This might be part of a sub-compilation of a plugin. Please check for any unwanted interference between plugins. [chunk name: '${chunk.name}']`
)
)
chunkRuntimeWarningsDedupe.add(chunk.id)
}
diag.rawDebug(
2,
'skipped adding runtime (additionalChunkRuntimeRequirements)'
)
// It's possible to generate the runtime with an empty policy to make the wrapped code work.
// It's no longer necessary now that `generate` function is only wrapping anything if paths were processed - which is when generator wrapping gets enabled,
// which corresponds to it being the main compilation. But plugins may exist that conflict with that assumption;
// in which case we're gonna have to bring back the runtime with empty policy
} else {
// If the chunk has already been processed, skip it.
if (onceForChunkSet.has(chunk)) {
STORE.mainCompilationWarnings.push(
new WebpackError(
`LavaMoatPlugin: Skipped adding runtime again to the same chunk [chunk name: '${chunk.name}']`
)
)
return
}
assertFields(STORE, [
'options',
'mainCompilationWarnings',
'root',
'identifiersForModuleIds',
'unenforceableModuleIds',
'contextModuleIds',
'externals',
'runtimeOptimizedPolicy',
])
const lavaMoatRuntimeModules = getLavaMoatRuntimeModules({
PROGRESS,
currentChunk: chunk,
chunkIds: STORE.chunkIds,
policyData: STORE.runtimeOptimizedPolicy,
identifiers: {
root: STORE.root,
identifiersForModuleIds: STORE.identifiersForModuleIds,
unenforceableModuleIds: STORE.unenforceableModuleIds,
contextModuleIds: STORE.contextModuleIds,
externals: STORE.externals,
},
chunkLoaderName:
compilation.outputOptions.chunkLoadingGlobal ??
'webpackChunk',
})
// Add the runtime modules to the chunk, which handles
// the runtime logic for wrapping with lavamoat.
lavaMoatRuntimeModules.forEach((module) => {
compilation.addRuntimeModule(chunk, module)
})
// set.add(RuntimeGlobals.onChunksLoaded); // TODO: develop an understanding of what this line does and why it was a part of the runtime setup for module federation
// Mark the chunk as processed by adding it to the WeakSet.
onceForChunkSet.add(chunk)
}
}
}
)
if (STORE.options.inlineLockdown) {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
},
sesPrefixFiles({
compilation,
inlineLockdown: STORE.options.inlineLockdown,
})
)
} else {
const HtmlWebpackPluginInUse = compiler.options.plugins.find(
/**
* @param {unknown} plugin
* @returns {plugin is import('webpack').WebpackPluginInstance}
*/
(plugin) =>
!!plugin &&
typeof plugin === 'object' &&
plugin.constructor.name === 'HtmlWebpackPlugin'
)
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
sesEmitHook({
compilation,
HtmlWebpackPluginInUse,
HtmlWebpackPluginInterop:
!!STORE.options.HtmlWebpackPluginInterop,
})
)
}
// TODO: add later hooks to optionally verify correctness and totality
// of wrapping for the paranoid mode.
}
)
}
}
module.exports = LavaMoatPlugin
module.exports.LavaMoatPlugin = LavaMoatPlugin
module.exports.exclude = EXCLUDE_LOADER