Source: electron/electron#9920 (comment)
I hope that this comment get noticed, because a lot of people are asking about importing fs
or ipcRenderer
in your apps. It's a common-need for electron apps but I found not many people have got it right, and are using outdated patterns. tl;dr - there is a security vulnerability if you don't import your node module (ie. fs
) or electron module (ie. ipcRenderer
) in the correct way. If you are using your app for only yourself you are probably safe, but if you ever want to share or sell your app you should read ahead.
Before I go into the solution, it's important to understand why we are doing this in the first place. Electron apps allow us to include node modules in our apps, which gives them amazing power, but security concerns. We want to allow our app to use native-os (ie. node) features, but we don't want them to be abused.
As brought up by @raddevus in a comment, this is necessary when loading remote content. If your electron app is entirely offline/local, then you are probably okay simply turning on
nodeIntegration:true
. I still would, however, opt to keepnodeIntegration:false
to act as a safeguard for accidental/malicious users using your app, and prevent any possible malware that might ever get installed on your machine from interacting with your electron app and using thenodeIntegration:true
attack vector (incredibly rare, but could happen)!
Setting nodeIntegration: true
in your BrowserWindow gives your renderer process access to node modules. Doing this, is vulnerable. You have access to require("fs")
and require("electron")
, but that means if someone were to find a XSS vulnerability, they could run any command you've exposed in your renderer process.
Think deleting all of the files on your computer, or something else that's really bad.
Alongside setting nodeIntegration to true, it's likely that your app is using webpack to bundle application files. Webpack messes up with certain symbols, so settings like target: 'electron-renderer'
or webpack externals allows you to pass through these variables (ipcRenderer
) into your app instead.
Still, this changes nothing except how you are setting up your app.
You can use the remote module which gives you access to ipcRenderer
. It's basically 'The easy way' in a different form. It's not recommended by Electron's security recommendations to do this since this type of attack suffers from a prototype pollution vector.
Ie. using remote could allow someone to modify a js-object's prototype and wreck havoc on your machine/app.
@marksyzm has a better solution, although not perfect, where we use IPC to send the ipcRenderer
to the renderer process. This type of setup is also vulnerable to prototype pollution attacks. If you want to get your app 80% of the way there, I'd use this method, as it probably won't require you to do much refactoring.
The right way of importing your fs
/ipcRenderer
into your renderer process is with IPC (inter-process-communication). This is Electron's way of allowing you to talk between main and renderer process. Broken down, this is how your app needs to look:
- A BrowserWindow has a
preload
property. This property is a js file that loads with access torequire
(which means you can require ipcRenderer) - Your BrowserWindow will also have
contextIsolation: true
to prevent prototype pollution attacks, but this means you need to use the contextBridge to pass the ipcRenderer to your renderer process - Using the preload script and the contextBridge, you allow your renderer process to access the
ipcRenderer
- In your main script, you create listeners for the ipcRenderer (in the ipcMain module). Within these listeners you can use the
fs
module
Roughly this is what all these steps look like:
main.js
const {
app,
BrowserWindow,
ipcMain
} = require("electron");
const path = require("path");
const fs = require("fs");
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
let win;
async function createWindow() {
// Create the browser window.
win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false, // is default value after Electron v5
contextIsolation: true, // protect against prototype pollution
enableRemoteModule: false, // turn off remote
preload: path.join(__dirname, "preload.js") // use a preload script
}
});
// Load app
win.loadFile(path.join(__dirname, "dist/index.html"));
// rest of code..
}
app.on("ready", createWindow);
ipcMain.on("toMain", (event, args) => {
fs.readFile("path/to/file", (error, data) => {
// Do something with file contents
// Send result back to renderer process
win.webContents.send("fromMain", responseObj);
});
});
preload.js
const {
contextBridge,
ipcRenderer
} = require("electron");
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld(
"api", {
send: (channel, data) => {
// whitelist channels
let validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
let validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => fn(...args));
}
}
}
);
index.html
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8"/>
<title>Title</title>
</head>
<body>
<script>
window.api.receive("fromMain", (data) => {
console.log(`Received ${data} from main process`);
});
window.api.send("toMain", "some data");
</script>
</body>
</html>
At the very least, I believe you need electron v7 for these features.
I care about secure electron apps, and built secure-electron-template
in order to create an electron application template to bake-in security instead of thinking of security as an afterthought.