Commit 5bdd42ac authored by Amaury Martiny's avatar Amaury Martiny Committed by Axel Chalon
Browse files

fix: Use preload script as proxy for main-renderer communication (#518)

* Remove ipcRenderer and window.bridge

* Make app work more or less

* Make app work really

* Make prod work

* Make translations work

* Small tweaks

* Rename IPC channels

* Tighten up CSP

* Optimize a little bit

* Fix lint
parent 6541e1b2
Pipeline #37057 passed with stages
in 10 minutes
......@@ -26,7 +26,11 @@ const getPreferences = fetherApp => {
settings.set('fether-language', 'en');
fetherApp.setupMenu(fetherApp);
// Frontend change language
fetherApp.win.webContents.emit('set-language', 'en');
fetherApp.win.webContents.send('send-to-renderer', {
action: 'SET_LANGUAGE_RESPONSE',
from: 'fether:electron',
payload: 'en'
});
}
},
{
......@@ -39,7 +43,11 @@ const getPreferences = fetherApp => {
settings.set('fether-language', 'de');
fetherApp.setupMenu(fetherApp);
// Frontend change language
fetherApp.win.webContents.emit('set-language', 'de');
fetherApp.win.webContents.send('send-to-renderer', {
action: 'SET_LANGUAGE_RESPONSE',
from: 'fether:electron',
payload: 'de'
});
}
}
]
......
......@@ -4,38 +4,81 @@
// SPDX-License-Identifier: BSD-3-Clause
import { checkClockSync, signerNewToken } from '@parity/electron';
import settings from 'electron-settings';
import Pino from '../utils/pino';
import { bundledParityPath } from '../utils/paths';
import cli from '../cli';
import Pino from '../utils/pino';
import { TRUSTED_LOOPBACK } from '../constants';
const pino = Pino();
/**
* Handle all asynchronous messages from renderer to main.
*/
export default async (fetherApp, event, action, ...args) => {
export default async (fetherApp, event, data) => {
try {
if (!action) {
pino.debug(
`Received IPC message from ${data.from}, with data ${JSON.stringify(
data
)}`
);
if (!data) {
return;
}
switch (action) {
case 'app-right-click': {
switch (data.action) {
case 'APP_RIGHT_CLICK_REQUEST': {
if (!fetherApp.win) {
return;
}
fetherApp.contextWindowMenu.getMenu().popup({ window: fetherApp.win });
break;
}
case 'check-clock-sync': {
checkClockSync().then(t => {
event.sender.send('check-clock-sync-reply', t);
case 'CHECK_CLOCK_SYNC_REQUEST': {
const payload = await checkClockSync();
event.sender.send('send-to-renderer', {
action: 'CHECK_CLOCK_SYNC_RESPONSE',
from: 'fether:electron',
payload
});
break;
}
case 'SET_LANGUAGE_REQUEST': {
event.sender.send('send-to-renderer', {
action: 'SET_LANGUAGE_RESPONSE',
from: 'fether:electron',
payload: settings.get('fether-language')
});
break;
}
case 'signer-new-token': {
case 'SIGNER_NEW_TOKEN_REQUEST': {
const token = await signerNewToken({ parityPath: bundledParityPath });
// Send back the token to the renderer process
event.sender.send('signer-new-token-reply', token);
event.sender.send('send-to-renderer', {
action: 'SIGNER_NEW_TOKEN_RESPONSE',
from: 'fether:electron',
payload: token
});
break;
}
case 'WS_INTERFACE_REQUEST': {
event.sender.send('send-to-renderer', {
action: 'WS_INTERFACE_RESPONSE',
from: 'fether:electron',
payload: TRUSTED_LOOPBACK
});
break;
}
case 'WS_PORT_REQUEST': {
event.sender.send('send-to-renderer', {
action: 'WS_PORT_RESPONSE',
from: 'fether:electron',
payload: cli.wsPort
});
break;
}
default:
......
......@@ -3,15 +3,11 @@
//
// SPDX-License-Identifier: BSD-3-Clause
import { DEFAULT_WS_PORT, IS_PROD, TRUSTED_LOOPBACK } from '../constants';
import cli from '../cli';
import { IS_PROD } from '../constants';
function setupGlobals () {
// Globals for fether-react parityStore
// Globals for preload script
global.IS_PROD = IS_PROD;
global.defaultWsInterface = TRUSTED_LOOPBACK;
global.defaultWsPort = DEFAULT_WS_PORT;
global.wsPort = cli.wsPort;
}
export default setupGlobals;
......@@ -5,17 +5,13 @@
import electron from 'electron';
import { IS_PROD } from '../constants';
import { CSP } from '../utils/csp';
import messages from '../messages';
import Pino from '../utils/pino';
const pino = Pino();
const { ipcMain, session } = electron;
function setupRequestListeners (fetherApp) {
// Listen to messages from renderer process
ipcMain.on('asynchronous-message', (...args) => {
ipcMain.on('send-to-main', (...args) => {
return messages(fetherApp, ...args);
});
......@@ -35,27 +31,6 @@ function setupRequestListeners (fetherApp) {
callback({ requestHeaders: details.requestHeaders }); // eslint-disable-line
}
);
// Content Security Policy (CSP)
// Note: `onHeadersReceived` will not be called in prod, because we use the
// file:// protocol: https://electronjs.org/docs/tutorial/security#csp-meta-tag
// Instead, the CSP are the ones in the meta tag inside index.html
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
pino.debug(
`Configuring Content-Security-Policy for environment ${
IS_PROD ? 'production' : 'development'
}`
);
/* eslint-disable */
callback({
responseHeaders: {
...details.responseHeaders,
"Content-Security-Policy": [CSP]
}
});
/* eslint-enable */
});
}
export default setupRequestListeners;
......@@ -28,12 +28,6 @@ pino.info(
* fether-electron/src/main/index.js, which is where we only
* permit requests from trusted paths.
*
* WARNING: DO NOT add the custom CLI interface `cli.wsInterface` as a
* trusted host. This may avoid Fether being launched with a
* malicious remote `cli.wsInterface` and sending sensitive user information
* (i.e. account password) over RPC.
* See https://github.com/paritytech/fether/pull/451#discussion_r268732256
*
* Note: We also disallows users from using Fether
* with a remote node.
* WARNING: SSH tunnels from an attacker are still possible.
......@@ -164,7 +158,7 @@ const SECURITY_OPTIONS = {
* https://stackoverflow.com/questions/55164360/with-contextisolation-true-is-it-possible-to-use-ipcrenderer
* Currently experimental and may change or be removed in future Electron releases.
*/
contextIsolation: false, // Should be enabled
contextIsolation: true,
/**
* Isolate access to Electron/Node.js from the Fether web app, by creating
* a bridge which plays the role of an API between main and renderer
......
......@@ -16,7 +16,7 @@ const pino = Pino();
let hasCalledInitParityEthereum = false;
class ParityEthereum {
constructor (fetherAppWindow) {
constructor () {
if (hasCalledInitParityEthereum) {
throw new Error('Unable to initialise Parity Ethereum more than once');
}
......@@ -50,13 +50,7 @@ class ParityEthereum {
await this.run();
pino.info('Running Parity Ethereum');
resolve(true);
})
.then(isRunning => {
// Notify the renderers
fetherAppWindow.webContents.send('parity-running', isRunning);
global.isParityRunning = isRunning; // Send this variable to renderers via IPC
})
.catch(handleError);
}).catch(handleError);
}
isRunning = async () => {
......
......@@ -38,8 +38,6 @@ const CSP_CONFIG = {
mediaSrc: "media-src 'none';",
// Disallow fonts and `<webview>` objects
objectSrc: "object-src 'none';",
// Disallow prefetching.
prefetchSrc: "prefetch-src 'none';",
scriptSrc: !IS_PROD
? // Only allow `http:` and `unsafe-eval` in dev mode (required by create-react-app)
"script-src 'self' file: http: blob: 'unsafe-inline' 'unsafe-eval';"
......
......@@ -12,48 +12,55 @@
* network stack).
*
* Reference: https://slack.engineering/interops-labyrinth-sharing-code-between-web-electron-apps-f9474d62eccc
*
* This preload script handles communication between main and renderer processes.
* https://github.com/electron/electron/issues/13130
*/
const { ipcRenderer, remote } = require('electron');
const IS_PROD = remote.getGlobal('IS_PROD');
function init () {
console.log(
`Initialising Electron Preload Script in environment: ${
IS_PROD ? 'production' : 'development'
}`
);
/**
* Expose only a bridging API to the Fether web app.
* Set methods on global `window`. Additional methods added later by web app
*
* Do not expose functionality or APIs that could compromise the computer
* such as core Electron (i.e. `electron`, `remote`), IPC (`ipcRenderer`)
* or Node.js modules like `require`.
*
* Note however that we require `ipcRenderer` to be exposed for communication
* between the main process and the renderer process. Hence why
* we have had no other choice but to set `contextIsolation: false`
*
* Example 1: Do not expose as `window.bridge.electron` or `window.bridge.remote`.
* Example 2: `require` should not be defined in Chrome Developer Tools Console.
*/
window.bridge = {
currentWindowWebContentsAddListener: remote.getCurrentWindow().webContents
.addListener,
currentWindowWebContentsRemoveListener: remote.getCurrentWindow()
.webContents.removeListener,
currentWindowWebContentsReload: remote.getCurrentWindow().webContents
.reload,
defaultWsInterface: remote.getGlobal('defaultWsInterface'),
defaultWsPort: remote.getGlobal('defaultWsPort'),
ipcRenderer,
isParityRunningStatus: remote.getGlobal('isParityRunning'),
IS_PROD,
wsPort: remote.getGlobal('wsPort')
};
const RENDERER_ORIGIN =
remote.getGlobal('IS_PROD') === true ? 'file://' : 'http://localhost:3000';
/**
* Handler that receives an IPC message from the main process, and passes it
* down to the renderer process.
*
* @param {*} _event The IPC event we receive from the main process.
* @param {*} data The data of the IPC message.
*/
function receiveIpcMessage (_event, data) {
window.postMessage(data, RENDERER_ORIGIN);
}
/**
* Handler that receives a post message from the renderer process, and passes
* it down to the main process.
*
* @param {*} _event The post message event we receive from the renderer process.
*/
function receivePostMessage (event) {
const { data, origin } = event;
if (origin !== RENDERER_ORIGIN) {
return;
}
if (!data) {
return;
}
const { from } = data;
if (from === 'fether:electron') {
// Since `payload` and `frontend` have the same origin, we use the `from`
// field to differentiate who's sending the postMessage to whom. If the
// message has been sent by `electron`, we ignore.
return;
}
ipcRenderer.send('send-to-main', data);
}
init();
ipcRenderer.on('send-to-renderer', receiveIpcMessage);
window.addEventListener('message', receivePostMessage);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<!-- These CSP are for prod. For dev, CSP are set inside @electron-app -->
<meta http-equiv="Content-Security-Policy" content="
block-all-mixed-content;
child-src 'none';
connect-src https: ws:;
default-src 'none';
font-src 'none';
form-action 'none';
frame-src 'none';
img-src 'self' 'unsafe-inline' file: data: blob: https:;
manifest-src 'none';
media-src 'none';
object-src 'none';
prefetch-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' http:;
worker-src blob:;
">
<!--
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!-- These CSP are for prod. For dev, CSP are set inside @electron-app -->
<meta
http-equiv="Content-Security-Policy"
content="
block-all-mixed-content;
child-src 'none';
connect-src 'self' https: ws:;
default-src 'none';
font-src 'none';
form-action 'none';
frame-src 'none';
img-src 'self' 'unsafe-inline' data: https:;
manifest-src 'none';
media-src 'none';
object-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
worker-src blob:;
"
/>
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json">
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
<!--
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
......@@ -38,15 +42,15 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Fether</title>
</head>
<title>Fether</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
......@@ -56,6 +60,5 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
\ No newline at end of file
</body>
</html>
......@@ -12,7 +12,6 @@ import {
Switch
} from 'react-router-dom';
import { inject, observer } from 'mobx-react';
import store from 'store';
import { Modal } from 'fether-ui';
import semver from 'semver';
import { version } from '../../package.json';
......@@ -21,44 +20,42 @@ import i18n, { packageNS } from '../i18n';
import Accounts from '../Accounts';
import BackupAccount from '../BackupAccount';
import Onboarding from '../Onboarding';
import * as postMessage from '../utils/postMessage';
import RequireParityVersion from '../RequireParityVersion';
import RequireHealthOverlay from '../RequireHealthOverlay';
import Send from '../Send';
import Tokens from '../Tokens';
import Whitelist from '../Whitelist';
const LANG_LS_KEY = 'fether-language';
const currentVersion = version;
// The preload scripts injects `ipcRenderer` into `window.bridge`
const {
currentWindowWebContentsAddListener,
currentWindowWebContentsReload,
currentWindowWebContentsRemoveListener,
ipcRenderer,
IS_PROD
} = window.bridge;
// Use MemoryRouter for production viewing in file:// protocol
// https://github.com/facebook/create-react-app/issues/3591
const Router = IS_PROD ? MemoryRouter : BrowserRouter;
const Router =
process.env.NODE_ENV === 'production' ? MemoryRouter : BrowserRouter;
@inject('onboardingStore', 'parityStore')
@observer
class App extends Component {
state = {
currentLanguage: undefined,
newRelease: false // false | {name, url, ignore}
};
componentDidMount () {
if (store.get(LANG_LS_KEY) && i18n.language !== store.get(LANG_LS_KEY)) {
i18n.changeLanguage(store.get(LANG_LS_KEY));
}
currentWindowWebContentsAddListener('set-language', newLanguage => {
postMessage.send('SET_LANGUAGE_REQUEST');
postMessage.listen$('SET_LANGUAGE_RESPONSE').subscribe(newLanguage => {
i18n.changeLanguage(newLanguage);
store.set(LANG_LS_KEY, newLanguage);
currentWindowWebContentsReload();
// Reload whole app when we change language
if (
this.state.currentLanguage &&
this.state.currentLanguage !== newLanguage
) {
window.location.reload();
} else {
this.setState({ currentLanguage: newLanguage });
}
});
window.addEventListener('contextmenu', this.handleRightClick);
......@@ -85,7 +82,6 @@ class App extends Component {
componentWillUnmount () {
window.removeEventListener('contextmenu', this.handleRightClick);
currentWindowWebContentsRemoveListener('set-language');
}
renderModalLinks = () => {
......@@ -113,10 +109,7 @@ class App extends Component {
};
handleRightClick = () => {
if (!ipcRenderer) {
return;
}
ipcRenderer.send('asynchronous-message', 'app-right-click');
postMessage.send('APP_RIGHT_CLICK_REQUEST');
};
/**
......
......@@ -5,25 +5,17 @@
import { action, observable } from 'mobx';
import Api from '@parity/api';
import { distinctUntilChanged, map, take, tap } from 'rxjs/operators';
import light from '@parity/light.js';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { of, timer, zip } from 'rxjs';
import store from 'store';
import { timer } from 'rxjs';
import Debug from '../utils/debug';
import LS_PREFIX from './utils/lsPrefix';
import * as postMessage from '../utils/postMessage';
const debug = Debug('parityStore');
// The preload scripts injects `ipcRenderer` into `window.bridge`
const {
defaultWsInterface,
defaultWsPort,
ipcRenderer,
isParityRunningStatus,
wsPort
} = window.bridge;
const LS_KEY = `${LS_PREFIX}::secureToken`;
export class ParityStore {
......@@ -36,57 +28,40 @@ export class ParityStore {
distinctUntilChanged()
);
@observable
isParityRunning = false;
@observable
token = null;
@observable
api = undefined;
constructor () {
// Retrieve token from localStorage
const token = store.get(LS_KEY);
if (token) {
debug('Got token from localStorage.');
this.setToken(token);
}
// FIXME - consider moving to start of this constructor block since
// if `setToken` method is called then `connectToApi` is called, which
// requires `ipcRenderer` to be defined
if (!ipcRenderer) {
debug(
'Not in Electron, ParityStore will only have limited capabilities.'
// Request WS port and interface from electron
postMessage.send('WS_INTERFACE_REQUEST');
postMessage.send('WS_PORT_REQUEST');
const lsToken = store.get(LS_KEY);
const token$ = lsToken
? of(lsToken).pipe(tap(() => debug('Got token from localStorage.')))
: this.requestNewToken$();
zip(
token$,
postMessage.listen$('WS_INTERFACE_RESPONSE'),
postMessage.listen$('WS_PORT_RESPONSE')
)
.pipe(take(1))
.subscribe(([token, wsInterface, wsPort]) =>
this.connectToApi(token, wsInterface, wsPort)
);
return;