electron / electron/asar

`@electron/asar` ≥ 4.1.2: `createPackageFromStreams` computes per-file integrity from the **destination path / CWD** instead of the stream content

Open
#446 0 comments 0 reactions 1 assignee Claimed by @mmaietta View on GitHub
Dominant language
TypeScript
Stars
2.9k
Forks
265
Avg merge
3h 52m
Merged PRs (30d)
5

Description

Full disclosure: debugging and write-up performed with Claude Opus.

Reproduced, while attempting to upgrade electron/asar version, on Windows during electron-builder e2e auto-updater tests (build->install exe->launch->auto-update->launch + verify version->uninstall)
https://github.com/electron-userland/electron-builder/actions/runs/27425068451/job/81060912131?pr=9889

Impact radius might be fairly limited since it's only through the Streaming API.

I'll get started on a fix, but I wanted to report the deeper details here in case anyone else runs into this issue.

---------------

## Summary

`Filesystem.insertFile` contains a synchronous "fast path" (added in **4.1.2**) that computes a packed file's embedded integrity by reading the file from disk with `fs.readFileSync(p)`, where `p` is the file's **destination path inside the archive**.

When packing via **`createPackageFromStreams`**, the file's content comes from the supplied stream, *not* from a file located at `p`. Because `p` is a path relative to the archive root, `fs.readFileSync(p)` resolves it against the current working directory. If the CWD happens to contain a file at that same relative path (extremely common — e.g. `package.json`, `index.js`, `node_modules//package.json` when building a project from its root), the archive header records the integrity hash of **the wrong file** while the archive stores the correct bytes from the stream.

The result is an `.asar` whose embedded per-file integrity does not match its own contents. When the consuming Electron app is built with the **`EnableEmbeddedAsarIntegrityValidation`** fuse, Electron validates file contents against the header integrity at load time, detects the mismatch, and refuses to load `app.asar` — the app exits immediately at launch with no output.

If the CWD does *not* contain a colliding path, `fs.readFileSync(p)` throws `ENOENT`, is silently caught, and the code falls back to the correct stream-based hashing. This makes the corruption **silent and environment-dependent**: it only manifests when a same-named file exists in the CWD, so it can pass in one environment and fail in another with no error at pack time.

## Affected versions

| Version | Status |
| --- | --- |
| ≤ 4.1.1 | ✅ Not affected — integrity is computed from the stream (`getFileIntegrity(streamGenerator())`) |
| 4.1.2 | ❌ Affected — fast path introduced |
| 4.2.0 (latest) | ❌ Affected |

## Root cause

`lib/filesystem.js` → `Filesystem.insertFile` (line numbers from 4.2.0):

```js
insertFile(p, streamGenerator, shouldUnpack, file, options = {}) {
// ...
const size = file.stat.size;
// ...
node.size = size;
node.offset = this.offset.toString();
// ...
if (size <= BUFFER_HASH_THRESHOLD) { // 2 MB
// Fully synchronous fast path — no Promise, no stream, no microtask yield
try {
const fileBuffer = fs.readFileSync(p); // <-- reads `p` from disk (the DEST path / CWD)
node.integrity = getFileIntegrityFromBuffer(fileBuffer);
file.cachedBuffer = fileBuffer;
this.offset += BigInt(size);
return Promise.resolve();
}
catch {
// Fall through to stream path
}
}
return getFileIntegrity(streamGenerator()).then((integrity) => {
node.integrity = integrity;
this.offset += BigInt(size);
});
}
```

This is correct for **`createPackageFromFiles`**, which calls:

```js
await filesystem.insertFile(filename, () => fs.createReadStream(filename), shouldUnpack, file, options);
```

— here `p === filename` is the real on-disk source path, and the stream reads the same file, so `fs.readFileSync(p)` is a valid optimization.

It is **incorrect** for **`createPackageFromStreams`**, which calls:

```js
const filename = path.normalize(destinationPath); // destination path inside the archive (relative)
// ...
return filesystem.insertFile(filename, stream.streamGenerator, stream.unpacked, {
type: 'file',
stat: stream.stat,
});
```

— here `p` is the **destination** path and `stream.streamGenerator` reads from an unrelated source. The fast path therefore hashes `CWD/` (whatever happens to be there) rather than the stream content.

Note the archive *bytes* are written separately by `streamFilesystem` from `streamGenerator()` (correct), so only the **header integrity** is wrong — the two disagree.

## Minimal reproduction

```js
import { createHash } from "crypto"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import * as asar from "@electron/asar" // 4.2.0

const work = fs.mkdtempSync(path.join(os.tmpdir(), "asar-bug-"))
const srcDir = path.join(work, "src")
const cwdDir = path.join(work, "cwd")
fs.mkdirSync(srcDir); fs.mkdirSync(cwdDir)

const REAL = "REAL-APP-CONTENT\n"
const DECOY = "DECOY-CONTENT-IN-CWD\n"
fs.writeFileSync(path.join(srcDir, "real.js"), REAL)
// a file in the CWD at the same *relative destination path* used below ("index.js")
fs.writeFileSync(path.join(cwdDir, "index.js"), DECOY)

const streams = [{
path: "index.js", // destination path inside the archive
type: "file",
unpacked: false,
streamGenerator: () => fs.createReadStream(path.join(srcDir, "real.js")), // real content
stat: fs.statSync(path.join(srcDir, "real.js")),
}]

const outAsar = path.join(work, "app.asar")
const orig = process.cwd()
process.chdir(cwdDir) // emulate building from a project directory
try { await asar.createPackageFromStreams(outAsar, streams) }
finally { process.chdir(orig) }

const archivedBytes = asar.extractFile(outAsar, "index.js").toString()
const headerHash = JSON.parse(asar.getRawHeader(outAsar).headerString).files["index.js"].integrity.hash

console.log("archived bytes are REAL: ", archivedBytes === REAL) // true
console.log("header integrity == sha256(REAL):", headerHash === createHash("sha256").update(REAL).digest("hex")) // false
console.log("header integrity == sha256(DECOY):", headerHash === createHash("sha256").update(DECOY).digest("hex")) // true <-- BUG
```

Output:

```
archived bytes are REAL: true
header integrity == sha256(REAL): false
header integrity == sha256(DECOY): true
```

The header integrity matches the **decoy** file that happened to be in the CWD, not the bytes actually written to the archive.

## Impact

- Tools that pack `.asar` archives from streams (e.g. **electron-builder**, which uses `createPackageFromStreams` to support content transforms) produce archives whose embedded per-file integrity does not match the archived content whenever the build CWD contains a same-named relative path. Building a project from its own root directory reliably triggers this for top-level files such as `package.json`.
- Apps built with the `EnableEmbeddedAsarIntegrityValidation` fuse (recommended for security, and required on Windows for ASAR integrity) **fail to launch**: Electron rejects `app.asar` before any application JavaScript runs.
- The failure is silent at pack time and depends on the contents of the CWD, making it hard to diagnose.

## Suggested fix

Per-file integrity must be derived from the same source as the bytes written to the archive. For the streams API the only authoritative source is the stream, so the buffer fast path must not be used there. Options:

1. Only take the `fs.readFileSync(p)` fast path when `p` is known to be the real on-disk source (i.e. the `createPackageFromFiles` path). Have `createPackageFromStreams` pass an explicit flag (e.g. `insertFile(..., { fromStream: true })`) and guard the fast path with `if (!options.fromStream && size <= BUFFER_HASH_THRESHOLD)`.
2. Or compute the fast-path buffer from the stream (`streamGenerator()`) rather than from `fs.readFileSync(p)` when packing streams.

Either keeps the 4.2.0 batched-write performance improvement while restoring correct integrity for stream-packed archives.

## Workaround

Pin `@electron/asar` to `4.1.1` (the last release that hashes the stream content via `getFileIntegrity(streamGenerator())`).

## Environment

- `@electron/asar`: 4.1.2 → 4.2.0 (latest)
- Node.js: 22.x
- Observed via electron-builder packaging with `enableEmbeddedAsarIntegrityValidation: true` + `onlyLoadAppFromAsar: true`; the packaged Electron app exits at launch with no stdout.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.