-
Notifications
You must be signed in to change notification settings - Fork 7
build(extension): Robustify JavaScript module externalization #45
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
Conversation
7e97006 to
117951b
Compare
packages/extension/vite.config.ts
Outdated
| buildEnd: { | ||
| order: 'post', | ||
| handler(error) { | ||
| if (error !== undefined) { | ||
| return; | ||
| } | ||
| const trustedHeaders = Array.from(this.getModuleIds()).filter((moduleId) => isTrustedHeader(moduleId)); | ||
| const importers = trustedHeaders.map((trustedHeader) => this.getModuleInfo(trustedHeader)?.importers.at(0)!); | ||
| importers.forEach(async (moduleId: string) => { | ||
| const code = this.getModuleInfo(moduleId)?.code; | ||
| if ( code === null || code === undefined ) { | ||
| this.error( | ||
| `NoCode: Module ${moduleId} was identified as a trusted header importer but has no code at buildEnd.` | ||
| ); | ||
| } else { | ||
| const prefix = makeExpectedPrefix(moduleId); | ||
| if (code.match(prefix) === null) { | ||
| this.error( | ||
| `MissingTrustedHeaderImport: Module ${moduleId} was identified as a trusted header importer, ` + | ||
| `but does not begin with trusted header import.\n` + | ||
| `ExpectedPrefix: ${prefix}\n` + | ||
| `ObservedCode: ${code.split(';').at(0)}`); | ||
| } | ||
| } | ||
| }); | ||
| } | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To remove rollup from the TCB of our security proof, this check should be handled in a build test which gates pushes to prod. It would look similar to what is implemented here, but would need its own method for identifying which files are meant to be trusted, be it systematic or declared.
117951b to
cd4878c
Compare
rekmarks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Might I suggest "trusted prelude" instead of "trusted header"? In my mind, "header" in the context of a source file connotes metadata and/or config options, while "prelude" is something that is actually executed. See also: the "synchronous prelude" of an async function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some further suggested name changes:
plugins/->build/- Just "plugins" invites the question "plugins for what?". Since the file names contain the word "plugin" and the name of the... "plug-ee", there's already no question that they're plugins or what they plug in to.
rollup-plugin-endoify-trusted-header.ts->rollup-plugin-js-trusted-prelude.tsvite-plugin-endoify-html-files.ts->vite-plugin-html-trusted-prelude.ts
Note: the exports of the files should match the file names.
Edit: Since both plugins are in fact Vite plugins, we could also do this, which I think I prefer:
vite-plugins/html-trusted-prelude.tsjs-trusted-prelude.ts
rekmarks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some minor requested changes, mostly related to documentation and the naming scheme for the plugins.
I'll note what I believe is one limitation of the JS plugin: if we ever observe that the import order has changed, the build will fail with no recourse. Instead, could we not parameterize the entry point files to the plugin and insert the import statement ourselves? I don't think this would negatively impact devex, since we could still import the types of the trusted header / prelude files. In any event, something to consider.
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
| console.log(expectedPrefix); | ||
| return expectedPrefix; | ||
| }; | ||
| return { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The internals of this would benefit from some documentation links and/or a description of what the different properties are for.
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
packages/extension/plugins/rollup-plugin-endoify-trusted-header.ts
Outdated
Show resolved
Hide resolved
|
|
||
| /** | ||
| * Vite plugin to ensure that the following are true: | ||
| * - Every entrypoint contains at most one import from a *trusted-header file. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this plugin actually check if any of the files are entrypoints, or just if they imported a trusted header / prelude?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't check for entrypoints. For now I have corrected the doc string to bundles. The current functionality ought to be sufficient for our purposes---to ensure our entrypoints have trusted headers. The risk of omitting the entrypoint check is that a dev could get the impression they have a trusted header even though their code is vulnerable to being imported after a malicious initialization. Given our organization processes, I'm comfortable with that risk.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sorry I should have specified: my comment was more about the comment on L6, which says that the plugin ensures that something is true about entrypoints, but actually it just enforces the property for any file it comes across.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This incorrect description has been corrected in the latest docs commit.
/**
* A Vite plugin to ensure the following.
* - Every declared trusted prelude is handled externally (automatically merged into {@link RollupOptions.external}).
* - Every declared trusted prelude importer:
* - Is a declared entry point (throws during {@link Plugin.buildStart} otherwise).
* - Imports at most one declared trusted prelude (throws during {@link Plugin.generateBundle} otherwise).
* - Begins by importing its declared trusted prelude (prepended during {@link Plugin.generateBundle} if missing).
*
* ...
*/
Build failures with inscrutable root causes are a bad time, agreed. Since import statements are idempotent, we could also leave the import statement in the source code and still prepend the import statement instead of checking for it. The bundled entrypoint of import"foo-trusted-prelude.js";import"foo-trusted-prelude.js";import{X as a,Rf as g}from"./bar.js";...And sometimes like this: import"foo-trusted-prelude.js";import{X as a,Rf as g}from"./bar.js";import"foo-trusted-prelude.js";... |
248f8f2 to
72fc236
Compare
|
The latest implementation does not rely on the naming convention |
7620df9 to
060cebe
Compare
|
Notably the refactored plugins are not test covered, but the reviewer is encouraged to check out the following branches and run Allow multiple entry points with separate trusted headers.Throw because
|
The implementation throws errors only if its configuration assumptions are violated.
Otherwise, it proactively externalizes trusted preludes and attempts to correct any
correctness-breaking import permutations that may have occurred during bundling,
merely warning that some corrective action was necessary but not breaking the build.
The configuration assumptions are as follows.
- If a file has a declared trusted prelude, the file must be declared an entry point.
- If a file has a declared trusted prelude, it must not transitively import more than
one declared trusted prelude.
The result is a plugin which ensures that a declared trusted prelude file is never
imported after any other code, bandaging bundles which unintentionally violate this
constraint and rejecting source code which intentionally violates this constaint.
26a761c to
92ce904
Compare
rekmarks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good! Just one minor thing and we're there.
| getTrustedPrelude = (context: PluginContext, importer: string) => { | ||
| const trustedPrelude = resolvedTrustingImporters.get(importer); | ||
| // Ensure importer was declared and recognized as a trusting importer. | ||
| if (!trustedPrelude) { | ||
| // Shouldn't be possible without heavy interference from other plugins. | ||
| context.error( | ||
| `Module "${importer}" was identified as but not declared as a trusted prelude importer.`, | ||
| ); | ||
| } | ||
| return trustedPrelude; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a double take reading this, because I wasn't sure if trustedPrelude referred to a filename or the actual trusted prelude content. This confusion can be precluded by:
| getTrustedPrelude = (context: PluginContext, importer: string) => { | |
| const trustedPrelude = resolvedTrustingImporters.get(importer); | |
| // Ensure importer was declared and recognized as a trusting importer. | |
| if (!trustedPrelude) { | |
| // Shouldn't be possible without heavy interference from other plugins. | |
| context.error( | |
| `Module "${importer}" was identified as but not declared as a trusted prelude importer.`, | |
| ); | |
| } | |
| return trustedPrelude; | |
| }; | |
| getTrustedPreludeFileName = (context: PluginContext, importer: string) => { | |
| const trustedPreludeFileName = resolvedTrustingImporters.get(importer); | |
| // Ensure importer was declared and recognized as a trusting importer. | |
| if (!trustedPreludeFileName) { | |
| // Shouldn't be possible without heavy interference from other plugins. | |
| context.error( | |
| `Module "${importer}" was identified as but not declared as a trusted prelude importer.`, | |
| ); | |
| } | |
| return trustedPreludeFileName; | |
| }; |
rekmarks
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
Closes: #14
We call steps 1 and 2 in order the trusted prelude of a file.
The strategy is to break out trusted preludes into their own file, and mark them as external (ignored by the bundler and statically copied into dist). In files which import the trusted header, we check if their first line in dist is to import the trusted header.
For total compartmentalization of this security concern, we should implement build tests that checks for first line
import '*-trusted-prelude.js';in minimally reliant proper js.