Secure Electron IPC
In this post we’ll look at how to do secure IPC (Inter-Process-Communication) in Electron.
PLEASE NOTE this is NOT a tutorial on Electron or Electron IPC, it will assume you already know how to use Electron and how IPC works.
Why does it matter?
To keep it short, insecure IPC can lead to full blown RCE (Remote Code Execute).
How? If you misconfigure the following two settings:
contextIsolation
set tofalse
nodeIntegration
set totrue
With the above settings, it’s possible to do the following on the front-end in JavaScript:
1
2
3
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('send-ipc-msg-server', 'Hello');
- And this should work perfectly fine.
The RCE Problem
While the above solution might work for IPC, we’ve just opened the back-end up to the front-end.
If someone were to get XSS on the front-end, they could run a command like this:
1
window.require('child_process').execSync('calc');
For a little bit more fun:
1
window.require('fs').readdirSync('C:\\');
So yeah, this is not a good solution. Fortunately, there is a better way to do IPC.
Server Side
electron.js
Our first step will be to ensure BrowserWindow
doesn’t have any insecure option enabled.
contextIsolation
should betrue
.enableRemoteModule
should befalse
.
The second step will to specify the location of our preload.js
file. If you don’t already have a preload.js
file, create one and specify the path:
1
2
3
4
5
6
7
8
9
10
11
12
13
const electron = require('electron');
const BrowserWindow = electron.BrowserWindow;
const path = require('path');
const win = new BrowserWindow({
width: 1800,
height: 1000,
webPreferences: {
contextIsolation: true,
enableRemoteModule: false,
preload: path.join(__dirname, "preload.js")
}
});
Finally we will also need to setup an IPC listener on the back-side. This can be done in electron.js
or another back-side file:
1
2
3
4
5
6
7
8
9
10
const { ipcMain } = require('electron');
// Event handler for incoming IPC messages
ipcMain.on('send-ipc-msg-server', (event, arg) => {
console.log('From CLIENT:');
console.log(arg);
// Send a message back to the client
event.sender.send('send-ipc-msg-client', `Return Message: ${(new Date()).toLocaleString()}`)
});
preload.js
In the preload.js
file, the following code will be required:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld(
"ipcComm", {
send: (channel, data) => {
ipcRenderer.send(channel, data);
},
on: (channel, callback) => {
// Remove `event` parameter (the underscore parameter) as the UI doesn't need it
ipcRenderer.on(channel, (_, ...args) => {
callback(...args);
});
},
removeAllListeners: (channel) => {
ipcRenderer.removeAllListeners(channel);
}
}
);
What does this do? This creates a safe “bridge” for our front-end to talk to the back-end. Any communication with the back-end must go through this bridge.
contextBridge.exposeInMainWorld(apiName, obj)
takes 2 parameters:
apiName
is the key that will be added to the front-endwindow
Object. You can access it usingwindow[apiName]
.obj
is the Object or Value that will be returned when you accesswindow[apiName]
.
The name ipcComm
is arbitrary and you can call it what you like.
The keys inside the provided Object have been setup as follows:
send
a function to send messages to the back-end on a certain channel.on
a function to setup a listener on a channel for when the server sends messages back.removeAllListeners
a function to remove all the listeners on a channel.
Why should you do it like this?
The reason for using this system is so that the front-end can’t import back-end modules directly, nor can it use the contextBridge
or ipcRenderer
modules directly.
Client Side
Now that we have our bridge setup, let’s see how to use it. In a Component let’s add the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
useEffect(() => {
window.ipcComm.on('send-ipc-msg-client', (returnValue) => {
console.log("From SERVER:");
console.log(returnValue);
});
window.ipcComm.send('send-ipc-msg-server', 'Hello');
return () => {
window.ipcComm.removeAllListeners('send-ipc-msg-client');
}
// eslint-disable-next-line
}, [0]);
Break down of the code above:
Line 2-5
setup a listener on thesend-ipc-msg-client
channel with a callback for when a message is received.Line 7
send the messageHello
to the server on thesend-ipc-msg-server
channel.Line 9-11
return a function that will remove all listeners on thesend-ipc-msg-client
channel when the Component is unmounted.Line 12-13
Provide a static value touseEffect
so that the code is only run once when the Component is mounted and NOT after every update.
If you are using a class based Component, then you can setup the listener and send the message in componentDidMount
, and remove the listener in componentWillUnmount
:
1
2
3
4
5
6
7
8
9
10
11
12
componentDidMount() {
window.ipcComm.on('send-ipc-msg-client', (returnValue) => {
console.log("From SERVER:");
console.log(returnValue);
});
window.ipcComm.send('send-ipc-msg-server', 'Hello');
}
componentWillUnmount() {
window.ipcComm.removeAllListeners('send-ipc-msg-client');
}