From 71c28322e6d323092d57141313abfd8618324be6 Mon Sep 17 00:00:00 2001 From: Gabriel Taveira Date: Mon, 17 Aug 2026 10:17:52 -0300 Subject: [PATCH] build(deps): patch development dependency DoS --- package.json | 3 +- patches/image-size@1.2.1.patch | 48 +++++++ pnpm-lock.yaml | 35 ++--- pnpm-workspace.yaml | 9 ++ tools/dependency-security-check.mjs | 194 ++++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 patches/image-size@1.2.1.patch create mode 100644 tools/dependency-security-check.mjs diff --git a/package.json b/package.json index f4d48fb..f2ef0a3 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,10 @@ "format": "oxfmt --check .", "format:fix": "oxfmt .", "typecheck": "tsc --noEmit", - "test": "jest", + "test": "node tools/dependency-security-check.mjs && jest", "prepare": "pnpm run build", "release": "release-it", + "security:check": "node tools/dependency-security-check.mjs && pnpm audit --audit-level high", "expo-module": "expo-module" }, "devDependencies": { diff --git a/patches/image-size@1.2.1.patch b/patches/image-size@1.2.1.patch new file mode 100644 index 0000000..40faa12 --- /dev/null +++ b/patches/image-size@1.2.1.patch @@ -0,0 +1,48 @@ +diff --git a/dist/types/icns.js b/dist/types/icns.js +index f2bfafef3723cb423b110304815e56e3f97f81e0..f7344cdaab5e31a23a64832ead7b5ba837e4831c 100644 +--- a/dist/types/icns.js ++++ b/dist/types/icns.js +@@ -65,9 +65,12 @@ const ICON_TYPE_SIZE = { + }; + function readImageHeader(input, imageOffset) { + const imageLengthOffset = imageOffset + ENTRY_LENGTH_OFFSET; ++ const imageLength = (0, utils_1.readUInt32BE)(input, imageLengthOffset); ++ if (imageLength < SIZE_HEADER) ++ throw new TypeError('Invalid ICNS entry length'); + return [ + (0, utils_1.toUTF8String)(input, imageOffset, imageLengthOffset), +- (0, utils_1.readUInt32BE)(input, imageLengthOffset), ++ imageLength, + ]; + } + function getImageSize(type) { +diff --git a/dist/types/utils.js b/dist/types/utils.js +index 5224bbafe87551ac415cb3de234820ccc0ff6e2c..10eb100aefb42e684abf3305bb1b4de2a1463e02 100644 +--- a/dist/types/utils.js ++++ b/dist/types/utils.js +@@ -49,10 +49,11 @@ function readUInt(input, bits, offset, isBigEndian) { + } + exports.readUInt = readUInt; + function readBox(input, offset) { +- if (input.length - offset < 4) ++ if (input.length - offset < 8) + return; +- const boxSize = (0, exports.readUInt32BE)(input, offset); +- if (input.length - offset < boxSize) ++ const declaredSize = (0, exports.readUInt32BE)(input, offset); ++ const boxSize = declaredSize === 0 ? input.length - offset : declaredSize; ++ if (boxSize < 8 || input.length - offset < boxSize) + return; + return { + name: (0, exports.toUTF8String)(input, 4 + offset, 8 + offset), +@@ -67,9 +68,7 @@ function findBox(input, boxName, offset) { + break; + if (box.name === boxName) + return box; +- // Fix the infinite loop by ensuring offset always increases +- // If box.size is 0, advance by at least 8 bytes (the size of the box header) +- offset += box.size > 0 ? box.size : 8; ++ offset += box.size; + } + } + exports.findBox = findBox; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59afe3b..3bc9ab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ overrides: brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 xcode>uuid: 11.1.1 +patchedDependencies: + image-size@1.2.1: 47b9dd91145f35c3b5349ad9fb3775bac13fc02d305a711a31b69cf285982eef + importers: .: @@ -3764,12 +3767,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.15.0: - resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + js-yaml@3.15.1: + resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==} hasBin: true - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsc-safe-url@0.2.4: @@ -4199,8 +4202,8 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -6527,7 +6530,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -6933,7 +6936,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 - js-yaml: 4.3.0 + js-yaml: 4.3.1 '@humanwhocodes/config-array@0.13.0(supports-color@8.1.1)': dependencies: @@ -7073,7 +7076,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.15.0 + js-yaml: 3.15.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -9049,7 +9052,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.3.0 + js-yaml: 4.3.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -9632,7 +9635,7 @@ snapshots: ignore@5.3.2: {} - image-size@1.2.1: + image-size@1.2.1(patch_hash=47b9dd91145f35c3b5349ad9fb3775bac13fc02d305a711a31b69cf285982eef): dependencies: queue: 6.0.2 @@ -10259,12 +10262,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.15.0: + js-yaml@3.15.1: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -10677,7 +10680,7 @@ snapshots: flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 hermes-parser: 0.35.0 - image-size: 1.2.1 + image-size: 1.2.1(patch_hash=47b9dd91145f35c3b5349ad9fb3775bac13fc02d305a711a31b69cf285982eef) invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 @@ -10767,7 +10770,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.16: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -11125,7 +11128,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fff91bc..85de6cb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,9 @@ +audit: + # image-size has no fixed release. The pnpm patch below fixes both advisories, + # and security:check exercises the patched parsers before audit runs. + ignore: + - GHSA-w3rx-r6r6-pgpr + - GHSA-5p2g-fcmc-qvqq minimumReleaseAge: 20160 minimumReleaseAgeExclude: # Grandfather the exact lockfile from 2026-08-04. Future versions still have @@ -37,6 +43,7 @@ minimumReleaseAgeExclude: - "magic-*" # Let the security fix through the global release-age gate. - brace-expansion@1.1.18 + - nanoid@3.3.18 # pnpm 11 no longer reads the `pnpm` key from package.json, and `overrides` has # no other home, so this file carries the old Yarn pins and scoped security # fixes. Yarn's `**/a/b` globs become pnpm's `a>b` selectors. @@ -46,3 +53,5 @@ overrides: brace-expansion@<1.1.18: ^1.1.18 brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 xcode>uuid: 11.1.1 +patchedDependencies: + image-size@1.2.1: patches/image-size@1.2.1.patch diff --git a/tools/dependency-security-check.mjs b/tools/dependency-security-check.mjs new file mode 100644 index 0000000..2f3b1ff --- /dev/null +++ b/tools/dependency-security-check.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const rootRequire = createRequire(import.meta.url); + +const requireFrom = (packageJsonPath) => createRequire(packageJsonPath); + +const packageFrom = (packageJsonPath, name) => + requireFrom(packageJsonPath).resolve(`${name}/package.json`); + +const versionAt = (packageJsonPath) => + JSON.parse(readFileSync(packageJsonPath, "utf8")).version; + +const expoPackage = rootRequire.resolve("expo/package.json"); +const metroConfigPackage = packageFrom(expoPackage, "@expo/metro-config"); +const metroPackage = packageFrom(metroConfigPackage, "metro"); +const imageSizePackage = packageFrom(metroPackage, "image-size"); +const imageSizeDirectory = dirname(imageSizePackage); + +assert.equal(versionAt(imageSizePackage), "1.2.1"); + +const icnsSource = readFileSync( + join(imageSizeDirectory, "dist/types/icns.js"), + "utf8", +); +const boxSource = readFileSync( + join(imageSizeDirectory, "dist/types/utils.js"), + "utf8", +); + +// Check the installed guards before the isolated parser probes run. +assert.match(icnsSource, /imageLength < SIZE_HEADER/); +assert.match(boxSource, /boxSize < 8/); +assert.match( + boxSource, + /declaredSize === 0 \? input\.length - offset : declaredSize/, +); + +const imageSize = requireFrom(imageSizePackage)("image-size"); +const { getAssetSize } = requireFrom(metroPackage)( + join(dirname(metroPackage), "src/Assets.js"), +); + +const malformedIcns = Buffer.alloc(16); +malformedIcns.write("icns", 0, "ascii"); +malformedIcns.writeUInt32BE(16, 4); +malformedIcns.write("ic07", 8, "ascii"); +malformedIcns.writeUInt32BE(0, 12); + +const malformedJxl = Buffer.alloc(40); +malformedJxl.writeUInt32BE(12, 0); +malformedJxl.write("JXL ", 4, "ascii"); +malformedJxl.writeUInt32BE(20, 12); +malformedJxl.write("ftyp", 16, "ascii"); +malformedJxl.write("jxl ", 20, "ascii"); +malformedJxl.writeUInt32BE(0, 32); +malformedJxl.write("jxlp", 36, "ascii"); + +const probes = { + "direct-icns": () => imageSize(malformedIcns), + "direct-jxl": () => imageSize(malformedJxl), + "metro-icns": () => getAssetSize("png", malformedIcns, "malformed.png"), + "metro-jxl": () => getAssetSize("png", malformedJxl, "malformed.png"), +}; + +const [probeName] = process.argv.slice(2); +if (probeName) { + assert.ok(probeName in probes, `Unknown parser probe: ${probeName}`); + assert.throws(probes[probeName]); + process.exit(0); +} + +for (const name of Object.keys(probes)) { + const result = spawnSync(process.execPath, [import.meta.filename, name], { + encoding: "utf8", + killSignal: "SIGKILL", + timeout: 3_000, + }); + + assert.equal( + result.status, + 0, + [ + `Parser probe failed: ${name}`, + result.error?.message, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join("\n"), + ); +} + +const makeHeif = (metaSize) => { + const input = Buffer.alloc(60); + input.writeUInt32BE(12, 0); + input.write("ftyp", 4, "ascii"); + input.write("heic", 8, "ascii"); + input.writeUInt32BE(metaSize, 12); + input.write("meta", 16, "ascii"); + input.writeUInt32BE(36, 24); + input.write("iprp", 28, "ascii"); + input.writeUInt32BE(28, 32); + input.write("ipco", 36, "ascii"); + input.writeUInt32BE(20, 40); + input.write("ispe", 44, "ascii"); + input.writeUInt32BE(1, 52); + input.writeUInt32BE(1, 56); + return input; +}; + +for (const metaSize of [48, 0]) { + assert.deepEqual(imageSize(makeHeif(metaSize)), { + height: 1, + type: "heic", + width: 1, + }); +} + +const onePixelPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); +assert.deepEqual(getAssetSize("png", onePixelPng, "pixel.png"), { + height: 1, + width: 1, +}); + +const postcssPackage = packageFrom(metroConfigPackage, "postcss"); +const nanoidPackage = packageFrom(postcssPackage, "nanoid"); +const nanoid = requireFrom(nanoidPackage)("nanoid"); + +assert.equal(versionAt(nanoidPackage), "3.3.18"); +assert.equal(nanoid.customAlphabet("abc", 0)(), ""); + +let randomCalls = 0; +const customRandom = nanoid.customRandom("abc", 3, (size) => { + randomCalls += 1; + return new Uint8Array(size); +}); +assert.equal(customRandom(0), ""); +assert.equal(randomCalls, 0); + +const pnpmStore = join(process.cwd(), "node_modules/.pnpm"); +const installedPackages = readdirSync(pnpmStore); + +for (const version of ["3.15.1", "4.3.1"]) { + const directory = installedPackages.find((entry) => + entry.startsWith(`js-yaml@${version}`), + ); + + assert.ok(directory, `js-yaml ${version} is installed`); + + const packageJson = join( + pnpmStore, + directory, + "node_modules/js-yaml/package.json", + ); + const omapPath = join( + dirname(packageJson), + version.startsWith("3.") ? "lib/js-yaml/type/omap.js" : "lib/type/omap.js", + ); + + assert.equal(versionAt(packageJson), version); + assert.doesNotMatch(readFileSync(omapPath, "utf8"), /objectKeys\.indexOf/); + + const yaml = requireFrom(packageJson)("js-yaml"); + const document = `!!omap\n${Array.from( + { length: 1_000 }, + (_, index) => `- k${index}: ${index}`, + ).join("\n")}`; + + assert.equal(yaml.load(document).length, 1_000); +} + +for (const vulnerablePackage of [ + "js-yaml@3.15.0", + "js-yaml@4.3.0", + "nanoid@3.3.16", + "nanoid@3.3.17", +]) { + assert.equal( + installedPackages.some((entry) => entry.startsWith(vulnerablePackage)), + false, + `${vulnerablePackage} must not be installed`, + ); +} + +console.log( + "Dependency security checks passed for js-yaml, nanoid and image-size.", +);