diff --git a/package.json b/package.json index f69633f55..3556eb50a 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/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 4a7f82d45..b62920933 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -1,5 +1,6 @@ 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 +24,71 @@ 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