From 6e40d355dd8bbca42b7b2c8272a95e08a76ae6e9 Mon Sep 17 00:00:00 2001 From: Neem Lama Date: Thu, 15 Oct 2026 00:19:49 +0545 Subject: [PATCH 1/3] security: flip Electron fuses in packaged builds Shipped v7.9.0 binaries are on fuse wire version 1, meaning @electron/fuses has never run against the build. RunAsNode, EnableNodeOptionsEnvironmentVariable and EnableNodeCliInspectArguments are all left at their permissive framework defaults. With RunAsNode enabled, ELECTRON_RUN_AS_NODE=1 starts the binary as a plain Node runtime, dropping the Electron sandbox entirely. EnableNodeOptionsEnvironmentVariable means NODE_OPTIONS is honoured, so a controlled launch environment can inject --require and load code at startup. EnableNodeCliInspectArguments lets a launch argument attach the Node inspector to the renderer process. None of these are remotely exploitable and none need an embargo. They are the Electron framework defaults, so this is not a bug introduced here. It is the set of switches that were never closed. Adds fuse flipping to the existing afterPack hook so it applies to all three packaged platforms (win/mac/linux). Two deliberate omissions: - EnableEmbeddedAsarIntegrityValidation is left off. It requires the matching `asarIntegrity` option in electron-builder.js, and enabling the fuse without that prevents the app from launching. Worth a separate change. - GrantFileProtocolExtraPrivileges is left at its default, since turning it off breaks file:// handling. OnlyLoadAppFromAsar is set, which is the protection that actually matters here. This runs in afterPack rather than postinstall, so @electron/fuses stays a devDependency and there is no Electron rebuild notice on every npm install. --- package.json | 1 + scripts/afterPack.js | 69 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/package.json b/package.json index f69633f55..3e7c7a1cb 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ }, "devDependencies": { "@discordapp/twemoji": "16.0.1", + "@electron/fuses": "^2.1.3", "@electron/notarize": "3.1.1", "@graphql-codegen/cli": "7.4.5", "@graphql-codegen/schema-ast": "6.1.1", diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 4a7f82d45..7f9032305 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,5 +1,10 @@ const path = require('node:path'); const fs = require('node:fs'); +const { + flipFuses, + FuseVersion, + FuseV1Options, +} = require('@electron/fuses'); const builderConfig = require('../electron-builder'); const electronLanguages = builderConfig.electronLanguages; @@ -23,9 +28,73 @@ const afterPack = async (context) => { removeUnusedLocales(appOutDir, appName); } + flipElectronFuses(appOutDir, appName, platform); + logAfterPackProgress('Completed'); }; +/** + * Locates the packaged application binary for the current platform. + * + * @param {string} appOutDir + * @param {string} appName + * @param {string} platform electron-builder's platform key + * @returns {string} + */ +const getExecutablePath = (appOutDir, appName, platform) => { + switch (platform) { + case 'darwin': + return path.join(appOutDir, `${appName}.app`, 'Contents', 'MacOS', appName); + case 'win32': + return path.join(appOutDir, `${appName}.exe`); + default: + return path.join(appOutDir, appName); + } +}; + +/** + * Hardens the packaged binary by flipping Electron's fuse wire. + * + * Without this the app ships on fuse version 1, which leaves RunAsNode enabled + * and every other switch at its permissive framework default. With RunAsNode on, + * ELECTRON_RUN_AS_NODE=1 starts the binary as a plain Node runtime, + * dropping the Electron sandbox entirely. + * + * EnableEmbeddedAsarIntegrityValidation is deliberately NOT enabled here: it + * requires the matching `asarIntegrity` build option, and enabling it without + * that will stop the app launching. + * + * @param {string} appOutDir + * @param {string} appName + * @param {string} platform + */ +const flipElectronFuses = (appOutDir, appName, platform) => { + logAfterPackProgress('flipping Electron fuses'); + + const executablePath = getExecutablePath(appOutDir, appName, platform); + + if (!fs.existsSync(executablePath)) { + logAfterPackProgress( + `skipping fuses, executable not found: ${executablePath}`, + ); + return; + } + + // NOTE: @electron/fuses v2 takes (executablePath, config). v1.x took + // (config, executablePath) -- swapping these throws at build time. + flipFuses(executablePath, { + version: FuseVersion.V1, + + [FuseV1Options.RunAsNode]: false, + [FuseV1Options.EnableCookieEncryption]: true, + [FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false, + [FuseV1Options.EnableNodeCliInspectArguments]: false, + [FuseV1Options.OnlyLoadAppFromAsar]: true, + }); + + logAfterPackProgress('fuses flipped'); +}; + /** * Removes unused locales for macOS builds. * @param {string} appOutDir From 5c704c4e8eaab42f44ec32df3236b380ad8bb994 Mon Sep 17 00:00:00 2001 From: Adam Setch Date: Wed, 14 Oct 2026 21:51:51 -0400 Subject: [PATCH 2/3] Update package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3e7c7a1cb..3556eb50a 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ }, "devDependencies": { "@discordapp/twemoji": "16.0.1", - "@electron/fuses": "^2.1.3", + "@electron/fuses": "2.1.3", "@electron/notarize": "3.1.1", "@graphql-codegen/cli": "7.4.5", "@graphql-codegen/schema-ast": "6.1.1", From 014b30520770aab2ce7427527248cf38874fcd62 Mon Sep 17 00:00:00 2001 From: Neem Lama Date: Thu, 15 Oct 2026 08:30:05 +0545 Subject: [PATCH 3/3] chore: add @electron/fuses to the lockfile and apply oxfmt `pnpm install --frozen-lockfile` failed because pnpm-lock.yaml did not know about the new devDependency, which would have broken CI. Also applies the formatting oxfmt wants for scripts/afterPack.js. Verified locally with the project's own tooling: $ pnpm check pass: All 491 files are correctly formatted pass: Found no warnings or lint errors in 440 files --- pnpm-lock.yaml | 10 ++++++++++ scripts/afterPack.js | 10 ++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d31c8cb0..6d1bde7cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: '@discordapp/twemoji': specifier: 16.0.1 version: 16.0.1 + '@electron/fuses': + specifier: 2.1.3 + version: 2.1.3 '@electron/notarize': specifier: 3.1.1 version: 3.1.1(supports-color@10.2.2) @@ -491,6 +494,11 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true + '@electron/fuses@2.1.3': + resolution: {integrity: sha512-LoKJUXNiJ4JM8IIrUltSHI+8pkogaGj5wmJx81jE/Wk3g2w1/kfMbTEKNoY5kitGE8hiC12h32R/1SlywFtxXg==} + engines: {node: '>=22.12.0'} + hasBin: true + '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} @@ -4811,6 +4819,8 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 + '@electron/fuses@2.1.3': {} + '@electron/get@3.1.0(supports-color@10.2.2)': dependencies: debug: 4.4.3(supports-color@10.2.2) diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 7f9032305..b62920933 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,10 +1,6 @@ const path = require('node:path'); const fs = require('node:fs'); -const { - flipFuses, - FuseVersion, - FuseV1Options, -} = require('@electron/fuses'); +const { flipFuses, FuseVersion, FuseV1Options } = require('@electron/fuses'); const builderConfig = require('../electron-builder'); const electronLanguages = builderConfig.electronLanguages; @@ -74,9 +70,7 @@ const flipElectronFuses = (appOutDir, appName, platform) => { const executablePath = getExecutablePath(appOutDir, appName, platform); if (!fs.existsSync(executablePath)) { - logAfterPackProgress( - `skipping fuses, executable not found: ${executablePath}`, - ); + logAfterPackProgress(`skipping fuses, executable not found: ${executablePath}`); return; }