ipcRenderer in CRA managed app
For my first electron app with react i’ve used CRA.
What is kinda comfy to start, but pretty soon I’ve faced a problem that react scripts cannot properly pack ipcRender
from electron
package.
I’ve blamed CRA at the beginning, however some research I’ve found that neither neither parcel, neither webpack won’t solve the problem in a simple and transparent way.
The problem is — if you try to import ipcRender
in web application, it crashes with TypeError: fs.existsSync is not a function
.
To be more precise this ↓
import { ipcRenderer } = from 'electron'
will end-up like this ↓
Without deep sh*t — it cannot be packed, it should be requested by old good require
.
There there are several solutions that, described below, mind the one that suits you more and all of them are pretty simple.
An easy but vulnerable approach
In ./public/electron.js enable nodeIntegration
which will allow use of node packages in renderer process.
Mind this is very VULNERABLE approach, and described just for educational purposes. To be clear, with
nodeIntegration
enabled, the successful XSS attack will provide the malefactor with all the power of NodeJS under privileges of user running the app.
mainWindow = new BrowserWindow({
width: 800, height: 600,
webPreferences: {
nodeIntegration: true
}
})
CRA uses webpack,
and webpack has __non_webpack_require__
function that behaves like classic require
instead of prepacking the specified module.
Fo reuse across react app let’s put it in a separate module say ./src/appRuntime.ts.
import { IpcRenderer } from 'electron' // this is just an interface
declare var __non_webpack_require__: (id: string) => any
const electron = __non_webpack_require__('electron')
export const ipcRenderer: IpcRenderer = electron.ipcRenderer
Further in react app, just import it from ./src/appRuntime.ts
not from electron
.
For example in ./src/App.tsx
import { ipcRenderer } from './appRuntime'
Vulnerable approach example @ github
A (kind of) fine approach
First thing is to disable nodeIntegration
,
and specify path to preload script, in ./public/electron.js
mainWindow = new BrowserWindow({
width: 800, height: 600 ,
webPreferences: {
nodeIntegration: false,
preload: path.resolve(__dirname, './preload.js')
}
})
As nodeIntegration
is disabled, the classic require
function won’t work within rederer context anymore,
however it is still available in preload script context.
Therefore we can resolve electron
using classic require
and store ipcRenderer
in a global variable in ./public/preload.js.
const { ipcRenderer } = require('electron')
window.ipcRenderer = ipcRenderer
In react app we can expect ipcRender
to be available in a global context,
for more convenient use across the app, can use separate module like ./src/appRuntime.ts
import { IpcRenderer } from 'electron' // this is just an interface
export const ipcRenderer: IpcRenderer = (window as any).ipcRenderer
Finally, same as in previous approach, further in react app,
just import it from ./src/appRuntime.ts
not from electron
.
For example in ./src/App.tsx.
import { ipcRenderer } from './appRuntime'
As renderer process does not have access to require
node modules,
this is less vulnerable as previous approach and quite a popular approach.
However even tho it might seem harder to exploit,
the hybrid context is still vulnerable to prototype pollution attach
that once again can give the malefactor full control of a NodeJS process.
A (kind of) fine approach example @ github
The ‘proper’ and secure approach
As hybrid context is a vulnerability, it can be switched off by enabling contextIsolation
,
obviously the nodeIntegration
should be disabled as well, and we’ll still gonna need the
preload script to expose some APIs to the renderer, in ./public/electron.js.
mainWindow = new BrowserWindow({
width: 800, height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, "preload.js")
}
})
With contextIsolation
being enabled,
the preload script will still have access to node modules,
it will isolated from main renderer process so won’t share global variables as well.
Therefore there is a contextBridge
to expose APIs we need to renderer in ./public/preload.js
const {
contextBridge,
ipcRenderer
} = require("electron");
contextBridge.exposeInMainWorld(
"appRuntime", {
send: (channel, data) => {
ipcRenderer.send(channel, data);
},
subscribe: (channel, listener) => {
const subscription = (event, ...args) => listener(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
}
}
}
)
The exposeInMainWorld
function takes two arguments.
First apiKey
will be the name global variable, under which, the exposed api will be available in renderer.
Second api
the object to be exposed.
Also notice that in subscribe, the listener is not being passed directly to ipcRenderer, it’s wrapped in function that prevents leak of ipcRednerer’s event object to a renderer process.
In react app we can expect that exposed object to be available under global variable appRuntime
, as we named it in exposeInMainWorld
,
and what’s left is just to add some typings let’s say in ./src/appRuntime.ts
type Unsubscribe = () => void;
type Listener = (...args: any[]) => void;
interface AppRuntime {
send: (channel: string, data: any) => void
subscribe: (channel: string, listener: Listener) => Unsubscribe
}
const appRuntime = (window as any).appRuntime as AppRuntime
export default appRuntime
Mind that everything you are exposing to renderer process is vulnerable to XSS attacks. So keep it simple — primitives in, primitives out.
Further in react app, just import and use the exported api, for example in ./src/App.tsx
import appRuntime from './appRuntime';
The secure approach example @ github
Good luck, with your great app :)