1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00
metamask-extension/app/scripts/contentscript.js
Mark Stacey f763979bed
Add support for one-click onboarding (#7017)
* 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`)
2019-11-22 13:03:51 -04:00

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 }))
}