Post

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.

  1. Why does it matter?
  2. Server Side
  3. Client Side

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 to false
  • nodeIntegration set to true

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 be true.
  • enableRemoteModule should be false.

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-end window Object. You can access it using window[apiName].
  • obj is the Object or Value that will be returned when you access window[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 the send-ipc-msg-client channel with a callback for when a message is received.
  • Line 7 send the message Hello to the server on the send-ipc-msg-server channel.
  • Line 9-11 return a function that will remove all listeners on the send-ipc-msg-client channel when the Component is unmounted.
  • Line 12-13 Provide a static value to useEffect 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');
}

References and More Info

This post is licensed under CC BY 4.0 by the author.