MEW-01-009 & MEW-01-010: Electron security fixes (#910)

* Handle opening of external links in electron. Minor refactor of window code.

* Convert updates from in-app modal to electron dialogs. Remove in-app code and preload bridge.

* Refine new window blocking. Re-enable tsconfig to look at electron-app.

* Clean up shared

* Whoops, wrong protocol format
This commit is contained in:
William O'Beirne 2018-01-26 14:53:51 -05:00 committed by Daniel Ternyak
parent 2e49d4718b
commit df52521c17
16 changed files with 188 additions and 509 deletions

View File

@ -1,61 +0,0 @@
@import 'common/sass/variables';
@keyframes new-update-popin {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes new-update-glow {
0% {
opacity: 1;
transform: scale(1);
},
80%, 100% {
opacity: 0;
transform: scale(2.5);
}
}
.Version {
position: relative;
&-text {
&.has-update:hover {
cursor: pointer;
text-decoration: underline;
}
}
&-new {
position: absolute;
top: 0px;
left: -12px;
width: 8px;
height: 8px;
border-radius: 100%;
background: $brand-warning;
animation: new-update-popin 500ms ease 1;
&:before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 100%;
box-shadow: 0 0 3px $brand-warning;
animation: new-update-glow 1200ms ease infinite;
}
}
}

View File

@ -1,48 +1,6 @@
import React from 'react';
import { VERSION } from 'config/data';
import UpdateModal, { UpdateInfo } from 'components/UpdateModal';
import { addListener } from 'utils/electron';
import EVENTS from 'shared/electronEvents';
import './Version.scss';
interface State {
updateInfo: UpdateInfo | null;
isModalOpen: boolean;
}
const Version: React.SFC<{}> = () => <div className="Version">v{VERSION}</div>;
export default class Version extends React.Component<{}, State> {
public state: State = {
updateInfo: null,
isModalOpen: false
};
public componentDidMount() {
addListener(EVENTS.UPDATE.UPDATE_AVAILABLE, updateInfo => {
this.setState({ updateInfo });
});
}
public render() {
const { updateInfo, isModalOpen } = this.state;
return (
<div className="Version">
<span className={`Version-text ${updateInfo ? 'has-update' : ''}`} onClick={this.openModal}>
v{VERSION}
</span>
{updateInfo && (
<span>
<span className="Version-new" />
<UpdateModal
isOpen={isModalOpen}
updateInfo={updateInfo}
handleClose={this.closeModal}
/>
</span>
)}
</div>
);
}
private openModal = () => this.setState({ isModalOpen: true });
private closeModal = () => this.setState({ isModalOpen: false });
}
export default Version;

View File

@ -1,62 +0,0 @@
@import 'common/sass/variables';
.UpdateModal {
cursor: default;
@media (min-width: 680px) {
min-width: 680px;
}
&-title {
margin-top: 0;
margin-bottom: $space-xs;
}
&-date {
font-size: $font-size-small;
color: $gray;
padding-bottom: $space-sm;
border-bottom: 1px solid $gray-lighter;
}
&-downloader {
padding: 50px 30px 80px;
text-align: center;
&-bar {
position: relative;
margin: 0 20px;
height: 16px;
border-radius: 4px;
background: $gray-lighter;
margin-bottom: $space-sm;
&-inner {
position: absolute;
top: 0;
left: 0;
bottom: 0;
border-radius: 4px;
background: $brand-primary;
transition: width 100ms ease;
}
}
&-info {
color: $gray-light;
font-size: $font-size-xs;
&-bit {
&:after {
display: inline-block;
content: "";
padding: 0 $space-xs;
opacity: 0.8;
}
&:last-child:after {
display: none;
}
}
}
}
}

View File

@ -1,142 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import moment from 'moment';
import { showNotification, TShowNotification } from 'actions/notifications';
import { Spinner, NewTabLink } from 'components/ui';
import Modal, { IButton } from 'components/ui/Modal';
import { addListener, sendEvent } from 'utils/electron';
import EVENTS from 'shared/electronEvents';
import { bytesToHuman } from 'utils/formatters';
import './UpdateModal.scss';
export interface UpdateInfo {
version: string;
sha512: string;
releaseDate: string;
releaseName: string;
releaseNotes: string;
}
export interface DownloadProgress {
bytesPerSecond: number;
percent: number;
transferred: number;
total: number;
}
interface Props {
isOpen: boolean;
updateInfo: UpdateInfo;
showNotification: TShowNotification;
handleClose(): void;
}
interface State {
isDownloading: boolean;
downloadProgress: DownloadProgress | null;
}
class UpdateModal extends React.Component<Props, State> {
public state: State = {
isDownloading: false,
downloadProgress: null
};
public componentDidMount() {
addListener(EVENTS.UPDATE.UPDATE_DOWNLOADED, () => {
sendEvent(EVENTS.UPDATE.QUIT_AND_INSTALL);
});
addListener(EVENTS.UPDATE.DOWNLOAD_PROGRESS, downloadProgress => {
this.setState({ downloadProgress });
});
addListener(EVENTS.UPDATE.ERROR, err => {
console.error('Update failed:', err);
this.setState({ isDownloading: false });
this.props.showNotification(
'danger',
<span>
Update could not be downloaded, please visit{' '}
<NewTabLink href="https://github.com/MyEtherWallet/MyEtherWallet/releases">
our github
</NewTabLink>{' '}
to download the latest release
</span>,
Infinity
);
});
}
public render() {
const { isOpen, updateInfo, handleClose } = this.props;
const { isDownloading, downloadProgress } = this.state;
const buttons: IButton[] | undefined = downloadProgress
? undefined
: [
{
text: <span>{isDownloading && <Spinner />} Download Update</span>,
type: 'primary',
onClick: this.downloadUpdate,
disabled: isDownloading
},
{
text: 'Close',
type: 'default',
onClick: handleClose
}
];
return (
<Modal
isOpen={isOpen}
title={`Update Information`}
handleClose={handleClose}
buttons={buttons}
>
<div className="UpdateModal">
{downloadProgress ? (
<div className="UpdateModal-downloader">
<h3 className="UpdateModal-downloader-title">Downloading...</h3>
<div className="UpdateModal-downloader-bar">
<div
className="UpdateModal-downloader-bar-inner"
style={{
width: `${downloadProgress.percent}%`
}}
/>
</div>
<div className="UpdateModal-downloader-info">
<span className="UpdateModal-downloader-info-bit">
Downloaded {downloadProgress.percent.toFixed(1)}%
</span>
<span className="UpdateModal-downloader-info-bit">
{bytesToHuman(downloadProgress.bytesPerSecond)}/s
</span>
<span className="UpdateModal-downloader-info-bit">
Total Size {bytesToHuman(downloadProgress.total)}
</span>
</div>
</div>
) : (
<div>
<h1 className="UpdateModal-title">{updateInfo.releaseName}</h1>
<div className="UpdateModal-date">{moment(updateInfo.releaseDate).format('LL')}</div>
<div
className="UpdateModal-content"
dangerouslySetInnerHTML={{
__html: updateInfo.releaseNotes
}}
/>
</div>
)}
</div>
</Modal>
);
}
private downloadUpdate = () => {
this.setState({ isDownloading: true });
sendEvent('UPDATE:download-update');
};
}
export default connect(undefined, { showNotification })(UpdateModal);

View File

@ -1,5 +1,4 @@
import React from 'react';
import { openInBrowser } from 'utils/electron';
interface AAttributes {
charset?: string;
@ -40,17 +39,11 @@ export class NewTabLink extends React.Component<NewTabLinkProps> {
public render() {
const { content, children, ...rest } = this.props;
return (
<a target="_blank" rel="noopener noreferrer" onClick={this.handleClick} {...rest}>
<a target="_blank" rel="noopener noreferrer" {...rest}>
{content || children}
</a>
);
}
private handleClick(ev: React.MouseEvent<HTMLAnchorElement>) {
if (openInBrowser(ev.currentTarget.href)) {
ev.preventDefault();
}
}
}
export default NewTabLink;

View File

@ -1,25 +0,0 @@
// Handles integrations with Electron. Wherever possible, should stub out
// behavior with noop's if not in the Electron environment, to simplify code.
import { ElectronBridgeFunctions } from 'shared/electronBridge';
const bridge: ElectronBridgeFunctions | null = (window as any).electronBridge;
export const addListener: ElectronBridgeFunctions['addListener'] = (event, cb) => {
if (bridge && bridge.addListener) {
// @ts-ignore unused ev
bridge.addListener(event, (ev, data) => cb(data));
}
};
export const sendEvent: ElectronBridgeFunctions['sendEvent'] = (event, data) => {
if (bridge && bridge.sendEvent) {
bridge.sendEvent(event, data);
}
};
export const openInBrowser: ElectronBridgeFunctions['openInBrowser'] = url => {
if (bridge && bridge.openInBrowser) {
bridge.openInBrowser(url);
return true;
}
return false;
};

View File

@ -1,63 +1,5 @@
import { app, BrowserWindow, Menu } from 'electron';
import * as path from 'path';
import updater from './updater';
import MENU from './menu';
const isDevelopment = process.env.NODE_ENV !== 'production';
// Global reference to mainWindow
// Necessary to prevent window from being garbage collected
let mainWindow;
function createMainWindow() {
// Construct new BrowserWindow
const window = new BrowserWindow({
title: 'MyEtherWallet',
backgroundColor: '#fbfbfb',
width: 1220,
height: 800,
minWidth: 320,
minHeight: 400,
// TODO - Implement styles for custom title bar in components/ui/TitleBar.scss
// frame: false,
// titleBarStyle: 'hidden',
webPreferences: {
devTools: true,
nodeIntegration: false,
preload: path.resolve(__dirname, 'preload.js')
}
});
const url = isDevelopment
? `http://localhost:${process.env.HTTPS ? 3443 : 3000}`
: `file://${__dirname}/index.html`;
window.loadURL(url);
window.on('closed', () => {
mainWindow = null;
});
window.webContents.on('devtools-opened', () => {
window.focus();
setImmediate(() => {
window.focus();
});
});
if (isDevelopment) {
window.webContents.on('did-fail-load', () => {
setTimeout(() => {
if (window && window.webContents) {
window.webContents.reload();
}
}, 500);
});
}
Menu.setApplicationMenu(Menu.buildFromTemplate(MENU));
return window;
}
import { app } from 'electron';
import getWindow from './window';
// Quit application when all windows are closed
app.on('window-all-closed', () => {
@ -71,16 +13,10 @@ app.on('window-all-closed', () => {
app.on('activate', () => {
// On macOS it is common to re-create a window
// even after all windows have been closed
if (mainWindow === null) {
mainWindow = createMainWindow();
updater(app, mainWindow);
}
getWindow();
});
// Create main BrowserWindow when electron is ready
app.on('ready', () => {
mainWindow = createMainWindow();
mainWindow.webContents.on('did-finish-load', () => {
updater(app, mainWindow);
});
getWindow();
});

View File

@ -1,97 +1,144 @@
import { App, BrowserWindow, ipcMain } from 'electron';
import { autoUpdater } from 'electron-updater';
import EVENTS from '../../shared/electronEvents';
import { app, dialog, BrowserWindow } from 'electron';
import { autoUpdater, UpdateInfo } from 'electron-updater';
import TEST_RELEASE from './testrelease.json';
autoUpdater.autoDownload = false;
// Set to 'true' if you want to test update behavior. Requires a recompile.
const shouldMockUpdate = false && process.env.NODE_ENV !== 'production';
const shouldMockUpdateError = false && process.env.NODE_ENV !== 'production';
let hasRunUpdater = false;
let hasStartedUpdating = false;
enum AutoUpdaterEvents {
CHECKING_FOR_UPDATE = 'checking-for-update',
UPDATE_NOT_AVAILABLE = 'update-not-available',
UPDATE_AVAILABLE = 'update-available',
DOWNLOAD_PROGRESS = 'download-progress',
UPDATE_DOWNLOADED = 'update-downloaded',
ERROR = 'error'
}
export default (app: App, window: BrowserWindow) => {
// Set to 'true' if you want to test update behavior. Requires a recompile.
const shouldMockUpdate = true && process.env.NODE_ENV !== 'production';
export default function(mainWindow: BrowserWindow) {
if (hasRunUpdater) {
return;
}
hasRunUpdater = true;
// Report update status
autoUpdater.on(AutoUpdaterEvents.CHECKING_FOR_UPDATE, () => {
window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE);
});
autoUpdater.on(AutoUpdaterEvents.UPDATE_NOT_AVAILABLE, () => {
window.webContents.send(EVENTS.UPDATE.UPDATE_NOT_AVAILABLE);
});
autoUpdater.on(AutoUpdaterEvents.UPDATE_AVAILABLE, info => {
window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, info);
autoUpdater.on(AutoUpdaterEvents.UPDATE_AVAILABLE, (info: UpdateInfo) => {
dialog.showMessageBox(
{
type: 'question',
buttons: ['Yes, start downloading', 'Maybe later'],
title: `An Update is Available (v${info.version})`,
message: `An Update is Available (v${info.version})`,
detail:
'A new version has been released. Would you like to start downloading the update? You will be notified when the download is finished.'
},
response => {
if (response === 0) {
if (shouldMockUpdate) {
mockDownload();
} else {
autoUpdater.downloadUpdate();
}
}
}
);
hasStartedUpdating = true;
});
autoUpdater.on(AutoUpdaterEvents.DOWNLOAD_PROGRESS, progress => {
window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, progress);
mainWindow.setTitle(`MyEtherWallet (Downloading update... ${Math.round(progress.percent)}%)`);
mainWindow.setProgressBar(progress.percent / 100);
});
autoUpdater.on(AutoUpdaterEvents.UPDATE_DOWNLOADED, () => {
window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED);
resetWindowFromUpdates(mainWindow);
dialog.showMessageBox(
{
type: 'question',
buttons: ['Yes, restart now', 'Maybe later'],
title: 'Update Has Been Downloaded',
message: 'Download complete!',
detail:
'The new version of MyEtherWallet has finished downloading. Would you like to restart to complete the installation?'
},
response => {
if (response === 0) {
if (shouldMockUpdate) {
app.quit();
} else {
autoUpdater.quitAndInstall();
}
}
}
);
});
autoUpdater.on(AutoUpdaterEvents.ERROR, (err, msg) => {
autoUpdater.on(AutoUpdaterEvents.ERROR, (err: Error) => {
console.error('Update failed with an error');
console.error(err);
window.webContents.send(EVENTS.UPDATE.ERROR, msg);
// If they haven't started updating yet, just fail silently
if (!hasStartedUpdating) {
return;
}
resetWindowFromUpdates(mainWindow);
dialog.showErrorBox(
'Downloading Update has Failed',
`The update could not be downloaded. Restart the app and try again later, or manually install the new update at https://github.com/MyEtherWallet/MyEtherWallet/releases\n\n(${
err.name
}: ${err.message})`
);
});
// Kick off the check
autoUpdater.checkForUpdatesAndNotify();
// Listen for restart request
ipcMain.on(EVENTS.UPDATE.DOWNLOAD_UPDATE, () => {
if (shouldMockUpdate) {
mockDownload(window);
} else {
autoUpdater.downloadUpdate();
}
});
ipcMain.on(EVENTS.UPDATE.QUIT_AND_INSTALL, () => {
if (shouldMockUpdate) {
app.quit();
} else {
autoUpdater.quitAndInstall();
}
});
// Simulate a test release
if (shouldMockUpdate) {
mockUpdateCheck(window);
mockUpdateCheck();
}
};
}
function resetWindowFromUpdates(window: BrowserWindow) {
window.setTitle('MyEtherWallet');
window.setProgressBar(-1); // Clears progress bar
}
// Mock functions for dev testing
function mockUpdateCheck(window: BrowserWindow) {
window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE);
function mockUpdateCheck() {
autoUpdater.emit(AutoUpdaterEvents.CHECKING_FOR_UPDATE);
setTimeout(() => {
window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, TEST_RELEASE);
autoUpdater.emit(AutoUpdaterEvents.UPDATE_AVAILABLE, TEST_RELEASE);
}, 3000);
}
function mockDownload(window: BrowserWindow) {
for (let i = 0; i < 101; i++) {
function mockDownload() {
for (let i = 0; i < 11; i++) {
setTimeout(() => {
if (i >= 5 && shouldMockUpdateError) {
if (i === 5) {
autoUpdater.emit(
AutoUpdaterEvents.ERROR,
new Error('Test error, nothing actually failed')
);
}
return;
}
const total = 150000000;
window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, {
bytesPerSecond: Math.round(Math.random() * 100000),
percent: i,
autoUpdater.emit(AutoUpdaterEvents.DOWNLOAD_PROGRESS, {
bytesPerSecond: Math.round(Math.random() * 100000000),
percent: i * 10,
transferred: total / i,
total
});
if (i === 100) {
window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED);
if (i === 10) {
autoUpdater.emit(AutoUpdaterEvents.UPDATE_DOWNLOADED);
}
}, 50 * i);
}, 500 * i);
}
}

View File

@ -0,0 +1,78 @@
import { BrowserWindow, Menu, shell } from 'electron';
import { URL } from 'url';
import MENU from './menu';
import updater from './updater';
const isDevelopment = process.env.NODE_ENV !== 'production';
// Cached reference, preventing recreations
let window;
// Construct new BrowserWindow
export default function getWindow() {
if (window) {
return window;
}
window = new BrowserWindow({
title: 'MyEtherWallet',
backgroundColor: '#fbfbfb',
width: 1220,
height: 800,
minWidth: 320,
minHeight: 400,
// TODO - Implement styles for custom title bar in components/ui/TitleBar.scss
// frame: false,
// titleBarStyle: 'hidden',
webPreferences: {
devTools: true,
nodeIntegration: false,
contextIsolation: true
}
});
const port = process.env.HTTPS ? '3443' : '3000';
const appUrl = isDevelopment ? `http://localhost:${port}` : `file://${__dirname}/index.html`;
window.loadURL(appUrl);
window.on('closed', () => {
window = null;
});
window.webContents.on('new-window', (ev, urlStr) => {
// Kill all new window requests by default
ev.preventDefault();
// Only allow HTTPS urls to actually be opened
const url = new URL(urlStr);
if (url.protocol === 'https:') {
shell.openExternal(urlStr);
} else {
console.warn(`Blocked request to open new window '${urlStr}', only HTTPS links are allowed`);
}
});
window.webContents.on('did-finish-load', () => {
updater(window);
});
window.webContents.on('devtools-opened', () => {
window.focus();
setImmediate(() => {
window.focus();
});
});
if (isDevelopment) {
window.webContents.on('did-fail-load', () => {
setTimeout(() => {
if (window && window.webContents) {
window.webContents.reload();
}
}, 500);
});
}
Menu.setApplicationMenu(Menu.buildFromTemplate(MENU));
return window;
}

View File

@ -1,19 +0,0 @@
// Selectively expose node integration, since all node integrations are
// disabled by default for security purposes.
import { ipcRenderer, shell } from 'electron';
import { ElectronBridgeFunctions } from '../shared/electronBridge';
const win = window as any;
const functions: ElectronBridgeFunctions = {
addListener(event, cb) {
ipcRenderer.on(event, cb);
},
sendEvent(event, data) {
ipcRenderer.send(event, data);
},
openInBrowser(url) {
return shell.openExternal(url);
}
};
win.electronBridge = functions;

View File

@ -10,8 +10,7 @@
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/jest_config/__mocks__/fileMock.ts",
"\\.(css|scss|less)$": "<rootDir>/jest_config/__mocks__/styleMock.ts",
"\\.worker.ts":"<rootDir>/jest_config/__mocks__/workerMock.js",
"^shared(.*)$": "<rootDir>/shared$1"
"\\.worker.ts":"<rootDir>/jest_config/__mocks__/workerMock.js"
},
"testPathIgnorePatterns": ["<rootDir>/common/config"],
"setupFiles": [

View File

@ -9,8 +9,7 @@
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
"<rootDir>/jest_config/__mocks__/fileMock.ts",
"\\.(css|scss|less)$": "<rootDir>/jest_config/__mocks__/styleMock.ts",
"^shared(.*)$": "<rootDir>/shared$1"
"\\.(css|scss|less)$": "<rootDir>/jest_config/__mocks__/styleMock.ts"
},
"testPathIgnorePatterns": ["<rootDir>/common/config"],
"setupFiles": [

View File

@ -1,9 +0,0 @@
// Provide typescript definitions / mappings between `electron-app/preload.ts`
// and 'common/utils/electron.ts'
export type ElectronBridgeCallback = (data?: any) => void;
export interface ElectronBridgeFunctions {
addListener(event: string, cb: ElectronBridgeCallback);
sendEvent(event: string, data?: any);
openInBrowser(url: string): boolean;
}

View File

@ -1,12 +0,0 @@
export default {
UPDATE: {
CHECKING_FOR_UPDATE: 'UPDATE:checking-for-update',
UPDATE_NOT_AVAILABLE: 'UPDATE:update-not-available',
UPDATE_AVAILABLE: 'UPDATE:update-available',
DOWNLOAD_PROGRESS: 'UPDATE:download-progress',
UPDATE_DOWNLOADED: 'UPDATE:update-downloaded',
ERROR: 'UPDATE:error',
DOWNLOAD_UPDATE: 'UPDATE:download-update',
QUIT_AND_INSTALL: 'UPDATE:quit-and-install'
}
};

View File

@ -23,7 +23,7 @@
},
"include": [
"./common/",
"./electron/",
"./electron-app/",
"spec",
"./node_modules/types-rlp/index.d.ts"
],

View File

@ -8,8 +8,7 @@ const makeConfig = require('./makeConfig');
const electronConfig = {
target: 'electron-main',
entry: {
main: path.join(config.path.electron, 'main/index.ts'),
preload: path.join(config.path.electron, 'preload.ts')
main: path.join(config.path.electron, 'main/index.ts')
},
module: {
rules: [config.typescriptRule]