D
Dennis Tretyakov

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 :)

© 2020 - 2024, Dennis Tretyakov