mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-29 23:58:06 +01:00
f763979bed
* Add support for one-click onboarding MetaMask now allows sites to register as onboarding the user, so that the user is redirected back to the initiating site after onboarding. This is accomplished through the use of the `metamask-onboarding` library and the MetaMask forwarder. At the end of onboarding, a 'snackbar'-stype component will explain to the user they are about to be moved back to the originating dapp, and it will show the origin of that dapp. This is intended to help prevent phishing attempts, as it highlights that a redirect is taking place to an untrusted third party. If the onboarding initiator tab is closed when onboarding is finished, the user is redirected to the onboarding originator as a fallback. Closes #6161 * Add onboarding button to contract test dapp The `contract-test` dapp (run with `yarn dapp`, used in e2e tests) now uses a `Connect` button instead of connecting automatically. This button also serves as an onboarding button when a MetaMask installation is not detected. * Add new static server for test dapp The `static-server` library we were using for the `contract-test` dapp didn't allow referencing files outside the server root. This should have been possible to work around using symlinks, but there was a bug that resulted in symlinks crashing the server. Instead it has been replaced with a simple static file server that will serve paths starting with `node_modules` from the project root. This will be useful in testing the onboarding library without vendoring it. * Add `@metamask/onboarding` and `@metamask/forwarder` Both libraries used to test onboarding are now included as dev dependencies, to help with testing. A few convenience scripts were added to help with this (`yarn forwarder` and `yarn dapp-forwarder`)
354 lines
9.7 KiB
JavaScript
354 lines
9.7 KiB
JavaScript
const fs = require('fs')
|
|
const path = require('path')
|
|
const pump = require('pump')
|
|
const log = require('loglevel')
|
|
const Dnode = require('dnode')
|
|
const querystring = require('querystring')
|
|
const { Writable } = require('readable-stream')
|
|
const LocalMessageDuplexStream = require('post-message-stream')
|
|
const ObjectMultiplex = require('obj-multiplex')
|
|
const extension = require('extensionizer')
|
|
const PortStream = require('extension-port-stream')
|
|
|
|
const inpageContent = fs.readFileSync(path.join(__dirname, '..', '..', 'dist', 'chrome', 'inpage.js')).toString()
|
|
const inpageSuffix = '//# sourceURL=' + extension.runtime.getURL('inpage.js') + '\n'
|
|
const inpageBundle = inpageContent + inpageSuffix
|
|
|
|
// Eventually this streaming injection could be replaced with:
|
|
// https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Language_Bindings/Components.utils.exportFunction
|
|
//
|
|
// But for now that is only Firefox
|
|
// If we create a FireFox-only code path using that API,
|
|
// MetaMask will be much faster loading and performant on Firefox.
|
|
|
|
if (shouldInjectWeb3()) {
|
|
injectScript(inpageBundle)
|
|
start()
|
|
}
|
|
|
|
/**
|
|
* 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 (e) {
|
|
console.error('MetaMask script injection failed', e)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets up the stream communication and submits site metadata
|
|
*
|
|
*/
|
|
async function start () {
|
|
await setupStreams()
|
|
await domIsReady()
|
|
}
|
|
|
|
/**
|
|
* 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 LocalMessageDuplexStream({
|
|
name: 'contentscript',
|
|
target: 'inpage',
|
|
})
|
|
const extensionPort = extension.runtime.connect({ name: 'contentscript' })
|
|
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)
|
|
)
|
|
|
|
const onboardingStream = pageMux.createStream('onboarding')
|
|
const addCurrentTab = new Writable({
|
|
objectMode: true,
|
|
write: (chunk, _, callback) => {
|
|
if (!chunk) {
|
|
return callback(new Error('Malformed onboarding message'))
|
|
}
|
|
|
|
const handleSendMessageResponse = (error, success) => {
|
|
if (!error && !success) {
|
|
error = extension.runtime.lastError
|
|
}
|
|
if (error) {
|
|
log.error(`Failed to send ${chunk.type} message`, error)
|
|
return callback(error)
|
|
}
|
|
callback(null)
|
|
}
|
|
|
|
try {
|
|
if (chunk.type === 'registerOnboarding') {
|
|
extension.runtime.sendMessage({ type: 'metamask:registerOnboarding', location: window.location.href }, handleSendMessageResponse)
|
|
} else {
|
|
throw new Error(`Unrecognized onboarding message type: '${chunk.type}'`)
|
|
}
|
|
} catch (error) {
|
|
log.error(error)
|
|
return callback(error)
|
|
}
|
|
},
|
|
})
|
|
|
|
pump(
|
|
onboardingStream,
|
|
addCurrentTab,
|
|
error => console.error('MetaMask onboarding channel traffic failed', error),
|
|
)
|
|
|
|
// forward communication across inpage-background for these channels only
|
|
forwardTrafficBetweenMuxers('provider', pageMux, extensionMux)
|
|
forwardTrafficBetweenMuxers('publicConfig', pageMux, extensionMux)
|
|
|
|
// connect "phishing" channel to warning system
|
|
const phishingStream = extensionMux.createStream('phishing')
|
|
phishingStream.once('data', redirectToPhishingWarning)
|
|
|
|
// connect "publicApi" channel to submit page metadata
|
|
const publicApiStream = extensionMux.createStream('publicApi')
|
|
const background = await setupPublicApi(publicApiStream)
|
|
|
|
return { background }
|
|
}
|
|
|
|
function forwardTrafficBetweenMuxers (channelName, muxA, muxB) {
|
|
const channelA = muxA.createStream(channelName)
|
|
const channelB = muxB.createStream(channelName)
|
|
pump(
|
|
channelA,
|
|
channelB,
|
|
channelA,
|
|
(err) => logStreamDisconnectWarning(`MetaMask muxed traffic for channel "${channelName}" failed.`, err)
|
|
)
|
|
}
|
|
|
|
async function setupPublicApi (outStream) {
|
|
const api = {
|
|
getSiteMetadata: (cb) => cb(null, getSiteMetadata()),
|
|
}
|
|
const dnode = Dnode(api)
|
|
pump(
|
|
outStream,
|
|
dnode,
|
|
outStream,
|
|
(err) => {
|
|
// report any error
|
|
if (err) {
|
|
log.error(err)
|
|
}
|
|
}
|
|
)
|
|
const background = await new Promise(resolve => dnode.once('remote', resolve))
|
|
return background
|
|
}
|
|
|
|
/**
|
|
* Gets site metadata and returns it
|
|
*
|
|
*/
|
|
function getSiteMetadata () {
|
|
// get metadata
|
|
const metadata = {
|
|
name: getSiteName(window),
|
|
icon: getSiteIcon(window),
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
/**
|
|
* Error handler for page to extension stream disconnections
|
|
*
|
|
* @param {string} remoteLabel Remote stream name
|
|
* @param {Error} err Stream connection error
|
|
*/
|
|
function logStreamDisconnectWarning (remoteLabel, err) {
|
|
let warningMsg = `MetamaskContentscript - lost connection to ${remoteLabel}`
|
|
if (err) {
|
|
warningMsg += '\n' + err.stack
|
|
}
|
|
console.warn(warningMsg)
|
|
}
|
|
|
|
/**
|
|
* Determines if Web3 should be injected
|
|
*
|
|
* @returns {boolean} {@code true} if Web3 should be injected
|
|
*/
|
|
function shouldInjectWeb3 () {
|
|
return doctypeCheck() && suffixCheck() &&
|
|
documentElementCheck() && !blacklistedDomainCheck()
|
|
}
|
|
|
|
/**
|
|
* 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.doctype
|
|
if (doctype) {
|
|
return doctype.name === 'html'
|
|
} else {
|
|
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 should not have web3 injected into them. 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$/,
|
|
/\.pdf$/,
|
|
]
|
|
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 blacklisted
|
|
*
|
|
* @returns {boolean} {@code true} if the current domain is blacklisted
|
|
*/
|
|
function blacklistedDomainCheck () {
|
|
const blacklistedDomains = [
|
|
'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 < blacklistedDomains.length; i++) {
|
|
const blacklistedDomain = blacklistedDomains[i].replace('.', '\\.')
|
|
currentRegex = new RegExp(`(?:https?:\\/\\/)(?:(?!${blacklistedDomain}).)*$`)
|
|
if (!currentRegex.test(currentUrl)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Redirects the current page to a phishing information page
|
|
*/
|
|
function redirectToPhishingWarning () {
|
|
console.log('MetaMask - routing to Phishing Warning component')
|
|
const extensionURL = extension.runtime.getURL('phishing.html')
|
|
window.location.href = `${extensionURL}#${querystring.stringify({
|
|
hostname: window.location.hostname,
|
|
href: window.location.href,
|
|
})}`
|
|
}
|
|
|
|
|
|
/**
|
|
* Extracts a name for the site from the DOM
|
|
*/
|
|
function getSiteName (window) {
|
|
const document = window.document
|
|
const siteName = document.querySelector('head > meta[property="og:site_name"]')
|
|
if (siteName) {
|
|
return siteName.content
|
|
}
|
|
|
|
const metaTitle = document.querySelector('head > meta[name="title"]')
|
|
if (metaTitle) {
|
|
return metaTitle.content
|
|
}
|
|
|
|
return document.title
|
|
}
|
|
|
|
/**
|
|
* Extracts an icon for the site from the DOM
|
|
*/
|
|
function getSiteIcon (window) {
|
|
const document = window.document
|
|
|
|
// Use the site's favicon if it exists
|
|
const shortcutIcon = document.querySelector('head > link[rel="shortcut icon"]')
|
|
if (shortcutIcon) {
|
|
return shortcutIcon.href
|
|
}
|
|
|
|
// Search through available icons in no particular order
|
|
const icon = Array.from(document.querySelectorAll('head > link[rel="icon"]')).find((icon) => Boolean(icon.href))
|
|
if (icon) {
|
|
return icon.href
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Returns a promise that resolves when the DOM is loaded (does not wait for images to load)
|
|
*/
|
|
async function domIsReady () {
|
|
// already loaded
|
|
if (['interactive', 'complete'].includes(document.readyState)) {
|
|
return
|
|
}
|
|
// wait for load
|
|
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve, { once: true }))
|
|
}
|