Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

webpack: lockdown inlining #1101

Merged
merged 5 commits into from Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
50 changes: 24 additions & 26 deletions packages/webpack/README.md
Expand Up @@ -14,15 +14,20 @@ LavaMoat Webpack Plugin wraps each module in the bundle in a [Compartment](https

> The plugin is emitting lockdown without the `.js` extension because that's the only way to prevent it from getting minified, which is likely to break it.

The LavaMoat plugin takes an options object with the following properties:

- policy: the LavaMoat policy object. (unstable. This will surely change before v1 or a policy loader export will be provided from the main package to incorporate policy-override files)
- runChecks: Optional boolean property to indicate whether to check resulting code with wrapping for correctness. Default is false.
- diagnosticsVerbosity: Optional number property to represent diagnostics output verbosity. A larger number means more overwhelming diagnostics output. Default is 0.
Setting positive verbosity will enable runChecks.
- readableResourceIds: Decide whether to keep resource IDs human readable (regardless of production/development mode). If false, they are replaced with a sequence of numbers. Keeping them readable may be useful for debugging when a policy violation error is thrown.
- lockdown: set configuration for [SES lockdown](). Setting the option replaces defaults from LavaMoat.
- HtmlWebpackPluginInterop: add a script tag to the html output for ./lockdown file if HtmlWebpackPlugin is in use
The LavaMoat plugin takes an options object with the following properties (all optional):

| Property | Description | Default |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `policyLocation` | Directory to store policy files in. | `./lavamoat/webpack` |
| `generatePolicy` | Whether to generate the `policy.json` file. Generated policy is used in the build immediately. `policy-override.json` is applied before bundling, if present. | `false` |
| `emitPolicySnapshot` | If enabled, emits the result of merging policy with overrides into the output directory of Webpack build for inspection. The file is not used by the bundle. | `false` |
| `readableResourceIds` | Boolean to decide whether to keep resource IDs human readable (possibly regardless of production/development mode). If `false`, they are replaced with a sequence of numbers. Keeping them readable may be useful for debugging when a policy violation error is thrown. By default, follows the Webpack config mode. | `(mode==='development')` |
| `lockdown` | Configuration for [SES lockdown][]. Setting the option replaces defaults from LavaMoat. | reasonable defaults |
| `HtmlWebpackPluginInterop` | Boolean to add a script tag to the HTML output for `./lockdown` file if `HtmlWebpackPlugin` is in use. | `false` |
| `inlineLockdown` | Array of output filenames in which to inline lockdown (instead of adding it as a file to the output directory). |
| `runChecks` | Boolean property to indicate whether to check resulting code with wrapping for correctness. | `false` |
| `diagnosticsVerbosity` | Number property to represent diagnostics output verbosity. A larger number means more overwhelming diagnostics output. Setting a positive verbosity will enable `runChecks`. | `0` |
| `policy` | The LavaMoat policy object (if not loading from file; see `policyLocation`) | `undefined` |

```js
const LavaMoatPlugin = require('@lavamoat/webpack')
Expand All @@ -31,18 +36,8 @@ module.exports = {
// ... other webpack configuration properties
plugins: [
new LavaMoatPlugin({
// policy generated by lavamoat
policy: require('./lavamoat/policy.json'),
// runChecks: true, // enables checking each wrapped module source if it's still proper JavaScript (in case mismatching braces somehow survived Webpack loaders processing)
// readableResourceIds: true, // explicitly decide if resourceIds from policy should be readable in the bundle or turned into numbers. You might want to bundle in production mode but keep the ids for debugging
// diagnosticsVerbosity: 2, // level of output verbosity from the plugin
// SES lockdown options to use at runtime
// lockdown: {
// errorTaming: "unsafe",
// consoleTaming: "unsafe",
// overrideTaming: "severe"
// },
// HtmlWebpackPluginInterop: false, // set it to true if you want a script tag for ./lockdown file to automatically be added to your HTML template
generatePolicy: true,
// ... settings from above, optionally
}),
],
// ... other webpack configuration properties
Expand Down Expand Up @@ -74,7 +69,7 @@ Example: avoid wrapping CSS modules:
'css-loader',
LavaMoatPlugin.exclude,
],
sideEffects: true,
// ...
},
],
},
Expand Down Expand Up @@ -107,12 +102,13 @@ Sadly, even treeshaking doesn't eliminate that module. It's left there and faili

This plugin will skip policy enforcement for such ignored modules.

# Security Claims
# Security

**This is a _beta_ release and does not provide any guarantees; even those listed below. Use at your own risk!**
**This is an experimental software. Use at your own risk!**

- SES must be added to the page without any bundling or transforming for any security guarantees to be sustained.
- The plugin is attempting to add it as an asset to the compilation for the sake of Developer Experience. Feedback welcome.
- [SES lockdown][] must be added to the page without any bundling or transforming for any security guarantees to be sustained.
- The plugin is attempting to add it as an asset to the compilation for the sake of Developer Experience. `.js` extension is omitted to prevent minification.
- Optionally lockdown can be inlined into the bundle files. You need to list the scripts that get to load as the first script on the page to apply lockdown only once when inlined. When you have a single bundle, you just configure a list with one element. It gets more complex with builds for multiple pages. The plugin doesn't attempt to guess where to inline lockdown.
- Each javascript module resulting from the webpack build is scoped to its package's policy

## Threat Model
Expand All @@ -137,3 +133,5 @@ Run `npm test` to start the automated tests.
- Navigate to `example/`
- Run `npm ci` and `npm test`
- Open `dist/index.html` in your browser and inspect the console

[SES lockdown]: https://github.com/endojs/endo/tree/master/packages/ses#lockdown
79 changes: 59 additions & 20 deletions packages/webpack/src/buildtime/emitSes.js
@@ -1,40 +1,79 @@
const { readFileSync } = require('node:fs')
const path = require('node:path')
const {
sources: { RawSource },
sources: { RawSource, ConcatSource },
} = require('webpack')

const lockdownSource = readFileSync(
path.join(require.resolve('ses'), '../lockdown.umd.min.js'),
'utf-8'
)
const lockdownSourcePrefix = `;(function(){\n${lockdownSource}\n})();\n`

module.exports = {
/**
* @param {object} options
* @param {import('webpack').Compilation} options.compilation
* @param {string[]} options.inlineLockdown
* @returns {() => void}
*/
sesPrefixFiles:
({ compilation, inlineLockdown: files }) =>
() => {
files.forEach((file) => {
const asset = compilation.assets[file]
if (!asset) {
throw new Error(
`LavaMoatPlugin: file specified in inlineLockdown not found in compilation: ${file}`
)
}
compilation.assets[file] = new ConcatSource(lockdownSourcePrefix, asset)
})
},
/**
* @param {object} options
* @param {import('webpack').Compilation} options.compilation
* @param {import('webpack').WebpackPluginInstance} [options.HtmlWebpackPluginInUse]
* @param {boolean} [options.HtmlWebpackPluginInterop]
* @returns {() => void}
*/
sesEmitHook:
({ compilation, HtmlWebpackPluginInUse, HtmlWebpackPluginInterop }) =>
() => {
const sesFile = readFileSync(require.resolve('ses'), 'utf-8')
// TODO: to consider: instead manually copy to compiler.options.output.path
const asset = new RawSource(sesFile)
const asset = new RawSource(lockdownSource)

compilation.emitAsset('lockdown', asset)

if (HtmlWebpackPluginInUse && HtmlWebpackPluginInterop) {
HtmlWebpackPluginInUse.constructor
// @ts-expect-error - incomplete types
.getHooks(compilation)
.beforeEmit.tapAsync('LavaMoatWebpackPlugin-lockdown', (data, cb) => {
const scriptTag = '<script src="./lockdown"></script>'
const headTagRegex = /<head[^>]*>/iu
const scriptTagRegex = /<script/iu
.beforeEmit.tapAsync(
'LavaMoatWebpackPlugin-lockdown',
(
/** @type {{ html: string }} */ data,
/** @type {(arg0: null, arg1: any) => void} */ cb
) => {
const scriptTag = '<script src="./lockdown"></script>'
const headTagRegex = /<head[^>]*>/iu
const scriptTagRegex = /<script/iu

if (headTagRegex.test(data.html)) {
data.html = data.html.replace(headTagRegex, `$&${scriptTag}`)
} else if (scriptTagRegex.test(data.html)) {
data.html = data.html.replace(
scriptTagRegex,
`${scriptTag}<script`
)
} else {
throw Error(
'LavaMoat: Could not insert lockdown script tag, no suitable location found in the html template'
)
if (headTagRegex.test(data.html)) {
data.html = data.html.replace(headTagRegex, `$&${scriptTag}`)
} else if (scriptTagRegex.test(data.html)) {
data.html = data.html.replace(
scriptTagRegex,
`${scriptTag}$&`
)
} else {
throw Error(
'LavaMoat: Could not insert lockdown script tag, no suitable location found in the html template'
)
}
cb(null, data)
}
cb(null, data)
})
)
}
},
}
66 changes: 43 additions & 23 deletions packages/webpack/src/plugin.js
Expand Up @@ -25,7 +25,7 @@ const JAVASCRIPT_MODULE_TYPE_ESM = 'javascript/esm'
// @ts-ignore
const { RUNTIME_KEY } = require('./ENUM.json')
const { wrapGeneratorMaker } = require('./buildtime/generator.js')
const { sesEmitHook } = require('./buildtime/emitSes.js')
const { sesEmitHook, sesPrefixFiles } = require('./buildtime/emitSes.js')
const EXCLUDE_LOADER = path.join(__dirname, './excludeLoader.js')

class VirtualRuntimeModule extends RuntimeModule {
Expand Down Expand Up @@ -63,13 +63,17 @@ class LavaMoatPlugin {
* @param {import('./types.js').LavaMoatPluginOptions} [options]
*/
constructor(options = {}) {
if (!options.lockdown) {
options.lockdown = lockdownDefaults
/**
* @type {import('./types.js').LavaMoatPluginOptions & {
* policyLocation: string
* lockdown: object
* }}
*/
this.options = {
lockdown: lockdownDefaults,
policyLocation: path.join('.', 'lavamoat', 'webpack'),
...options,
}
if (!options.policyLocation) {
options.policyLocation = path.join('.', 'lavamoat', 'webpack')
}
this.options = options

diag.level = options.diagnosticsVerbosity || 0
}
Expand Down Expand Up @@ -429,22 +433,38 @@ class LavaMoatPlugin {
}
)

const HtmlWebpackPluginInUse = compiler.options.plugins.find(
(plugin) => plugin && plugin.constructor.name === 'HtmlWebpackPlugin'
)

// TODO: avoid minification
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
},
sesEmitHook({
compilation,
HtmlWebpackPluginInUse,
HtmlWebpackPluginInterop: options.HtmlWebpackPluginInterop,
})
)
if (options.inlineLockdown) {
compilation.hooks.processAssets.tap(
{
name: PLUGIN_NAME,
stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
},
sesPrefixFiles({
compilation,
inlineLockdown: 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: !!options.HtmlWebpackPluginInterop,
})
)
}

// TODO: add later hooks to optionally verify correctness and totality
// of wrapping for the paranoid mode.
Expand Down
7 changes: 4 additions & 3 deletions packages/webpack/src/types.js
Expand Up @@ -12,17 +12,18 @@
* defaults to './lavamoat/webpack'
* @property {boolean} [emitPolicySnapshot] - Additionally put policy in dist of
* webpack compilation
* @property {boolean} [runChecks] - Check resulting code with wrapping for
* correctness
* @property {boolean} [readableResourceIds] - Should resourceIds be readable or
* turned into numbers - defaults to (mode==='development')
* @property {boolean} [HtmlWebpackPluginInterop] - Add a script tag to the html
* output for lockdown.js if HtmlWebpackPlugin is in use
* @property {string[]} [inlineLockdown] - Prefix the listed files with lockdown
* @property {number} [diagnosticsVerbosity] - A number representing diagnostics
* output verbosity, the larger the more overwhelming
* @property {import('ses').LockdownOptions} [lockdown] - Options to pass to lockdown
* @property {Policy} [policy] - LavaMoat policy object - if programmaticly
* created
* @property {Object} [lockdown] - Options to pass to lockdown
* @property {boolean} [runChecks] - Check resulting code with wrapping for
* correctness
*/

/**
Expand Down
29 changes: 29 additions & 0 deletions packages/webpack/test/e2e-lockdown-inline.spec.js
@@ -0,0 +1,29 @@
const test = require('ava')
const { scaffold, runScript } = require('./scaffold.js')
const { makeConfig } = require('./fixtures/main/webpack.config.js')

test.before(async (t) => {
const webpackConfig = makeConfig({
inlineLockdown: ['app.js'],
})
await t.notThrowsAsync(async () => {
t.context.build = await scaffold(webpackConfig)
}, 'Expected the build to succeed')
Comment on lines +9 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the side-effect here is little malodorous to me, but I'm not sure what a better solution would look like.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean is a side effect here. scaffold is running the build and returning information it collects. If it fails, we report it as a testsuite failure.

t.context.bundle = t.context.build.snapshot['/dist/app.js']
})

test('webpack/lockdown-inline - bundle runs under lockdown', (t) => {
t.notThrows(() => {
runScript(
t.context.bundle +
`
;;
if (!Object.isFrozen(Object.prototype)) {
throw Error('expected Object.prototype to be frozen');
}
if (!Compartment || !harden) {
throw Error('expected SES globals to be available');
}`
)
})
})