Skip to content

build: harden packaged builds with Electron fuses - #3442

Draft
setchy wants to merge 1 commit into
mainfrom
build/harden-packaged-builds
Draft

setchy wants to merge 1 commit 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 hardening switches closed rather than at the framework defaults, and adds a post-packaging check that fails the build when an artifact is not actually hardened.

Supersedes #3423, which reaches the same outcome by flipping fuses in scripts/afterPack.js. This route uses electron-builder's built-in electronFuses configuration instead.

Credit to @neemlama, who identified and evidenced this in #3423: the shipped v7.9.0 build sitting on fuse wire version 1, the fuse set applied here, and the reasoning for leaving grantFileProtocolExtraPrivileges at its default all come from that analysis.

Why

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

None of these are remotely exploitable, and none are a bug introduced by Gitify — they are Electron's defaults. The gap is that the packaging configuration made no deliberate choice about them, and nothing asserted the shipped artifacts were hardened.

The posture

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

grantFileProtocolExtraPrivileges is deliberately left at the Electron default: the renderer loads index.html over file://, so removing those privileges breaks asset and sound loading.

electronFuses also sets resetAdHocDarwinSignature, which is not a fuse. Flipping invalidates the ad-hoc signature Electron ships with, and Apple Silicon refuses to launch a binary whose signature no longer validates. build.yml packages macOS unsigned (-c.mac.identity=null), so without it the arm64 artifact is killed on launch.

Why the configuration route over an afterPack hook

electron-builder flips fuses in doAddElectronFuses(), deliberately placed between sanityCheckPackage() and doSignAfterPack(), and resolves each platform's executable itself — including LinuxPackager.executableName, which is the lowercased package name (gitify, not Gitify). The hook route needs a second copy of @electron/fuses, a hand-rolled per-platform path, and an explicit await; a fire-and-forget call resolves the hook while the binary is still being rewritten, letting signing race the write.

No dependency is added. @electron/fuses already resolves transitively through electron-builder, and pnpm-lock.yaml is unchanged.

Verification

doAddElectronFuses() is a silent no-op when the configuration key is absent, so 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 electron-builder.js: deriving it from the configuration passed vacuously once the configuration was removed, which is the exact failure this guards against. Both workflows run the check after packaging and before artifact cleanup.

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

  • the merged Info.plist carries ElectronAsarIntegrity for Resources/app.asar, injected by @electron/universal during the architecture merge
  • verifyFuses.js passes on the hardened bundle, and fails on one built with the fuse configuration removed
  • packaging with asar disabled fails on the archive guard rather than producing an artifact that cannot start
  • codesign --verify passes and the unsigned artifact launches on Apple Silicon
  • pnpm check is clean, and pnpm test passes 189 files / 1907 tests

Not yet verified, and why this is a draft: the checker's Windows and Linux code paths have never been exercised (only macOS binaries have), and Windows launchability needs a Windows runtime. Both are covered by the workflow wiring but need a CI run to confirm.

Notes

  • Planning artifacts live in the shared gitify-specs store as the harden-packaged-builds change, so they are not part of this diff.
  • Worth deciding whether ci(build): flip Electron fuses in packaged builds #3423 is closed in favour of this or rewritten; the two overlap almost entirely.

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.
@github-actions github-actions Bot added the build Build, action or package manager changes label Oct 16, 2026
@sonarqubecloud

Copy link
Copy Markdown

@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.

This branch has not been deployed

No deployments
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.

2 participants