@metamask/snaps-utils
Version:
A collection of utilities for MetaMask Snaps
339 lines • 16.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.postProcessBundle = exports.PostProcessWarning = void 0;
const core_1 = require("@babel/core");
const types_1 = require("@babel/types");
var PostProcessWarning;
(function (PostProcessWarning) {
PostProcessWarning["UnsafeMathRandom"] = "`Math.random` was detected in the Snap bundle. This is not a secure source of randomness, and should not be used in a secure context. Use `crypto.getRandomValues` instead.";
})(PostProcessWarning || (exports.PostProcessWarning = PostProcessWarning = {}));
// The RegEx below consists of multiple groups joined by a boolean OR.
// Each part consists of two groups which capture a part of each string
// which needs to be split up, e.g., `<!--` is split into `<!` and `--`.
const TOKEN_REGEX = /(<!)(--)|(--)(>)|(--)(!)(>)|(import)(\(.*?\))/gu;
// An empty template element, i.e., a part of a template literal without any
// value ("").
const EMPTY_TEMPLATE_ELEMENT = (0, types_1.templateElement)({ raw: '', cooked: '' });
const evalWrapper = core_1.template.statement(`
(1, REF)(ARGS)
`);
const objectEvalWrapper = core_1.template.statement(`
(1, OBJECT.REF)
`);
const regeneratorRuntimeWrapper = core_1.template.statement(`
var regeneratorRuntime;
`);
/**
* Breaks up tokens that would otherwise result in SES errors. The tokens are
* broken up in a non-destructive way where possible. Currently works with:
* - HTML comment tags `<!--` and `-->`, broken up into `<!`, `--`, and `--`,
* `>`.
* - `import(n)` statements, broken up into `import`, `(n)`.
*
* @param value - The string value to break up.
* @returns The string split into an array, in a way that it can be joined
* together to form the same string, but with the tokens separated into single
* array elements.
*/
function breakTokens(value) {
const tokens = value.split(TOKEN_REGEX);
return (tokens
// TODO: The `split` above results in some values being `undefined`.
// There may be a better solution to avoid having to filter those out.
.filter((token) => token !== '' && token !== undefined));
}
/**
* Breaks up tokens that would otherwise result in SES errors. The tokens are
* broken up in a non-destructive way where possible. Currently works with:
* - HTML comment tags `<!--` and `-->`, broken up into `<!`, `--`, and `--`,
* `>`.
* - `import(n)` statements, broken up into `import`, `(n)`.
*
* @param value - The string value to break up.
* @returns The string split into a tuple consisting of the new template
* elements and string literal expressions.
*/
function breakTokensTemplateLiteral(value) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore `matchAll` is not available in ES2017, but this code
// should only be used in environments where the function is supported.
const matches = Array.from(value.matchAll(TOKEN_REGEX));
if (matches.length > 0) {
const output = matches.reduce(([elements, expressions], rawMatch, index, values) => {
const [, first, last] = rawMatch.filter((raw) => raw !== undefined);
// Slice the text in front of the match, which does not need to be
// broken up.
const prefix = value.slice(index === 0
? 0
: values[index - 1].index + values[index - 1][0].length, rawMatch.index);
return [
[
...elements,
(0, types_1.templateElement)({
raw: getRawTemplateValue(prefix),
cooked: prefix,
}),
EMPTY_TEMPLATE_ELEMENT,
],
[...expressions, (0, types_1.stringLiteral)(first), (0, types_1.stringLiteral)(last)],
];
}, [[], []]);
// Add the text after the last match to the output.
const lastMatch = matches[matches.length - 1];
const suffix = value.slice(lastMatch.index + lastMatch[0].length);
return [
[
...output[0],
(0, types_1.templateElement)({ raw: getRawTemplateValue(suffix), cooked: suffix }),
],
output[1],
];
}
// If there are no matches, simply return the original value.
return [
[(0, types_1.templateElement)({ raw: getRawTemplateValue(value), cooked: value })],
[],
];
}
/**
* Get a raw template literal value from a cooked value. This adds a backslash
* before every '`', '\' and '${' characters.
*
* @see https://github.com/babel/babel/issues/9242#issuecomment-532529613
* @param value - The cooked string to get the raw string for.
* @returns The value as raw value.
*/
function getRawTemplateValue(value) {
return value.replace(/\\|`|\$\{/gu, '\\$&');
}
/**
* Post process code with AST such that it can be evaluated in SES.
*
* Currently:
* - Makes all direct calls to eval indirect.
* - Handles certain Babel-related edge cases.
* - Removes the `Buffer` provided by Browserify.
* - Optionally removes comments.
* - Breaks up tokens that would otherwise result in SES errors, such as HTML
* comment tags `<!--` and `-->` and `import(n)` statements.
*
* @param code - The code to post process.
* @param options - The post-process options.
* @param options.stripComments - Whether to strip comments. Defaults to `true`.
* @param options.sourceMap - Whether to generate a source map for the modified
* code. See also `inputSourceMap`.
* @param options.inputSourceMap - The source map for the input code. When
* provided, the source map will be used to generate a source map for the
* modified code. This ensures that the source map is correct for the modified
* code, and still points to the original source. If not provided, a new source
* map will be generated instead.
* @returns An object containing the modified code, and source map, or null if
* the provided code is null.
*/
function postProcessBundle(code, { stripComments = true, sourceMap: sourceMaps, inputSourceMap, } = {}) {
const warnings = new Set();
const pre = ({ ast }) => {
ast.comments?.forEach((comment) => {
// Break up tokens that could be parsed as HTML comment terminators. The
// regular expressions below are written strangely so as to avoid the
// appearance of such tokens in our source code. For reference:
// https://github.com/endojs/endo/blob/70cc86eb400655e922413b99c38818d7b2e79da0/packages/ses/error-codes/SES_HTML_COMMENT_REJECTED.md
comment.value = comment.value
.replace(new RegExp(`<!${'--'}`, 'gu'), '< !--')
.replace(new RegExp(`${'--'}>`, 'gu'), '-- >')
.replace(/import(\(.*\))/gu, 'import\\$1');
});
};
const visitor = {
FunctionExpression(path) {
const { node } = path;
// Browserify provides the `Buffer` global as an argument to modules that
// use it, but this does not work in SES. Since we pass in `Buffer` as an
// endowment, we can simply remove the argument.
//
// Note that this only removes `Buffer` from a wrapped function
// expression, e.g., `(function (Buffer) { ... })`. Regular functions
// are not affected.
//
// TODO: Since we're working on the AST level, we could check the scope
// of the function expression, and possibly prevent false positives?
if (node.type === 'FunctionExpression' && node.extra?.parenthesized) {
node.params = node.params.filter((param) => !(param.type === 'Identifier' && param.name === 'Buffer'));
}
},
CallExpression(path) {
const { node } = path;
// Replace `eval(foo)` with `(1, eval)(foo)`.
if (node.callee.type === 'Identifier' && node.callee.name === 'eval') {
path.replaceWith(evalWrapper({
REF: node.callee,
ARGS: node.arguments,
}));
}
// Detect the use of `Math.random()` and add a warning.
if (node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Math' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'random') {
warnings.add(PostProcessWarning.UnsafeMathRandom);
}
},
MemberExpression(path) {
const { node } = path;
// Replace `object.eval(foo)` with `(1, object.eval)(foo)`.
if (node.property.type === 'Identifier' &&
node.property.name === 'eval' &&
// We only apply this to MemberExpressions that are the callee of CallExpression
path.parent.type === 'CallExpression' &&
path.parent.callee === node) {
path.replaceWith(objectEvalWrapper({
OBJECT: node.object,
REF: node.property,
}));
}
},
Identifier(path) {
const { node } = path;
// Insert `regeneratorRuntime` global if it's used in the code.
if (node.name === 'regeneratorRuntime') {
const program = path.findParent((parent) => parent.node.type === 'Program');
// We know that `program` is a Program node here, but this keeps
// TypeScript happy.
if (program?.node.type === 'Program') {
const body = program.node.body[0];
// This stops it from inserting `regeneratorRuntime` multiple times.
if (body.type === 'VariableDeclaration' &&
body.declarations[0].id.name ===
'regeneratorRuntime') {
return;
}
program?.node.body.unshift(regeneratorRuntimeWrapper());
}
}
},
TemplateLiteral(path) {
const { node } = path;
// This checks if the template literal was visited before. Without this,
// it would cause an infinite loop resulting in a stack overflow. We can't
// skip the path here, because we need to visit the children of the node.
if (path.getData('visited')) {
return;
}
// Break up tokens that could be parsed as HTML comment terminators, or
// `import()` statements.
// For reference:
// - https://github.com/endojs/endo/blob/70cc86eb400655e922413b99c38818d7b2e79da0/packages/ses/error-codes/SES_HTML_COMMENT_REJECTED.md
// - https://github.com/MetaMask/snaps-monorepo/issues/505
const [replacementQuasis, replacementExpressions] = node.quasis.reduce(([elements, expressions], quasi, index) => {
// Note: Template literals have two variants, "cooked" and "raw". Here
// we use the cooked version.
// https://exploringjs.com/impatient-js/ch_template-literals.html#template-strings-cooked-vs-raw
const tokens = breakTokensTemplateLiteral(quasi.value.cooked);
// Only update the node if something changed.
if (tokens[0].length <= 1) {
return [
[...elements, quasi],
[...expressions, node.expressions[index]],
];
}
return [
[...elements, ...tokens[0]],
[
...expressions,
...tokens[1],
node.expressions[index],
],
];
}, [[], []]);
path.replaceWith((0, types_1.templateLiteral)(replacementQuasis, replacementExpressions.filter((expression) => expression !== undefined)));
path.setData('visited', true);
},
StringLiteral(path) {
const { node } = path;
// Break up tokens that could be parsed as HTML comment terminators, or
// `import()` statements.
// For reference:
// - https://github.com/endojs/endo/blob/70cc86eb400655e922413b99c38818d7b2e79da0/packages/ses/error-codes/SES_HTML_COMMENT_REJECTED.md
// - https://github.com/MetaMask/snaps-monorepo/issues/505
const tokens = breakTokens(node.value);
// Only update the node if the string literal was broken up.
if (tokens.length <= 1) {
return;
}
const replacement = tokens
.slice(1)
.reduce((acc, value) => (0, types_1.binaryExpression)('+', acc, (0, types_1.stringLiteral)(value)), (0, types_1.stringLiteral)(tokens[0]));
path.replaceWith(replacement);
path.skip();
},
BinaryExpression(path) {
const { node } = path;
const errorMessage = 'Using HTML comments (`<!--` and `-->`) as operators is not allowed. The behaviour of ' +
'these comments is ambiguous, and differs per browser and environment. If you want ' +
'to use them as operators, break them up into separate characters, i.e., `a-- > b` ' +
'and `a < ! --b`.';
if (node.operator === '<' &&
(0, types_1.isUnaryExpression)(node.right) &&
(0, types_1.isUpdateExpression)(node.right.argument) &&
node.right.argument.operator === '--' &&
node.left.end &&
node.right.argument.argument.start) {
const expression = code.slice(node.left.end, node.right.argument.argument.start);
if (expression.includes('<!--')) {
throw new Error(errorMessage);
}
}
if (node.operator === '>' &&
(0, types_1.isUpdateExpression)(node.left) &&
node.left.operator === '--' &&
node.left.argument.end &&
node.right.start) {
const expression = code.slice(node.left.argument.end, node.right.start);
if (expression.includes('-->')) {
throw new Error(errorMessage);
}
}
},
};
try {
const file = (0, core_1.transformSync)(code, {
// Prevent Babel from searching for a config file.
configFile: false,
parserOpts: {
// Strict mode isn't enabled by default, so we need to enable it here.
strictMode: true,
// If this is disabled, the AST does not include any comments. This is
// useful for performance reasons, and we use it for stripping comments.
attachComment: !stripComments,
},
// By default, Babel optimises bundles that exceed 500 KB, but that
// results in characters which look like HTML comments, which breaks SES.
compact: false,
// This configures Babel to generate a new source map from the existing
// source map if specified. If `sourceMap` is `true` but an input source
// map is not provided, a new source map will be generated instead.
inputSourceMap,
sourceMaps,
plugins: [
() => ({
pre,
visitor,
}),
],
});
if (!file?.code) {
throw new Error('Bundled code is empty.');
}
return {
code: file.code,
sourceMap: file.map,
warnings: Array.from(warnings),
};
}
catch (error) {
throw new Error(`Failed to post process code:\n${error.message}`);
}
}
exports.postProcessBundle = postProcessBundle;
//# sourceMappingURL=post-process.cjs.map