diff --git a/lavamoat/webpack/policy-override.json b/lavamoat/webpack/policy-override.json index 333a76891..f2429202c 100644 --- a/lavamoat/webpack/policy-override.json +++ b/lavamoat/webpack/policy-override.json @@ -320,6 +320,12 @@ "window": true, "self": true } + }, + "cypress>lodash": { + "globals": { + "setTimeout": true, + "clearTimeout": true + } } } } diff --git a/public/electron.js b/public/electron.js index d29e68da7..001afc9c2 100644 --- a/public/electron.js +++ b/public/electron.js @@ -57,8 +57,13 @@ function createWindow () { height: 768, icon: path.join(__dirname, iconOS), webPreferences: { - nodeIntegration: true, - contextIsolation: false, + // Security: no Node in the renderer; it reaches main only through the + // contextBridge API in preload.js (window.electronAPI). + nodeIntegration: false, + contextIsolation: true, + // sandbox stays off so the preload can still use Node (require/process) + // for the bridge and Sentry — enabling it is a follow-up. + sandbox: false, preload: path.join(__dirname, 'preload.js') } }) diff --git a/public/preload.js b/public/preload.js index d694d2995..828b7b567 100644 --- a/public/preload.js +++ b/public/preload.js @@ -6,15 +6,118 @@ */ // This file is executed right before electron start loading the index +const { contextBridge, ipcRenderer, shell } = require('electron') const Sentry = require('@sentry/electron') const constants = require('./constants'); -const { ipcRenderer } = require('electron') Sentry.init({ dsn: constants.SENTRY_DSN, release: process.env.npm_package_version }) +const VALID_SEND_CHANNELS = [ + 'ledger:getVersion', + 'ledger:getPublicKeyData', + 'ledger:checkAddress', + 'ledger:sendTx', + 'ledger:getSignatures', + 'ledger:signToken', + 'ledger:sendTokens', + 'ledger:verifyTokenSignature', + 'ledger:verifyManyTokenSignatures', + 'ledger:resetTokenSignatures', + 'app:clear_storage_success', +]; + +const VALID_RECEIVE_CHANNELS = [ + 'ledger:version', + 'ledger:publicKeyData', + 'ledger:address', + 'ledger:txSent', + 'ledger:signatures', + 'ledger:tokenSignature', + 'ledger:tokenDataSent', + 'ledger:tokenSignatureValid', + 'ledger:manyTokenSignatureValid', + 'ledger:tokenSignatureReset', + 'ledger:closed', + 'app:clear_storage', +]; + +/** + * Restore Buffers that the contextBridge downgraded to Uint8Array, before the + * IPC send: the main process and ledgerjs expect Buffers, so public/ledger.js + * stays unchanged. Only plain objects and arrays are traversed; anything else + * (Date, Map, non-Uint8Array typed arrays, ...) is passed through untouched, and + * the WeakSet guards against circular payloads. + */ +function restoreBuffers(value, seen = new WeakSet()) { + if (value instanceof Uint8Array) { + return Buffer.from(value); + } + if (value === null || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return value; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => restoreBuffers(item, seen)); + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, restoreBuffers(item, seen)]) + ); +} + +// Replaces the old `window.require('electron')` access that required nodeIntegration. +contextBridge.exposeInMainWorld('electronAPI', { + send: (channel, ...args) => { + if (VALID_SEND_CHANNELS.includes(channel)) { + ipcRenderer.send(channel, ...args.map(restoreBuffers)); + } + }, + on: (channel, listener) => { + if (VALID_RECEIVE_CHANNELS.includes(channel)) { + // Don't forward the Electron event across the bridge; pass undefined to + // keep the legacy (event, ...args) listener signature. + ipcRenderer.on(channel, (_event, ...args) => listener(undefined, ...args)); + } + }, + removeAllListeners: (channel) => { + if (VALID_RECEIVE_CHANNELS.includes(channel)) { + ipcRenderer.removeAllListeners(channel); + } + }, + // Only http/https may be opened externally; block file: and custom schemes, + // which could trigger host-side execution if the renderer is compromised. + openExternal: (url) => { + try { + const { protocol } = new URL(url); + if (protocol === 'http:' || protocol === 'https:') { + return shell.openExternal(url); + } + } catch (_e) { /* invalid URL */ } + return undefined; + }, + // Sentry runs in the preload (@sentry/electron), outside the LavaMoat-governed + // renderer bundle, on the renderer's behalf. Empty dsn disables it (consent toggle). + sentrySetEnabled: (dsn) => Sentry.init({ dsn, release: process.env.npm_package_version }), + sentryCapture: ({ name, message, stack, extra }) => { + const error = new Error(message); + if (name) error.name = name; + if (stack) error.stack = stack; + Sentry.withScope(scope => { + Object.entries(extra || {}).forEach(([key, item]) => scope.setExtra(key, item)); + Sentry.captureException(error); + }); + }, +}); + process.once('loaded', () => { const oldAccessDataRaw = localStorage.getItem('wallet:accessData'); if (oldAccessDataRaw) { diff --git a/src/constants.js b/src/constants.js index 6d7cf6ab2..2b925da17 100644 --- a/src/constants.js +++ b/src/constants.js @@ -103,11 +103,10 @@ export const MIN_JOB_ESTIMATION = 1; let ipcRenderer = null; -if (window.require) { - // Requiring electron outside main thread must be done like that - // https://github.com/electron/electron/issues/7300 - const electron = window.require('electron'); - ipcRenderer = electron.ipcRenderer; +if (window.electronAPI) { + // window.electronAPI (from preload.js) mirrors the ipcRenderer methods the + // renderer uses, without requiring nodeIntegration. + ipcRenderer = window.electronAPI; } /** diff --git a/src/utils/helpers.js b/src/utils/helpers.js index 0963708e9..aa5e6f740 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -13,9 +13,10 @@ import { networkUpdate, networkSettingsUpdate, networkSettingsUpdateSuccess } fr import { NETWORK_SETTINGS } from '../constants'; import LOCAL_STORE from '../storage'; -let shell = null; -if (window.require) { - shell = window.require('electron').shell; +let openExternal = null; +if (window.electronAPI) { + // Exposed by preload.js via contextBridge; wraps electron's shell.openExternal. + openExternal = window.electronAPI.openExternal; } const helpers = { @@ -31,8 +32,8 @@ const helpers = { openExternalURL(url) { // We use electron shell to open the user external default browser // otherwise it would open another electron window and the user wouldn't be able to copy the URL - if (shell !== null) { - shell.openExternal(url); + if (openExternal !== null) { + openExternal(url); } else { // In case it's running on the browser it won't have electron shell // This should be used only when testing diff --git a/src/utils/wallet.js b/src/utils/wallet.js index 59c5973c1..6302c040b 100644 --- a/src/utils/wallet.js +++ b/src/utils/wallet.js @@ -7,6 +7,7 @@ import { SENTRY_DSN, + VERSION, WALLET_HISTORY_COUNT, METADATA_CONCURRENT_DOWNLOAD, ADDRESS_MODE, @@ -30,14 +31,12 @@ import { import { chunk, get } from 'lodash'; import helpers from '../utils/helpers'; import LOCAL_STORE from '../storage'; +// In Electron, Sentry runs in the preload (@sentry/electron) via the bridge, +// keeping it outside the LavaMoat-governed bundle. In a plain browser (dev/tests) +// there's no bridge, so fall back to the bundled browser SDK. +import * as SentryBrowser from '@sentry/browser'; -let Sentry = null; -// Need to import with window.require in electron (https://github.com/electron/electron/issues/7300) -if (window.require) { - Sentry = window.require('@sentry/electron'); -} else { - Sentry = require('@sentry/browser'); -} +const sentryBridge = (typeof window !== 'undefined' && window.electronAPI) || null; /** * Key string constants for manipulating the storage @@ -459,9 +458,13 @@ const wallet = { * @inner */ initSentry(dsn) { - Sentry.init({ + if (sentryBridge) { + sentryBridge.sentrySetEnabled(dsn); + return; + } + SentryBrowser.init({ dsn: dsn, - release: process.env.npm_package_version + release: VERSION }); }, @@ -491,12 +494,21 @@ const wallet = { * @inner */ sentryWithScope(error, info) { - Sentry.withScope(scope => { - Object.entries(info).forEach( + if (sentryBridge) { + sentryBridge.sentryCapture({ + name: error?.name, + message: error?.message, + stack: error?.stack, + extra: info, + }); + return; + } + SentryBrowser.withScope(scope => { + Object.entries(info || {}).forEach( ([key, item]) => scope.setExtra(key, item) ); // TODO: Add storage snapshot to sentry - Sentry.captureException(error); + SentryBrowser.captureException(error); }); },