mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-22 18:00:18 +01:00
7199d9c567
An externally hosted phishing warning page is now used rather than the built-in phishing warning page.The phishing page warning URL is set via configuration file or environment variable. The default URL is either the expected production URL or `http://localhost:9999/` for e2e testing environments. The new external phishing page includes a design change when it is loaded within an iframe. In that case it now shows a condensed message, and prompts the user to open the full warning page in a new tab to see more details or bypass the warning. This is to prevent a clickjacking attack from safelisting a site without user consent. The new external phishing page also includes a simple caching service worker to ensure it continues to work offline (or if our hosting goes offline), as long as the user has successfully loaded the page at least once. We also load the page temporarily during the extension startup process to trigger the service worker installation. The old phishing page and all related lines have been removed. The property `web_accessible_resources` has also been removed from the manifest. The only entry apart from the phishing page was `inpage.js`, and we don't need that to be web accessible anymore because we inject the script inline into each page rather than loading the file directly. New e2e tests have been added to cover more phishing warning page functionality, including the "safelist" action and the "iframe" case.
363 lines
10 KiB
JavaScript
363 lines
10 KiB
JavaScript
import querystring from 'querystring';
|
|
import pump from 'pump';
|
|
import { WindowPostMessageStream } from '@metamask/post-message-stream';
|
|
import ObjectMultiplex from 'obj-multiplex';
|
|
import browser from 'webextension-polyfill';
|
|
import PortStream from 'extension-port-stream';
|
|
import { obj as createThoughStream } from 'through2';
|
|
|
|
// These require calls need to use require to be statically recognized by browserify
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const inpageContent = fs.readFileSync(
|
|
path.join(__dirname, '..', '..', 'dist', 'chrome', 'inpage.js'),
|
|
'utf8',
|
|
);
|
|
const inpageSuffix = `//# sourceURL=${browser.runtime.getURL('inpage.js')}\n`;
|
|
const inpageBundle = inpageContent + inpageSuffix;
|
|
|
|
// contexts
|
|
const CONTENT_SCRIPT = 'metamask-contentscript';
|
|
const INPAGE = 'metamask-inpage';
|
|
const PHISHING_WARNING_PAGE = 'metamask-phishing-warning-page';
|
|
|
|
// stream channels
|
|
const PHISHING_SAFELIST = 'metamask-phishing-safelist';
|
|
const PROVIDER = 'metamask-provider';
|
|
|
|
// TODO:LegacyProvider: Delete
|
|
const LEGACY_CONTENT_SCRIPT = 'contentscript';
|
|
const LEGACY_INPAGE = 'inpage';
|
|
const LEGACY_PROVIDER = 'provider';
|
|
const LEGACY_PUBLIC_CONFIG = 'publicConfig';
|
|
|
|
const phishingPageUrl = new URL(process.env.PHISHING_PAGE_URL);
|
|
|
|
if (
|
|
window.location.origin === phishingPageUrl.origin &&
|
|
window.location.pathname === phishingPageUrl.pathname
|
|
) {
|
|
setupPhishingStream();
|
|
} else if (shouldInjectProvider()) {
|
|
injectScript(inpageBundle);
|
|
setupStreams();
|
|
}
|
|
|
|
/**
|
|
* Injects a script tag into the current document
|
|
*
|
|
* @param {string} content - Code to be executed in the current document
|
|
*/
|
|
function injectScript(content) {
|
|
try {
|
|
const container = document.head || document.documentElement;
|
|
const scriptTag = document.createElement('script');
|
|
scriptTag.setAttribute('async', 'false');
|
|
scriptTag.textContent = content;
|
|
container.insertBefore(scriptTag, container.children[0]);
|
|
container.removeChild(scriptTag);
|
|
} catch (error) {
|
|
console.error('MetaMask: Provider injection failed.', error);
|
|
}
|
|
}
|
|
|
|
async function setupPhishingStream() {
|
|
// the transport-specific streams for communication between inpage and background
|
|
const pageStream = new WindowPostMessageStream({
|
|
name: CONTENT_SCRIPT,
|
|
target: PHISHING_WARNING_PAGE,
|
|
});
|
|
const extensionPort = browser.runtime.connect({ name: CONTENT_SCRIPT });
|
|
const extensionStream = new PortStream(extensionPort);
|
|
|
|
// create and connect channel muxers
|
|
// so we can handle the channels individually
|
|
const pageMux = new ObjectMultiplex();
|
|
pageMux.setMaxListeners(25);
|
|
const extensionMux = new ObjectMultiplex();
|
|
extensionMux.setMaxListeners(25);
|
|
|
|
pump(pageMux, pageStream, pageMux, (err) =>
|
|
logStreamDisconnectWarning('MetaMask Inpage Multiplex', err),
|
|
);
|
|
pump(extensionMux, extensionStream, extensionMux, (err) => {
|
|
logStreamDisconnectWarning('MetaMask Background Multiplex', err);
|
|
window.postMessage(
|
|
{
|
|
target: PHISHING_WARNING_PAGE, // the post-message-stream "target"
|
|
data: {
|
|
// this object gets passed to obj-multiplex
|
|
name: PHISHING_SAFELIST, // the obj-multiplex channel name
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
method: 'METAMASK_STREAM_FAILURE',
|
|
},
|
|
},
|
|
},
|
|
window.location.origin,
|
|
);
|
|
});
|
|
|
|
// forward communication across inpage-background for these channels only
|
|
forwardTrafficBetweenMuxes(PHISHING_SAFELIST, pageMux, extensionMux);
|
|
}
|
|
|
|
/**
|
|
* Sets up two-way communication streams between the
|
|
* browser extension and local per-page browser context.
|
|
*
|
|
*/
|
|
async function setupStreams() {
|
|
// the transport-specific streams for communication between inpage and background
|
|
const pageStream = new WindowPostMessageStream({
|
|
name: CONTENT_SCRIPT,
|
|
target: INPAGE,
|
|
});
|
|
const extensionPort = browser.runtime.connect({ name: CONTENT_SCRIPT });
|
|
const extensionStream = new PortStream(extensionPort);
|
|
|
|
// create and connect channel muxers
|
|
// so we can handle the channels individually
|
|
const pageMux = new ObjectMultiplex();
|
|
pageMux.setMaxListeners(25);
|
|
const extensionMux = new ObjectMultiplex();
|
|
extensionMux.setMaxListeners(25);
|
|
extensionMux.ignoreStream(LEGACY_PUBLIC_CONFIG); // TODO:LegacyProvider: Delete
|
|
|
|
pump(pageMux, pageStream, pageMux, (err) =>
|
|
logStreamDisconnectWarning('MetaMask Inpage Multiplex', err),
|
|
);
|
|
pump(extensionMux, extensionStream, extensionMux, (err) => {
|
|
logStreamDisconnectWarning('MetaMask Background Multiplex', err);
|
|
notifyInpageOfStreamFailure();
|
|
});
|
|
|
|
// forward communication across inpage-background for these channels only
|
|
forwardTrafficBetweenMuxes(PROVIDER, pageMux, extensionMux);
|
|
|
|
// connect "phishing" channel to warning system
|
|
const phishingStream = extensionMux.createStream('phishing');
|
|
phishingStream.once('data', redirectToPhishingWarning);
|
|
|
|
// TODO:LegacyProvider: Delete
|
|
// handle legacy provider
|
|
const legacyPageStream = new WindowPostMessageStream({
|
|
name: LEGACY_CONTENT_SCRIPT,
|
|
target: LEGACY_INPAGE,
|
|
});
|
|
|
|
const legacyPageMux = new ObjectMultiplex();
|
|
legacyPageMux.setMaxListeners(25);
|
|
const legacyExtensionMux = new ObjectMultiplex();
|
|
legacyExtensionMux.setMaxListeners(25);
|
|
|
|
pump(legacyPageMux, legacyPageStream, legacyPageMux, (err) =>
|
|
logStreamDisconnectWarning('MetaMask Legacy Inpage Multiplex', err),
|
|
);
|
|
pump(
|
|
legacyExtensionMux,
|
|
extensionStream,
|
|
getNotificationTransformStream(),
|
|
legacyExtensionMux,
|
|
(err) => {
|
|
logStreamDisconnectWarning('MetaMask Background Legacy Multiplex', err);
|
|
notifyInpageOfStreamFailure();
|
|
},
|
|
);
|
|
|
|
forwardNamedTrafficBetweenMuxes(
|
|
LEGACY_PROVIDER,
|
|
PROVIDER,
|
|
legacyPageMux,
|
|
legacyExtensionMux,
|
|
);
|
|
forwardTrafficBetweenMuxes(
|
|
LEGACY_PUBLIC_CONFIG,
|
|
legacyPageMux,
|
|
legacyExtensionMux,
|
|
);
|
|
}
|
|
|
|
function forwardTrafficBetweenMuxes(channelName, muxA, muxB) {
|
|
const channelA = muxA.createStream(channelName);
|
|
const channelB = muxB.createStream(channelName);
|
|
pump(channelA, channelB, channelA, (error) =>
|
|
console.debug(
|
|
`MetaMask: Muxed traffic for channel "${channelName}" failed.`,
|
|
error,
|
|
),
|
|
);
|
|
}
|
|
|
|
// TODO:LegacyProvider: Delete
|
|
function forwardNamedTrafficBetweenMuxes(
|
|
channelAName,
|
|
channelBName,
|
|
muxA,
|
|
muxB,
|
|
) {
|
|
const channelA = muxA.createStream(channelAName);
|
|
const channelB = muxB.createStream(channelBName);
|
|
pump(channelA, channelB, channelA, (error) =>
|
|
console.debug(
|
|
`MetaMask: Muxed traffic between channels "${channelAName}" and "${channelBName}" failed.`,
|
|
error,
|
|
),
|
|
);
|
|
}
|
|
|
|
// TODO:LegacyProvider: Delete
|
|
function getNotificationTransformStream() {
|
|
return createThoughStream((chunk, _, cb) => {
|
|
if (chunk?.name === PROVIDER) {
|
|
if (chunk.data?.method === 'metamask_accountsChanged') {
|
|
chunk.data.method = 'wallet_accountsChanged';
|
|
chunk.data.result = chunk.data.params;
|
|
delete chunk.data.params;
|
|
}
|
|
}
|
|
cb(null, chunk);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Error handler for page to extension stream disconnections
|
|
*
|
|
* @param {string} remoteLabel - Remote stream name
|
|
* @param {Error} error - Stream connection error
|
|
*/
|
|
function logStreamDisconnectWarning(remoteLabel, error) {
|
|
console.debug(
|
|
`MetaMask: Content script lost connection to "${remoteLabel}".`,
|
|
error,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* This function must ONLY be called in pump destruction/close callbacks.
|
|
* Notifies the inpage context that streams have failed, via window.postMessage.
|
|
* Relies on obj-multiplex and post-message-stream implementation details.
|
|
*/
|
|
function notifyInpageOfStreamFailure() {
|
|
window.postMessage(
|
|
{
|
|
target: INPAGE, // the post-message-stream "target"
|
|
data: {
|
|
// this object gets passed to obj-multiplex
|
|
name: PROVIDER, // the obj-multiplex channel name
|
|
data: {
|
|
jsonrpc: '2.0',
|
|
method: 'METAMASK_STREAM_FAILURE',
|
|
},
|
|
},
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determines if the provider should be injected
|
|
*
|
|
* @returns {boolean} {@code true} Whether the provider should be injected
|
|
*/
|
|
function shouldInjectProvider() {
|
|
return (
|
|
doctypeCheck() &&
|
|
suffixCheck() &&
|
|
documentElementCheck() &&
|
|
!blockedDomainCheck()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Checks the doctype of the current document if it exists
|
|
*
|
|
* @returns {boolean} {@code true} if the doctype is html or if none exists
|
|
*/
|
|
function doctypeCheck() {
|
|
const { doctype } = window.document;
|
|
if (doctype) {
|
|
return doctype.name === 'html';
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Returns whether or not the extension (suffix) of the current document is prohibited
|
|
*
|
|
* This checks {@code window.location.pathname} against a set of file extensions
|
|
* that we should not inject the provider into. This check is indifferent of
|
|
* query parameters in the location.
|
|
*
|
|
* @returns {boolean} whether or not the extension of the current document is prohibited
|
|
*/
|
|
function suffixCheck() {
|
|
const prohibitedTypes = [/\.xml$/u, /\.pdf$/u];
|
|
const currentUrl = window.location.pathname;
|
|
for (let i = 0; i < prohibitedTypes.length; i++) {
|
|
if (prohibitedTypes[i].test(currentUrl)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks the documentElement of the current document
|
|
*
|
|
* @returns {boolean} {@code true} if the documentElement is an html node or if none exists
|
|
*/
|
|
function documentElementCheck() {
|
|
const documentElement = document.documentElement.nodeName;
|
|
if (documentElement) {
|
|
return documentElement.toLowerCase() === 'html';
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Checks if the current domain is blocked
|
|
*
|
|
* @returns {boolean} {@code true} if the current domain is blocked
|
|
*/
|
|
function blockedDomainCheck() {
|
|
const blockedDomains = [
|
|
'uscourts.gov',
|
|
'dropbox.com',
|
|
'webbyawards.com',
|
|
'cdn.shopify.com/s/javascripts/tricorder/xtld-read-only-frame.html',
|
|
'adyen.com',
|
|
'gravityforms.com',
|
|
'harbourair.com',
|
|
'ani.gamer.com.tw',
|
|
'blueskybooking.com',
|
|
'sharefile.com',
|
|
];
|
|
const currentUrl = window.location.href;
|
|
let currentRegex;
|
|
for (let i = 0; i < blockedDomains.length; i++) {
|
|
const blockedDomain = blockedDomains[i].replace('.', '\\.');
|
|
currentRegex = new RegExp(
|
|
`(?:https?:\\/\\/)(?:(?!${blockedDomain}).)*$`,
|
|
'u',
|
|
);
|
|
if (!currentRegex.test(currentUrl)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Redirects the current page to a phishing information page
|
|
*/
|
|
function redirectToPhishingWarning() {
|
|
console.debug('MetaMask: Routing to Phishing Warning page.');
|
|
const baseUrl = process.env.PHISHING_PAGE_URL;
|
|
window.location.href = `${baseUrl}#${querystring.stringify({
|
|
hostname: window.location.hostname,
|
|
href: window.location.href,
|
|
})}`;
|
|
}
|