diff --git a/docs/changelog.md b/docs/changelog.md index 81a0ae47..b4ef8e1e 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,6 +10,8 @@ Requires libvips v8.12.1 * Reduce minimum Linux ARM64v8 glibc requirement to 2.17. +* Verify prebuilt binaries with a Subresource Integrity check. + * Standardise WebP `effort` option name, deprecate `reductionEffort`. * Standardise HEIF `effort` option name, deprecate `speed`. diff --git a/docs/install.md b/docs/install.md index d0c6dd7a..5aaeff47 100644 --- a/docs/install.md +++ b/docs/install.md @@ -23,8 +23,9 @@ Ready-compiled sharp and libvips binaries are provided for use on the most commo * Windows x64 * Windows x86 -An ~7MB tarball containing libvips and its most commonly used dependencies -is downloaded via HTTPS and stored within `node_modules/sharp/vendor` during `npm install`. +A ~7MB tarball containing libvips and its most commonly used dependencies +is downloaded via HTTPS, verified via Subresource Integrity +and decompressed into `node_modules/sharp/vendor` during `npm install`. This provides support for the JPEG, PNG, WebP, AVIF, TIFF, GIF and SVG (input) image formats. @@ -78,7 +79,7 @@ npm install --platform=... --arch=... --arm-version=... sharp * `--platform`: one of `linux`, `linuxmusl`, `darwin` or `win32`. * `--arch`: one of `x64`, `ia32`, `arm` or `arm64`. * `--arm-version`: one of `6`, `7` or `8` (`arm` defaults to `6`, `arm64` defaults to `8`). -* `--sharp-install-force`: skip version compatibility checks. +* `--sharp-install-force`: skip version compatibility and Subresource Integrity checks. These values can also be set via environment variables, `npm_config_platform`, `npm_config_arch`, `npm_config_arm_version` diff --git a/install/libvips.js b/install/libvips.js index 6ca27ad0..f5c3bc5a 100644 --- a/install/libvips.js +++ b/install/libvips.js @@ -5,6 +5,7 @@ const os = require('os'); const path = require('path'); const stream = require('stream'); const zlib = require('zlib'); +const { createHash } = require('crypto'); const detectLibc = require('detect-libc'); const semverLessThan = require('semver/functions/lt'); @@ -55,6 +56,33 @@ const handleError = function (err) { } }; +const verifyIntegrity = function (platformAndArch) { + const expected = libvips.integrity(platformAndArch); + if (installationForced || !expected) { + libvips.log(`Integrity check skipped for ${platformAndArch}`); + return new stream.PassThrough(); + } + const hash = createHash('sha512'); + return new stream.Transform({ + transform: function (chunk, _encoding, done) { + hash.update(chunk); + done(null, chunk); + }, + flush: function (done) { + const digest = `sha512-${hash.digest('base64')}`; + if (expected !== digest) { + libvips.removeVendoredLibvips(); + libvips.log(`Integrity expected: ${expected}`); + libvips.log(`Integrity received: ${digest}`); + done(new Error(`Integrity check failed for ${platformAndArch}`)); + } else { + libvips.log(`Integrity check passed for ${platformAndArch}`); + done(); + } + } + }); +}; + const extractTarball = function (tarPath, platformAndArch) { const versionedVendorPath = path.join(__dirname, '..', 'vendor', minimumLibvipsVersion, platformAndArch); libvips.mkdirSync(versionedVendorPath); @@ -66,6 +94,7 @@ const extractTarball = function (tarPath, platformAndArch) { stream.pipeline( fs.createReadStream(tarPath), + verifyIntegrity(platformAndArch), new zlib.BrotliDecompress(), tarFs.extract(versionedVendorPath, { ignore }), function (err) { diff --git a/lib/libvips.js b/lib/libvips.js index 4618fbc2..566bc148 100644 --- a/lib/libvips.js +++ b/lib/libvips.js @@ -8,10 +8,11 @@ const semverCoerce = require('semver/functions/coerce'); const semverGreaterThanOrEqualTo = require('semver/functions/gte'); const platform = require('./platform'); +const { config } = require('../package.json'); const env = process.env; const minimumLibvipsVersionLabelled = env.npm_package_config_libvips || /* istanbul ignore next */ - require('../package.json').config.libvips; + config.libvips; const minimumLibvipsVersion = semverCoerce(minimumLibvipsVersionLabelled).version; const spawnSyncOptions = { @@ -19,6 +20,8 @@ const spawnSyncOptions = { shell: true }; +const vendorPath = path.join(__dirname, '..', 'vendor', minimumLibvipsVersion, platform()); + const mkdirSync = function (dirPath) { try { fs.mkdirSync(dirPath, { recursive: true }); @@ -39,6 +42,10 @@ const cachePath = function () { return libvipsCachePath; }; +const integrity = function (platformAndArch) { + return env[`npm_package_config_integrity_${platformAndArch.replace('-', '_')}`] || config.integrity[platformAndArch]; +}; + const log = function (item) { if (item instanceof Error) { console.error(`sharp: Installation error: ${item.message}`); @@ -67,10 +74,15 @@ const globalLibvipsVersion = function () { }; const hasVendoredLibvips = function () { - const vendorPath = path.join(__dirname, '..', 'vendor', minimumLibvipsVersion, platform()); return fs.existsSync(vendorPath); }; +/* istanbul ignore next */ +const removeVendoredLibvips = function () { + const rm = fs.rmSync ? fs.rmSync : fs.rmdirSync; + rm(vendorPath, { recursive: true, maxRetries: 3, force: true }); +}; + const pkgConfigPath = function () { if (process.platform !== 'win32') { const brewPkgConfigPath = spawnSync('which brew >/dev/null 2>&1 && eval $(brew --env) && echo $PKG_CONFIG_LIBDIR', spawnSyncOptions).stdout || ''; @@ -99,9 +111,11 @@ module.exports = { minimumLibvipsVersion, minimumLibvipsVersionLabelled, cachePath, + integrity, log, globalLibvipsVersion, hasVendoredLibvips, + removeVendoredLibvips, pkgConfigPath, useGlobalLibvips, mkdirSync diff --git a/package.json b/package.json index ae8b6248..79081337 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,19 @@ "license": "Apache-2.0", "config": { "libvips": "8.12.1", + "integrity": { + "darwin-arm64v8": "sha512-Keb3wpyDLdYad3NFvh0qpXXpUDTK/QvUukMuQkUNoIdkMlVL73HI7TOW29UKxCS4C1xFn284miOh3UczvXwToQ==", + "darwin-x64": "sha512-Q0laQN+afWnXjlTLebPLnqLmDBKj8nN6g/VW2jfU3XfzUNqxFM39gCmddKdN5ekIfGka6tZAf8tTJuCqXotVUg==", + "linux-arm64v8": "sha512-gaUQsJReRd2GfUpQWRJnQtRAoLr+CABsGxyMOXk3m4ajp53rGr34l4Pcyet4r/XFOtF9jDvcAOKpyydZMv3qsg==", + "linux-armv6": "sha512-s+nQWZNsclbQopLxbuSAuHfluX31vEpsU020mNAS5VUE2Pemhn4CCGX79thyvXF0hlXxBsxAb18dzU7hXOz/PQ==", + "linux-armv7": "sha512-ra8Yp0FvO3b5dJQ2nqBxc1sBz3R8LQOno8MzNlubg6jWM5FIfGlfFe1yxXLKEXVgqOLf4ABjNPhX6kDQx5v+Kw==", + "linux-x64": "sha512-q2JPFq9FXCEIYKlfdhF70hEtpQquqGMnecYnTlB0Vcjyy1CY6aN+91difiEnvCSCHYzpoXDGv6C/ZNCMmdcD3Q==", + "linuxmusl-arm64v8": "sha512-JwG5rTa2UneL0uZCjbEmw0pRoQY5gnqtT2g9OkSuuGrlbHsrJuBGFVKEnqzox7C3g/yXgk0ePn4JaxefJXFaOQ==", + "linuxmusl-x64": "sha512-Quj7YHPNWZu+HvdQbreSxaWUc9gg1nYTrN/NQLkf7DmME5XrVxA2tkIy7+IIuFz2Tk7LfG8jLJ8c6ZKuYEMBSg==", + "win32-arm64v8": "sha512-QWchuNLKgZSpdh+Ixr9tve1XJFYRir9wFdPfwncJpOAAa5cbmJRMFReYa0iF4ncqO2MxTw6aHcDvfbg6bwaDtw==", + "win32-ia32": "sha512-/uyO2tP0okJOD1UjXIiGurwLlF7M+7MYaldQ9p2in+rUMZxdSuglEhgHS8tjMXHc7QeXMfGaGSW0bxk2Yu1L0A==", + "win32-x64": "sha512-PF+ZcWnDYbsQXWaaJ3yngWD+E0bPq1I7S+xeqms8frrA8trPdQxC8Jjvw4Aaylynn9YMXzPYY7RAeIzopOqFxA==" + }, "runtime": "napi", "target": 5 }, diff --git a/test/unit/libvips.js b/test/unit/libvips.js index 29eb69c9..af1528d6 100644 --- a/test/unit/libvips.js +++ b/test/unit/libvips.js @@ -76,6 +76,27 @@ describe('libvips binaries', function () { }); }); + describe('integrity', function () { + it('reads value from environment variable', function () { + const prev = process.env.npm_package_config_integrity_platform_arch; + process.env.npm_package_config_integrity_platform_arch = 'sha512-test'; + + const integrity = libvips.integrity('platform-arch'); + assert.strictEqual('sha512-test', integrity); + + process.env.npm_package_config_integrity_platform_arch = prev; + }); + it('reads value from package.json', function () { + const prev = process.env.npm_package_config_integrity_linux_x64; + delete process.env.npm_package_config_integrity_linux_x64; + + const integrity = libvips.integrity('linux-x64'); + assert.strictEqual('sha512-', integrity.substr(0, 7)); + + process.env.npm_package_config_integrity_linux_x64 = prev; + }); + }); + describe('safe directory creation', function () { before(function () { mockFS({