diff --git a/app/manifest/_base.json b/app/manifest/_base.json index c2b20b4db..87bed959d 100644 --- a/app/manifest/_base.json +++ b/app/manifest/_base.json @@ -72,6 +72,5 @@ "*://*.eth/", "notifications" ], - "short_name": "__MSG_appName__", - "web_accessible_resources": ["inpage.js", "phishing.html"] + "short_name": "__MSG_appName__" } diff --git a/app/phishing.html b/app/phishing.html deleted file mode 100644 index e854d79fa..000000000 --- a/app/phishing.html +++ /dev/null @@ -1,150 +0,0 @@ - - - - MetaMask Phishing Detection - - - - - - - - - - -
-
- MetaMask Logo -

- - MetaMask Phishing Detection -

-
-
-

- This domain is currently on the MetaMask domain warning list. This - means that based on information available to us, MetaMask believes - this domain could currently compromise your security and, as an added - safety feature, MetaMask has restricted access to the site. To - override this, please read the rest of this warning for instructions - on how to continue at your own risk. -

-

- There are many reasons sites can appear on our warning list, and our - warning list compiles from other widely used industry lists. Such - reasons can include known fraud or security risks, such as domains - that test positive on the - Ethereum Phishing Detector. Domains on these warning lists may include outright malicious - websites and legitimate websites that have been compromised by a - malicious actor. -

-

- To read more about this site - please search for the domain on CryptoScamDB. -

-

- Note that this warning list is compiled on a voluntary basis. This - list may be inaccurate or incomplete. Just because a domain does not - appear on this list is not an implicit guarantee of that domain's - safety. As always, your transactions are your own responsibility. If - you wish to interact with any domain on our warning list, you can do - so by continuing at your own risk. -

-

- If you think this domain is incorrectly flagged or if a blocked - legitimate website has resolved its security issues, - please file an issue. -

-
-
- - diff --git a/app/scripts/background.js b/app/scripts/background.js index ae85d781b..5e3cd7770 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -67,6 +67,12 @@ if (inTest || process.env.METAMASK_DEBUG) { global.metamaskGetState = localStore.get.bind(localStore); } +const phishingPageUrl = new URL(process.env.PHISHING_PAGE_URL); + +const ONE_SECOND_IN_MILLISECONDS = 1_000; +// Timeout for initializing phishing warning page. +const PHISHING_WARNING_PAGE_TIMEOUT = ONE_SECOND_IN_MILLISECONDS; + // initialization flow initialize().catch(log.error); @@ -134,9 +140,76 @@ async function initialize() { const initState = await loadStateFromPersistence(); const initLangCode = await getFirstPreferredLangCode(); await setupController(initState, initLangCode); + await loadPhishingWarningPage(); log.info('MetaMask initialization complete.'); } +/** + * An error thrown if the phishing warning page takes too long to load. + */ +class PhishingWarningPageTimeoutError extends Error { + constructor() { + super('Timeout failed'); + } +} + +/** + * Load the phishing warning page temporarily to ensure the service + * worker has been registered, so that the warning page works offline. + */ +async function loadPhishingWarningPage() { + let iframe; + try { + const extensionStartupPhishingPageUrl = new URL( + process.env.PHISHING_PAGE_URL, + ); + // The `extensionStartup` hash signals to the phishing warning page that it should not bother + // setting up streams for user interaction. Otherwise this page load would cause a console + // error. + extensionStartupPhishingPageUrl.hash = '#extensionStartup'; + + iframe = window.document.createElement('iframe'); + iframe.setAttribute('src', extensionStartupPhishingPageUrl.href); + iframe.setAttribute('sandbox', 'allow-scripts allow-same-origin'); + + // Create "deferred Promise" to allow passing resolve/reject to event handlers + let deferredResolve; + let deferredReject; + const loadComplete = new Promise((resolve, reject) => { + deferredResolve = resolve; + deferredReject = reject; + }); + + // The load event is emitted once loading has completed, even if the loading failed. + // If loading failed we can't do anything about it, so we don't need to check. + iframe.addEventListener('load', deferredResolve); + + // This step initiates the page loading. + window.document.body.appendChild(iframe); + + // This timeout ensures that this iframe gets cleaned up in a reasonable + // timeframe, and ensures that the "initialization complete" message + // doesn't get delayed too long. + setTimeout( + () => deferredReject(new PhishingWarningPageTimeoutError()), + PHISHING_WARNING_PAGE_TIMEOUT, + ); + await loadComplete; + } catch (error) { + if (error instanceof PhishingWarningPageTimeoutError) { + console.warn( + 'Phishing warning page timeout; page not guaraneteed to work offline.', + ); + } else { + console.error('Failed to initialize phishing warning page', error); + } + } finally { + if (iframe) { + iframe.remove(); + } + } +} + // // State and Persistence // @@ -362,6 +435,10 @@ function setupController(initState, initLangCode) { remotePort.sender.origin === `chrome-extension://${browser.runtime.id}`; } + const senderUrl = remotePort.sender?.url + ? new URL(remotePort.sender.url) + : null; + if (isMetaMaskInternalProcess) { const portStream = new PortStream(remotePort); // communication with popup @@ -406,6 +483,15 @@ function setupController(initState, initLangCode) { ); }); } + } else if ( + senderUrl && + senderUrl.origin === phishingPageUrl.origin && + senderUrl.pathname === phishingPageUrl.pathname + ) { + const portStream = new PortStream(remotePort); + controller.setupPhishingCommunication({ + connectionStream: portStream, + }); } else { if (remotePort.sender && remotePort.sender.tab && remotePort.sender.url) { const tabId = remotePort.sender.tab.id; diff --git a/app/scripts/contentscript.js b/app/scripts/contentscript.js index 037f3de14..57850a535 100644 --- a/app/scripts/contentscript.js +++ b/app/scripts/contentscript.js @@ -17,8 +17,13 @@ const inpageContent = fs.readFileSync( 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 @@ -27,7 +32,14 @@ const LEGACY_INPAGE = 'inpage'; const LEGACY_PROVIDER = 'provider'; const LEGACY_PUBLIC_CONFIG = 'publicConfig'; -if (shouldInjectProvider()) { +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(); } @@ -50,6 +62,47 @@ function injectScript(content) { } } +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. @@ -300,9 +353,9 @@ function blockedDomainCheck() { * Redirects the current page to a phishing information page */ function redirectToPhishingWarning() { - console.debug('MetaMask: Routing to Phishing Warning component.'); - const extensionURL = browser.runtime.getURL('phishing.html'); - window.location.href = `${extensionURL}#${querystring.stringify({ + 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, })}`; diff --git a/app/scripts/lib/util.js b/app/scripts/lib/util.js index 9800666af..b7fdf0521 100644 --- a/app/scripts/lib/util.js +++ b/app/scripts/lib/util.js @@ -27,7 +27,7 @@ const getEnvironmentTypeMemo = memoize((url) => { const parsedUrl = new URL(url); if (parsedUrl.pathname === '/popup.html') { return ENVIRONMENT_TYPE_POPUP; - } else if (['/home.html', '/phishing.html'].includes(parsedUrl.pathname)) { + } else if (['/home.html'].includes(parsedUrl.pathname)) { return ENVIRONMENT_TYPE_FULLSCREEN; } else if (parsedUrl.pathname === '/notification.html') { return ENVIRONMENT_TYPE_NOTIFICATION; diff --git a/app/scripts/lib/util.test.js b/app/scripts/lib/util.test.js index 62c33b5a5..0892a4e3d 100644 --- a/app/scripts/lib/util.test.js +++ b/app/scripts/lib/util.test.js @@ -34,13 +34,6 @@ describe('app utils', () => { expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_FULLSCREEN); }); - it('should return fullscreen type for phishing.html', () => { - const environmentType = getEnvironmentType( - 'http://extension-id/phishing.html', - ); - expect(environmentType).toStrictEqual(ENVIRONMENT_TYPE_FULLSCREEN); - }); - it('should return background type', () => { const environmentType = getEnvironmentType( 'http://extension-id/_generated_background_page.html', diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index e5e8493fd..c307068d2 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -145,6 +145,9 @@ export const METAMASK_CONTROLLER_EVENTS = { APPROVAL_STATE_CHANGE: 'ApprovalController:stateChange', }; +// stream channels +const PHISHING_SAFELIST = 'metamask-phishing-safelist'; + export default class MetamaskController extends EventEmitter { /** * @param {Object} opts @@ -1371,7 +1374,6 @@ export default class MetamaskController extends EventEmitter { ), markPasswordForgotten: this.markPasswordForgotten.bind(this), unMarkPasswordForgotten: this.unMarkPasswordForgotten.bind(this), - safelistPhishingDomain: this.safelistPhishingDomain.bind(this), getRequestAccountTabIds: this.getRequestAccountTabIds, getOpenMetamaskTabsIds: this.getOpenMetamaskTabsIds, markNotificationPopupAsAutomaticallyClosed: () => @@ -3152,6 +3154,33 @@ export default class MetamaskController extends EventEmitter { ); } + /** + * Used to create a multiplexed stream for connecting to the phishing warning page. + * + * @param options - Options bag. + * @param {ReadableStream} options.connectionStream - The Duplex stream to connect to. + */ + setupPhishingCommunication({ connectionStream }) { + const { usePhishDetect } = this.preferencesController.store.getState(); + + if (!usePhishDetect) { + return; + } + + // setup multiplexing + const mux = setupMultiplex(connectionStream); + const phishingStream = mux.createStream(PHISHING_SAFELIST); + + // set up postStream transport + phishingStream.on( + 'data', + createMetaRPCHandler( + { safelistPhishingDomain: this.safelistPhishingDomain.bind(this) }, + phishingStream, + ), + ); + } + /** * Called when we detect a suspicious domain. Requests the browser redirects * to our anti-phishing page. diff --git a/app/scripts/phishing-detect.js b/app/scripts/phishing-detect.js deleted file mode 100644 index f926b234d..000000000 --- a/app/scripts/phishing-detect.js +++ /dev/null @@ -1,52 +0,0 @@ -import querystring from 'querystring'; -import PortStream from 'extension-port-stream'; -import browser from 'webextension-polyfill'; -import createRandomId from '../../shared/modules/random-id'; -import { setupMultiplex } from './lib/stream-utils'; -import { getEnvironmentType } from './lib/util'; -import ExtensionPlatform from './platforms/extension'; - -document.addEventListener('DOMContentLoaded', start); - -function start() { - const hash = window.location.hash.substring(1); - const suspect = querystring.parse(hash); - - const newIssueLink = document.getElementById('new-issue-link'); - const newIssueUrl = `https://github.com/MetaMask/eth-phishing-detect/issues/new`; - const newIssueParams = `?title=[Legitimate%20Site%20Blocked]%20${encodeURIComponent( - suspect.hostname, - )}&body=${encodeURIComponent(suspect.href)}`; - newIssueLink.href = `${newIssueUrl}${newIssueParams}`; - - global.platform = new ExtensionPlatform(); - - const extensionPort = browser.runtime.connect({ - name: getEnvironmentType(), - }); - const connectionStream = new PortStream(extensionPort); - const mx = setupMultiplex(connectionStream); - const backgroundConnection = mx.createStream('controller'); - const continueLink = document.getElementById('unsafe-continue'); - continueLink.addEventListener('click', () => { - backgroundConnection.write({ - jsonrpc: '2.0', - method: 'safelistPhishingDomain', - params: [suspect.hostname], - id: createRandomId(), - }); - const redirectTarget = new URL(suspect.href, window.location.href); - // validate redirect url - const invalidProtocol = !['https:', 'http:'].includes( - redirectTarget.protocol, - ); - // if in valid, show warning and abort - if (invalidProtocol) { - // we intentionally dont display to the user any potential attacker-written content here - console.error(`Invalid redirect url.`); - return; - } - // use the validated url instance - window.location.href = redirectTarget.href; - }); -} diff --git a/development/build/scripts.js b/development/build/scripts.js index f942c4c1b..8e2a3864e 100644 --- a/development/build/scripts.js +++ b/development/build/scripts.js @@ -34,6 +34,7 @@ const metamaskrc = require('rc')('metamask', { INFURA_PROD_PROJECT_ID: process.env.INFURA_PROD_PROJECT_ID, ONBOARDING_V2: process.env.ONBOARDING_V2, COLLECTIBLES_V1: process.env.COLLECTIBLES_V1, + PHISHING_PAGE_URL: process.env.PHISHING_PAGE_URL, TOKEN_DETECTION_V2: process.env.TOKEN_DETECTION_V2, SEGMENT_HOST: process.env.SEGMENT_HOST, SEGMENT_WRITE_KEY: process.env.SEGMENT_WRITE_KEY, @@ -133,6 +134,48 @@ function getSegmentWriteKey({ buildType, environment }) { throw new Error(`Invalid build type: '${buildType}'`); } +/** + * Get the URL for the phishing warning page, if it has been set. + * + * @param options0 + * @param options0.testing + * @returns {string} The URL for the phishing warning page, or `undefined` if no URL is set. + */ +function getPhishingWarningPageUrl({ testing }) { + let phishingWarningPageUrl = metamaskrc.PHISHING_PAGE_URL; + + if (!phishingWarningPageUrl) { + phishingWarningPageUrl = testing + ? 'http://localhost:9999/' + : 'https://metamask.github.io/phishing-warning/v1.1.0/'; + } + + // We add a hash/fragment to the URL dynamically, so we need to ensure it + // has a valid pathname to append a hash to. + const normalizedUrl = phishingWarningPageUrl.endsWith('/') + ? phishingWarningPageUrl + : `${phishingWarningPageUrl}/`; + + let phishingWarningPageUrlObject; + try { + // eslint-disable-next-line no-new + phishingWarningPageUrlObject = new URL(normalizedUrl); + } catch (error) { + throw new Error( + `Invalid phishing warning page URL: '${normalizedUrl}'`, + error, + ); + } + if (phishingWarningPageUrlObject.hash) { + // The URL fragment must be set dynamically + throw new Error( + `URL fragment not allowed in phishing warning page URL: '${normalizedUrl}'`, + ); + } + + return normalizedUrl; +} + const noopWriteStream = through.obj((_file, _fileEncoding, callback) => callback(), ); @@ -216,11 +259,6 @@ function createScriptTasks({ createTaskForBundleSentry({ devMode, testing }), ); - const phishingDetectSubtask = createTask( - `${taskPrefix}:phishing-detect`, - createTaskForBundlePhishingDetect({ devMode, testing }), - ); - // task for initiating browser livereload const initiateLiveReload = async () => { if (devMode) { @@ -243,7 +281,6 @@ function createScriptTasks({ contentscriptSubtask, disableConsoleSubtask, installSentrySubtask, - phishingDetectSubtask, ].map((subtask) => runInChildProcess(subtask, { buildType, @@ -290,23 +327,6 @@ function createScriptTasks({ }); } - function createTaskForBundlePhishingDetect({ devMode, testing }) { - const label = 'phishing-detect'; - return createNormalBundle({ - buildType, - browserPlatforms, - destFilepath: `${label}.js`, - devMode, - entryFilepath: `./app/scripts/${label}.js`, - ignoredFiles, - label, - testing, - policyOnly, - shouldLintFenceFiles, - version, - }); - } - // the "contentscript" bundle contains the "inpage" bundle function createTaskForBundleContentscript({ devMode, testing }) { const inpage = 'inpage'; @@ -818,6 +838,7 @@ function getEnvironmentVariables({ buildType, devMode, testing, version }) { METAMASK_BUILD_TYPE: buildType, NODE_ENV: devMode ? ENVIRONMENT.DEVELOPMENT : ENVIRONMENT.PRODUCTION, IN_TEST: testing, + PHISHING_PAGE_URL: getPhishingWarningPageUrl({ testing }), PUBNUB_SUB_KEY: process.env.PUBNUB_SUB_KEY || '', PUBNUB_PUB_KEY: process.env.PUBNUB_PUB_KEY || '', CONF: devMode ? metamaskrc : {}, diff --git a/development/build/static.js b/development/build/static.js index 7efde5a72..d1f024fbc 100644 --- a/development/build/static.js +++ b/development/build/static.js @@ -174,10 +174,6 @@ function getCopyTargets(shouldIncludeLockdown) { src: require.resolve('@lavamoat/lavapack/src/runtime.js'), dest: `runtime-lavamoat.js`, }, - { - src: `./app/phishing.html`, - dest: `phishing.html`, - }, ]; const languageTags = new Set(); diff --git a/development/sourcemap-validator.js b/development/sourcemap-validator.js index 01861e229..0531a03ce 100644 --- a/development/sourcemap-validator.js +++ b/development/sourcemap-validator.js @@ -24,7 +24,6 @@ async function start() { `common-0.js`, `background-0.js`, `ui-0.js`, - 'phishing-detect.js', // `contentscript.js`, skipped because the validator is erroneously sampling the inlined `inpage.js` script `inpage.js`, ]; diff --git a/package.json b/package.json index ccc788828..fd1117e5e 100644 --- a/package.json +++ b/package.json @@ -252,6 +252,7 @@ "@metamask/eslint-config-nodejs": "^9.0.0", "@metamask/eslint-config-typescript": "^9.0.1", "@metamask/forwarder": "^1.1.0", + "@metamask/phishing-warning": "^1.0.0", "@metamask/test-dapp": "^5.0.0", "@sentry/cli": "^1.58.0", "@storybook/addon-a11y": "^6.3.12", diff --git a/test/e2e/helpers.js b/test/e2e/helpers.js index a6f7b3c7b..1b611b989 100644 --- a/test/e2e/helpers.js +++ b/test/e2e/helpers.js @@ -6,6 +6,7 @@ const enLocaleMessages = require('../../app/_locales/en/messages.json'); const { setupMocking } = require('./mock-e2e'); const Ganache = require('./ganache'); const FixtureServer = require('./fixture-server'); +const PhishingWarningPageServer = require('./phishing-warning-page-server'); const { buildWebDriver } = require('./webdriver'); const { ensureXServerIsRunning } = require('./x-server'); @@ -27,6 +28,7 @@ async function withFixtures(options, testSuite) { title, failOnConsoleError = true, dappPath = undefined, + dappPaths, testSpecificMock = function () { // do nothing. }, @@ -38,6 +40,7 @@ async function withFixtures(options, testSuite) { let secondaryGanacheServer; let numberOfDapps = dapp ? 1 : 0; const dappServer = []; + const phishingPageServer = new PhishingWarningPageServer(); let webDriver; let failed = false; @@ -55,14 +58,15 @@ async function withFixtures(options, testSuite) { } await fixtureServer.start(); await fixtureServer.loadState(path.join(__dirname, 'fixtures', fixtures)); + await phishingPageServer.start(); if (dapp) { if (dappOptions?.numberOfDapps) { numberOfDapps = dappOptions.numberOfDapps; } for (let i = 0; i < numberOfDapps; i++) { let dappDirectory; - if (dappPath) { - dappDirectory = path.resolve(__dirname, dappPath); + if (dappPath || (dappPaths && dappPaths[i])) { + dappDirectory = path.resolve(__dirname, dappPath || dappPaths[i]); } else { dappDirectory = path.resolve( __dirname, @@ -146,6 +150,9 @@ async function withFixtures(options, testSuite) { } } } + if (phishingPageServer.isRunning()) { + await phishingPageServer.quit(); + } await mockServer.stop(); } } diff --git a/test/e2e/mock-page-with-disallowed-iframe/index.html b/test/e2e/mock-page-with-disallowed-iframe/index.html new file mode 100644 index 000000000..c6309677e --- /dev/null +++ b/test/e2e/mock-page-with-disallowed-iframe/index.html @@ -0,0 +1,19 @@ + + + + Mock E2E Phishing Page + + + +
Hello
+