Skip to content

build: harden packaged builds with Electron fuses - #3442

Merged
afonsojramos merged 2 commits into
mainfrom
build/harden-packaged-builds
Nov 2, 2026
Merged

afonsojramos merged 2 commits into
mainfrom
build/harden-packaged-builds

Conversation

@setchy

@setchy setchy commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

What

Pins Electron's fuses in electron-builder.js, so packaged builds ship with the security switches turned off instead of Electron's defaults. Also adds a check after packaging that fails the build if an artifact isn't actually hardened.

Supersedes #3423, which does the same thing with an afterPack hook. This uses electron-builder's built-in electronFuses config instead. Credit to @neemlama for finding and documenting this in #3423.

Why

Shipped v7.9.0 builds use fuse wire version 1, so RunAsNode, EnableNodeOptionsEnvironmentVariable and EnableNodeCliInspectArguments all sit at their permissive defaults, and app.asar is never checked on load.

None of this is remotely exploitable, and none of it is a bug in Gitify — these are Electron's defaults. The gap is that packaging never made a deliberate choice about them, and nothing confirmed that shipped builds were hardened.

The fuses

Fuse Value
runAsNode off
enableNodeOptionsEnvironmentVariable off
enableNodeCliInspectArguments off
enableEmbeddedAsarIntegrityValidation on
onlyLoadAppFromAsar on

Two are left at their defaults on purpose:

  • enableCookieEncryption: tokens are already encrypted with safeStorage, Gitify keeps no cookies worth protecting, and Electron documents the fuse as one-way.
  • grantFileProtocolExtraPrivileges: the renderer loads index.html over file://, so removing these privileges breaks asset and sound loading.

electronFuses also sets resetAdHocDarwinSignature, which isn't a fuse. Flipping fuses breaks the ad-hoc signature Electron ships with, and Apple Silicon won't launch a binary with an invalid signature. CI builds macOS unsigned (-c.mac.identity=null), so without this the arm64 build won't start.

Why config instead of an afterPack hook

electron-builder flips fuses in doAddElectronFuses(), which runs between sanityCheckPackage() and doSignAfterPack() and works out each platform's executable path itself — including the lowercased Linux name, gitify. The hook route needs a second copy of @electron/fuses, its own per-platform path logic, and an explicit await; without it, signing can race the fuse write.

No dependency is added: @electron/fuses already comes in through electron-builder, and pnpm-lock.yaml is unchanged.

Verification

doAddElectronFuses() silently does nothing if the config key is missing. So scripts/verifyFuses.js reads the fuses back from each built artifact and fails if they don't match the expected set. That set is hardcoded rather than read from electron-builder.js, because reading it from the config would pass trivially once the config was removed. Both workflows run the check after packaging.

Verified locally on macOS (universal build, Electron 44.7.0, electron-builder 26.17.0):

  • Info.plist has ElectronAsarIntegrity for Resources/app.asar
  • verifyFuses.js passes on the hardened bundle and fails when the fuse config is removed
  • packaging with asar disabled fails the check instead of producing a broken artifact
  • codesign --verify passes and the unsigned build launches on Apple Silicon
  • pnpm check is clean and pnpm test passes (189 files, 1907 tests)

Verified on Linux with a packaged build (Electron 44, electron-builder 26.18.0):

  • verifyFuses.js passes on dist/linux-unpacked/gitify, and fails on stock Electron and on a missing path
  • the hardened binary refuses ELECTRON_RUN_AS_NODE=1, where stock Electron runs it as Node
  • the app starts, loads its UI from app.asar, loads sounds and icons over file://, decrypts an existing profile's tokens, and fetches notifications

CI runs verifyFuses.js after packaging on all three platforms.

Not yet verified: launching the Windows artifact, which needs a Windows runtime.

Notes

  • Planning artifacts live in the shared gitify-specs store as the harden-packaged-builds change, so they're not part of this diff.
  • ci(build): flip Electron fuses in packaged builds #3423 overlaps almost entirely — worth deciding whether to close it in favour of this or rewrite it.

@github-actions github-actions Bot added the build Build, action or package manager changes label Oct 16, 2026
@neemlama

Copy link
Copy Markdown

Thanks for superseding #3423, and for the credit - that means a lot coming from the maintainer.

Worth recording the two mistakes in it, since they are the reason your route is the correct one and not just a stylistic preference:

  • flipFuses() was never awaited, so afterPack resolved while the binary was still being rewritten. Signing could race the write.
  • getExecutablePath() used productFilename for the Linux branch, which is Gitify, while the Linux binary is the lowercased package name gitify. The existsSync guard turned that into a silent skip - which is the exact failure mode I flagged in the PR description and then shipped anyway.

Both were invisible to node --check, to pnpm check, and to the byte-level test I ran against a copy of Gitify.exe, because none of those exercise the hook on a platform it was not written for. The verification that would have caught them is packaging for Linux and reading the fuse wire back out of the artifact, which is what verifyFuses.js now does on every build.

One thing I would still flag in your PR if useful: enableEmbeddedAsarIntegrityValidation is set here, and the note about ElectronAsarIntegrity being injected by @electron/universal during the architecture merge is the part I had assumed wrong. I left that fuse off in #3423 because I read it as requiring a separate asarIntegrity build option and assumed enabling it without one would block launch. The evidence in your PR suggests the two are meant to move together and that electron-builder handles the wiring.

Closing #3423 now.

Pin the hardening fuse set in electron-builder.js so packaged builds refuse ELECTRON_RUN_AS_NODE, NODE_OPTIONS and the Node inspector, encrypt the cookie store, load only from app.asar, and validate that archive before loading it.

Because electron-builder leaves the Electron defaults in place when electronFuses is absent, scripts/verifyFuses.js reads the fuse wire back out of each built artifact and fails when it does not match the expected posture. The expected set is a constant in that script rather than read from the configuration it polices, which would otherwise pass vacuously once the configuration is removed. Both workflows run it after packaging.

resetAdHocDarwinSignature is also set: flipping the fuses invalidates the ad-hoc signature Electron ships with, and build.yml packages macOS unsigned, so the arm64 artifact is killed on launch without it.

Supersedes #3423, which reaches the same outcome by flipping fuses in scripts/afterPack.js. The configuration route needs no new dependency and resolves each platform's executable itself.
@afonsojramos
afonsojramos force-pushed the build/harden-packaged-builds branch from f39d874 to c607bee Compare November 2, 2026 12:40
Comment thread electron-builder.js Resolved
@afonsojramos
afonsojramos marked this pull request as ready for review November 2, 2026 12:53
@afonsojramos
afonsojramos self-requested a review as a code owner November 2, 2026 12:53
@sonarqubecloud

sonarqubecloud Bot commented Nov 2, 2026

Copy link
Copy Markdown

@afonsojramos
afonsojramos merged commit 6700bb0 into main Nov 2, 2026
31 checks passed
@afonsojramos
afonsojramos deleted the build/harden-packaged-builds branch November 2, 2026 13:56
@github-actions github-actions Bot added this to the Release 8.0.0 milestone Nov 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build Build, action or package manager changes

Development

Successfully merging this pull request may close these issues.

3 participants