Skip to content

Commit 39473a9

Browse files
committed
fix #4343: integrity check for binary download
1 parent 2025c9f commit 39473a9

8 files changed

Lines changed: 129 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
* Add integrity checks to fallback download path ([#4343](https://github.com/evanw/esbuild/issues/4343))
6+
7+
Installing esbuild via npm is somewhat complicated with several different edge cases (see [esbuild's documentation](https://esbuild.github.io/getting-started/#additional-npm-flags) for details). If the regular installation of esbuild's platform-specific package fails, esbuild's install script attempts to download the platform-specific package itself (first with the `npm` command, and then with a HTTP request to `registry.npmjs.org` as a last resort).
8+
9+
This last resort path previously didn't have any integrity checks. With this release, esbuild will now verify that the hash of the downloaded binary matches the expected hash for the current release. This means the hashes for all of esbuild's platform-specific binary packages will now be embedded in the top-level `esbuild` package. Hopefully this should work without any problems. But just in case, this change is being done as a breaking change release.
10+
311
## 0.27.7
412

513
* Fix lowering of define semantics for TypeScript parameter properties ([#4421](https://github.com/evanw/esbuild/issues/4421))

Makefile

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,20 @@ version-go:
317317
node scripts/esbuild.js --update-version-go
318318

319319
platform-all: \
320-
platform-aix-ppc64 \
321320
platform-android-arm \
322-
platform-android-arm64 \
323321
platform-android-x64 \
322+
platform-deno \
323+
platform-neutral \
324+
platform-openharmony-arm64 \
325+
platform-wasi-preview1 \
326+
platform-wasm
327+
328+
# Note: The dependency list here must match `generateBinaryHashes` in `./scripts/esbuild.js`
329+
platform-neutral: \
330+
platform-aix-ppc64 \
331+
platform-android-arm64 \
324332
platform-darwin-arm64 \
325333
platform-darwin-x64 \
326-
platform-deno \
327334
platform-freebsd-arm64 \
328335
platform-freebsd-x64 \
329336
platform-linux-arm \
@@ -337,17 +344,19 @@ platform-all: \
337344
platform-linux-x64 \
338345
platform-netbsd-arm64 \
339346
platform-netbsd-x64 \
340-
platform-neutral \
341347
platform-openbsd-arm64 \
342348
platform-openbsd-x64 \
343-
platform-openharmony-arm64 \
344349
platform-sunos-x64 \
345-
platform-wasi-preview1 \
346-
platform-wasm \
347350
platform-win32-arm64 \
348351
platform-win32-ia32 \
349352
platform-win32-x64
350353

354+
# This must happen last because it hashes everything from the steps above
355+
@echo
356+
@echo "# Build: npm/esbuild"
357+
node scripts/esbuild.js npm/esbuild/package.json --version
358+
node scripts/esbuild.js ./esbuild --neutral
359+
351360
platform-internal:
352361
@test -n "$(GOOS)" || (echo "The environment variable GOOS must be provided" && false)
353362
@test -n "$(GOARCH)" || (echo "The environment variable GOARCH must be provided" && false)
@@ -453,12 +462,6 @@ platform-wasm: esbuild go-compiler
453462
$(GO_COMPILER) "$(NODE)" scripts/esbuild.js ./esbuild --wasm
454463
@shasum -a 256 npm/esbuild-wasm/esbuild.wasm
455464

456-
platform-neutral: esbuild
457-
@echo
458-
@echo "# Build: npm/esbuild"
459-
node scripts/esbuild.js npm/esbuild/package.json --version
460-
node scripts/esbuild.js ./esbuild --neutral
461-
462465
platform-deno: platform-wasm go-compiler
463466
@echo
464467
@echo "# Build: deno"

lib/npm/node-install.ts

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@ import os = require('os')
55
import path = require('path')
66
import zlib = require('zlib')
77
import https = require('https')
8+
import crypto = require('crypto')
89
import child_process = require('child_process')
910

10-
const versionFromPackageJSON: string = require(path.join(__dirname, 'package.json')).version
11+
interface PackageJSON {
12+
version: string
13+
'esbuild.binaryHashes': Record<string, string>
14+
}
15+
16+
const packageJSON: PackageJSON = require(path.join(__dirname, 'package.json')) as typeof import('../../npm/esbuild/package.json')
1117
const toPath = path.join(__dirname, 'bin', 'esbuild')
1218
let isToPathJS = true
1319

@@ -48,8 +54,8 @@ which means the "esbuild" binary executable can't be run. You can either:
4854
}
4955
throw err
5056
}
51-
if (stdout !== versionFromPackageJSON) {
52-
throw new Error(`Expected ${JSON.stringify(versionFromPackageJSON)} but got ${JSON.stringify(stdout)}`)
57+
if (stdout !== packageJSON.version) {
58+
throw new Error(`Expected ${JSON.stringify(packageJSON.version)} but got ${JSON.stringify(stdout)}`)
5359
}
5460
}
5561

@@ -115,13 +121,14 @@ function installUsingNPM(pkg: string, subpath: string, binPath: string): void {
115121
// command instead of a HTTP request so that it hopefully works in situations
116122
// where HTTP requests are blocked but the "npm" command still works due to,
117123
// for example, a custom configured npm registry and special firewall rules.
118-
child_process.execSync(`npm install --loglevel=error --prefer-offline --no-audit --progress=false ${pkg}@${versionFromPackageJSON}`,
124+
child_process.execSync(`npm install --loglevel=error --prefer-offline --no-audit --progress=false ${pkg}@${packageJSON.version}`,
119125
{ cwd: installDir, stdio: 'pipe', env })
120126

121127
// Move the downloaded binary executable into place. The destination path
122128
// is the same one that the JavaScript API code uses so it will be able to
123129
// find the binary executable here later.
124130
const installedBinPath = path.join(installDir, 'node_modules', pkg, subpath)
131+
binaryIntegrityCheck(pkg, subpath, fs.readFileSync(installedBinPath))
125132
fs.renameSync(installedBinPath, binPath)
126133
} finally {
127134
// Try to clean up afterward so we don't unnecessarily waste file system
@@ -168,7 +175,7 @@ function applyManualBinaryPathOverride(overridePath: string): void {
168175
fs.writeFileSync(libMain, `var ESBUILD_BINARY_PATH = ${pathString};\n${code}`)
169176
}
170177

171-
function maybeOptimizePackage(binPath: string): void {
178+
function maybeOptimizePackage(binPath: string, isWASM: boolean): void {
172179
// This package contains a "bin/esbuild" JavaScript file that finds and runs
173180
// the appropriate binary executable. However, this means that running the
174181
// "esbuild" command runs another instance of "node" which is way slower than
@@ -190,7 +197,6 @@ function maybeOptimizePackage(binPath: string): void {
190197
//
191198
// This optimization also doesn't apply when npm's "--ignore-scripts" flag is
192199
// used since in that case this install script will not be run.
193-
const { isWASM } = pkgAndSubpathForCurrentPlatform()
194200
if (os.platform() !== 'win32' && !isYarn() && !isWASM) {
195201
const tempPath = path.join(__dirname, 'bin-esbuild')
196202
try {
@@ -219,13 +225,23 @@ function maybeOptimizePackage(binPath: string): void {
219225
}
220226
}
221227

228+
function binaryIntegrityCheck(pkg: string, subpath: string, bytes: Uint8Array): void {
229+
const hash = crypto.createHash('sha256').update(bytes).digest('hex')
230+
const key = `${pkg}/${subpath}`
231+
const expected = packageJSON['esbuild.binaryHashes'][key]
232+
if (!expected) throw new Error(`Missing hash for "${key}"`)
233+
if (hash !== expected) throw new Error(`"${hash.slice(0, 8)}..." doesn't match "${expected.slice(0, 8)}..."`)
234+
}
235+
222236
async function downloadDirectlyFromNPM(pkg: string, subpath: string, binPath: string): Promise<void> {
223237
// If that fails, the user could have npm configured incorrectly or could not
224238
// have npm installed. Try downloading directly from npm as a last resort.
225-
const url = `https://registry.npmjs.org/${pkg}/-/${pkg.replace('@esbuild/', '')}-${versionFromPackageJSON}.tgz`
239+
const url = `https://registry.npmjs.org/${pkg}/-/${pkg.replace('@esbuild/', '')}-${packageJSON.version}.tgz`
226240
console.error(`[esbuild] Trying to download ${JSON.stringify(url)}`)
227241
try {
228-
fs.writeFileSync(binPath, extractFileFromTarGzip(await fetch(url), subpath))
242+
const bytes = extractFileFromTarGzip(await fetch(url), subpath)
243+
binaryIntegrityCheck(pkg, subpath, bytes)
244+
fs.writeFileSync(binPath, bytes)
229245
fs.chmodSync(binPath, 0o755)
230246
} catch (e: any) {
231247
console.error(`[esbuild] Failed to download ${JSON.stringify(url)}: ${e && e.message || e}`)
@@ -246,7 +262,7 @@ async function checkAndPreparePackage(): Promise<void> {
246262
}
247263
}
248264

249-
const { pkg, subpath } = pkgAndSubpathForCurrentPlatform()
265+
const { pkg, subpath, isWASM } = pkgAndSubpathForCurrentPlatform()
250266

251267
let binPath: string
252268
try {
@@ -262,6 +278,12 @@ for your current platform. This install script will now attempt to work around
262278
this. If that fails, you need to remove the "--no-optional" flag to use esbuild.
263279
`)
264280

281+
// The "binary" in the WebAssembly package is not actually a binary, and is
282+
// not self-contained. It's a JavaScript file that references another
283+
// binary "esbuild.wasm" file. The fallback code below assumes that the
284+
// binary is self-contained, so fail now if this is a WebAssembly fallback.
285+
if (isWASM) throw new Error(`Failed to install package "${pkg}"`)
286+
265287
// If that didn't work, then someone probably installed esbuild with the
266288
// "--no-optional" flag. Attempt to compensate for this by downloading the
267289
// package using a nested call to "npm" instead.
@@ -289,7 +311,7 @@ this. If that fails, you need to remove the "--no-optional" flag to use esbuild.
289311
}
290312
}
291313

292-
maybeOptimizePackage(binPath)
314+
maybeOptimizePackage(binPath, isWASM)
293315
}
294316

295317
checkAndPreparePackage().then(() => {

lib/tsconfig-deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"module": "esnext", // Allow the "import.meta" and top-level await syntax
44
"target": "es2017", // Allow calling APIs such as "Object.entries"
55
"strict": true,
6+
"resolveJsonModule": true,
67
"skipLibCheck": true,
78
"types": [
89
"node"

lib/tsconfig-nolib.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"module": "CommonJS", // Allow the "import assignment" syntax
44
"target": "es2017", // Allow calling APIs such as "Object.entries"
55
"strict": true,
6+
"resolveJsonModule": true,
67
"lib": [
78
// Omit "dom" to test what happens with the "WebAssembly" type, which is defined in "dom"
89
"ES5",

lib/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"module": "CommonJS", // Allow the "import assignment" syntax
44
"target": "es2017", // Allow calling APIs such as "Object.entries"
55
"strict": true,
6+
"resolveJsonModule": true,
67
"types": [
78
"node"
89
],

npm/esbuild/package.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,30 @@
4545
"@esbuild/win32-ia32": "0.27.7",
4646
"@esbuild/win32-x64": "0.27.7"
4747
},
48-
"license": "MIT"
48+
"license": "MIT",
49+
"esbuild.binaryHashes": {
50+
"@esbuild/aix-ppc64/bin/esbuild": "e189c30ba15cbdf0ff36985af2b31d3c45921e5c4f7d7a20d0779ced42c9a461",
51+
"@esbuild/android-arm64/bin/esbuild": "f98558df51a9c5bc7611f327e740a49b444eea45549418d5043cef623add2a6c",
52+
"@esbuild/darwin-arm64/bin/esbuild": "b82c6243eb58d520d0bc55cddcf6993dd0f5572f3ab8dc2ba6d77058be043e62",
53+
"@esbuild/darwin-x64/bin/esbuild": "616ac773dfd3ab720d8a000244cc8ef460245d7a2292e94a82a9d17c0919a826",
54+
"@esbuild/freebsd-arm64/bin/esbuild": "55423b64f0d643ed537ec346b45c012e9e38b143331a57b18bc116bfb3010714",
55+
"@esbuild/freebsd-x64/bin/esbuild": "7df169572f3b6f9ef598c83b52e9f132b68c0e20d67d6fc47f96ed4da885f755",
56+
"@esbuild/linux-arm/bin/esbuild": "97d76dfa7aa0e7716eadf5f14a3bce2c010b7fd22b1940b11b89eae04d3135ac",
57+
"@esbuild/linux-arm64/bin/esbuild": "57fa0b46e197f4ff8d67763cc2b1bdf28090578fc5e6bbe71b18473c296fcfcf",
58+
"@esbuild/linux-ia32/bin/esbuild": "b61ad8f30aa458b7dfd79a4b6a744aa101971b85f4f9a508970f9ccf919e8386",
59+
"@esbuild/linux-loong64/bin/esbuild": "d69c5174b36d5ce9c5d9e0455e5e9e97b6b0e007709d16d9761fe0d1c8646740",
60+
"@esbuild/linux-mips64el/bin/esbuild": "ff85f4b896b656eeab590f9389f796792f8afa607200396a1955cf85624d095e",
61+
"@esbuild/linux-ppc64/bin/esbuild": "6b6bdb97ba7523f77c015246579dfd13a7d0a321908221b84fc2f3788f3299f7",
62+
"@esbuild/linux-riscv64/bin/esbuild": "8cb98fa08a9f13b43723387fdd47a842d41e481e4ebacaed8aa516890b5f77f4",
63+
"@esbuild/linux-s390x/bin/esbuild": "03c3adfb52f099b1d61e126cf34738446730bd6160a50e5d52913c95984e8f2e",
64+
"@esbuild/linux-x64/bin/esbuild": "c638273fcf95573ca74af586677800b4dd874c55f8f945adb54316d6902ba14b",
65+
"@esbuild/netbsd-arm64/bin/esbuild": "2fe6fa1e6bd28ac94b0068d0608a26cef41fe264d0c69b4b941a56737b640474",
66+
"@esbuild/netbsd-x64/bin/esbuild": "b7b6b75dad26113b545e5d33ed94c06f7b273f8ebcd0aa501ed6130dae0543c1",
67+
"@esbuild/openbsd-arm64/bin/esbuild": "5f3e6a235bd9d1377011f29a9dca9daf95b8734904c51db2dfdb493529bff052",
68+
"@esbuild/openbsd-x64/bin/esbuild": "79ffa254e3067803e76da4d71a6611cf18620de749fd271318e28abadeabce08",
69+
"@esbuild/sunos-x64/bin/esbuild": "6406fd8a315d5a31c31170d8cf4dc9a97beeed1d24f4499588259edde7c1501f",
70+
"@esbuild/win32-arm64/esbuild.exe": "3ed4a42ee89b22193e33e700b7eb834b1d70e5490f26f0437d65c94888f7d4b1",
71+
"@esbuild/win32-ia32/esbuild.exe": "70d1ad7f1bc85e1fdc0334cf718d8b326bba3c123bf90aed55a6e8a1ea4b4b07",
72+
"@esbuild/win32-x64/esbuild.exe": "44ce6728d54c891b1c5a6d7dbfb1a0f13419884cca0b090f1fbcf0dcd8bee0e9"
73+
}
4974
}

scripts/esbuild.js

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const childProcess = require('child_process')
2+
const crypto = require('crypto')
23
const path = require('path')
34
const fs = require('fs')
45
const os = require('os')
@@ -81,9 +82,49 @@ const buildNeutralLib = (esbuildPath) => {
8182

8283
// Update "npm/esbuild/package.json"
8384
const pjPath = path.join(npmDir, 'package.json')
84-
const package_json = JSON.parse(fs.readFileSync(pjPath, 'utf8'))
85-
package_json.optionalDependencies = optionalDependencies
86-
fs.writeFileSync(pjPath, JSON.stringify(package_json, null, 2) + '\n')
85+
const packageJSON = JSON.parse(fs.readFileSync(pjPath, 'utf8'))
86+
packageJSON.optionalDependencies = optionalDependencies
87+
packageJSON['esbuild.binaryHashes'] = generateBinaryHashes()
88+
fs.writeFileSync(pjPath, JSON.stringify(packageJSON, null, 2) + '\n')
89+
}
90+
91+
function generateBinaryHashes() {
92+
// Note: The dependency list here must match `platform-neutral` in `../Makefile`
93+
const toHash = [
94+
// Unix-like
95+
'@esbuild/aix-ppc64/bin/esbuild',
96+
'@esbuild/android-arm64/bin/esbuild',
97+
'@esbuild/darwin-arm64/bin/esbuild',
98+
'@esbuild/darwin-x64/bin/esbuild',
99+
'@esbuild/freebsd-arm64/bin/esbuild',
100+
'@esbuild/freebsd-x64/bin/esbuild',
101+
'@esbuild/linux-arm/bin/esbuild',
102+
'@esbuild/linux-arm64/bin/esbuild',
103+
'@esbuild/linux-ia32/bin/esbuild',
104+
'@esbuild/linux-loong64/bin/esbuild',
105+
'@esbuild/linux-mips64el/bin/esbuild',
106+
'@esbuild/linux-ppc64/bin/esbuild',
107+
'@esbuild/linux-riscv64/bin/esbuild',
108+
'@esbuild/linux-s390x/bin/esbuild',
109+
'@esbuild/linux-x64/bin/esbuild',
110+
'@esbuild/netbsd-arm64/bin/esbuild',
111+
'@esbuild/netbsd-x64/bin/esbuild',
112+
'@esbuild/openbsd-arm64/bin/esbuild',
113+
'@esbuild/openbsd-x64/bin/esbuild',
114+
'@esbuild/sunos-x64/bin/esbuild',
115+
116+
// Windows
117+
'@esbuild/win32-arm64/esbuild.exe',
118+
'@esbuild/win32-ia32/esbuild.exe',
119+
'@esbuild/win32-x64/esbuild.exe',
120+
]
121+
122+
const hashes = {}
123+
for (const key of toHash) {
124+
const bytes = fs.readFileSync(path.join(repoDir, 'npm', key))
125+
hashes[key] = crypto.createHash('sha256').update(bytes).digest('hex')
126+
}
127+
return hashes
87128
}
88129

89130
async function generateWorkerCode({ esbuildPath, wasm_exec_js, minify, target }) {

0 commit comments

Comments
 (0)