diff --git a/package.json b/package.json index e3b6be9..c951ebb 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "pods": "pnpm --filter react-native-magic-toast-example exec pod-install --quiet", "prepare": "bob build && husky", "release": "release-it", - "test": "jest", + "test": "jest && node tools/image-size-security-check.cjs", + "test:security": "node tools/image-size-security-check.cjs", "typecheck": "tsc --noEmit" }, "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..117a8f3 --- /dev/null +++ b/patches/image-size@1.2.1.patch @@ -0,0 +1,50 @@ +diff --git a/dist/types/icns.js b/dist/types/icns.js +index f2bfafef3723cb423b110304815e56e3f97f81e0..8a17039c7137b2cbb8c7a3de8c3643516c1d9c39 100644 +--- a/dist/types/icns.js ++++ b/dist/types/icns.js +@@ -81,6 +81,8 @@ exports.ICNS = { + const fileLength = (0, utils_1.readUInt32BE)(input, FILE_LENGTH_OFFSET); + let imageOffset = SIZE_HEADER; + let imageHeader = readImageHeader(input, imageOffset); ++ if (imageHeader[1] < SIZE_HEADER) ++ throw new TypeError('Invalid ICNS entry length'); + let imageSize = getImageSize(imageHeader[0]); + imageOffset += imageHeader[1]; + if (imageOffset === fileLength) +@@ -92,6 +94,8 @@ exports.ICNS = { + }; + while (imageOffset < fileLength && imageOffset < inputLength) { + imageHeader = readImageHeader(input, imageOffset); ++ if (imageHeader[1] < SIZE_HEADER) ++ throw new TypeError('Invalid ICNS entry length'); + imageSize = getImageSize(imageHeader[0]); + imageOffset += imageHeader[1]; + result.images.push(imageSize); +diff --git a/dist/types/utils.js b/dist/types/utils.js +index 5224bbafe87551ac415cb3de234820ccc0ff6e2c..954431e30caa980965dc38dd0cd458c6cbde37fc 100644 +--- a/dist/types/utils.js ++++ b/dist/types/utils.js +@@ -49,10 +49,10 @@ 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) ++ if (boxSize < 8 || input.length - offset < boxSize) + return; + return { + name: (0, exports.toUTF8String)(input, 4 + offset, 8 + offset), +@@ -67,9 +67,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 0e9c565..b338b4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,12 @@ overrides: brace-expansion@1: ^1.1.18 brace-expansion@>=4.0.0 <5.0.9: ^5.0.9 fast-uri@>=3.0.0 <3.1.5: ^3.1.5 + js-yaml@3: ^3.15.1 + js-yaml@4: ^4.3.1 + nanoid@3: ^3.3.18 + +patchedDependencies: + image-size@1.2.1: ecb4ccea31a12b16c8163e69e670834fcb60833c146f1669821a0dd9aca8f9e1 importers: @@ -3450,12 +3456,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: @@ -3919,8 +3925,8 @@ packages: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} - 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 @@ -6446,7 +6452,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.7 chalk: 4.1.2 - js-yaml: 4.3.0 + js-yaml: 4.3.1 '@inquirer/ansi@2.0.7': {} @@ -6574,7 +6580,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': {} @@ -7920,7 +7926,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -8578,7 +8584,7 @@ snapshots: ignore@7.0.6: {} - image-size@1.2.1: + image-size@1.2.1(patch_hash=ecb4ccea31a12b16c8163e69e670834fcb60833c146f1669821a0dd9aca8f9e1): dependencies: queue: 6.0.2 @@ -9112,12 +9118,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 @@ -9611,7 +9617,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=ecb4ccea31a12b16c8163e69e670834fcb60833c146f1669821a0dd9aca8f9e1) invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 @@ -9657,7 +9663,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=ecb4ccea31a12b16c8163e69e670834fcb60833c146f1669821a0dd9aca8f9e1) invariant: 2.2.4 jest-worker: 29.7.0 jsc-safe-url: 0.2.4 @@ -9733,7 +9739,7 @@ snapshots: mute-stream@3.0.0: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -10024,7 +10030,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 cc34c82..25f573c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,13 @@ packages: - example +auditConfig: + # pnpm audits published versions, so it cannot see the local patch below. + # The timeout regression check is the control for both parser advisories. + ignoreGhsas: + - GHSA-5p2g-fcmc-qvqq + - GHSA-w3rx-r6r6-pgpr + # Keep newly published packages out of installs for 14 days. pnpm applies this # to direct and transitive dependencies, including frozen-lockfile installs. minimumReleaseAge: 20160 @@ -28,12 +35,20 @@ minimumReleaseAgeExclude: - "fs-extra@11.4.0" - "giget@3.3.1" - "ip-address@10.3.1" + - "js-yaml@3.15.1" + - "js-yaml@4.3.1" + - "nanoid@3.3.18" - "postcss@8.5.23" - "release-it@21.0.0" - "sax@1.6.1" - "undici@7.29.0" - "yargs@18.1.0" +patchedDependencies: + # image-size is archived and has no published fix for its zero-length entry + # loops, so Metro uses this narrow parser patch until it replaces the package. + image-size@1.2.1: patches/image-size@1.2.1.patch + # React Native's Metro bundler resolves modules by walking up node_modules # directories, so the isolated store layout breaks it. Same setting as # react-native-magic-modal. @@ -51,3 +66,8 @@ overrides: "brace-expansion@>=4.0.0 <5.0.9": "^5.0.9" # GHSA-7p8r-x3mc-p8w7. commitlint reaches fast-uri through ajv. "fast-uri@>=3.0.0 <3.1.5": "^3.1.5" + # GHSA-5p4m-2wfm-xmqj. Jest and Expo reach both maintained js-yaml lines. + "js-yaml@3": "^3.15.1" + "js-yaml@4": "^4.3.1" + # GHSA-2v37-7h3g-55p8. Expo reaches nanoid through PostCSS. + "nanoid@3": "^3.3.18" diff --git a/tools/image-size-security-check.cjs b/tools/image-size-security-check.cjs new file mode 100644 index 0000000..87c6a30 --- /dev/null +++ b/tools/image-size-security-check.cjs @@ -0,0 +1,67 @@ +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); + +const packageRoot = path.dirname(require.resolve("image-size/package.json")); + +const cases = [ + { + name: "ICNS zero-length entry", + module: "icns", + handler: "ICNS", + input: [ + 0x69, 0x63, 0x6e, 0x73, 0x00, 0x00, 0x00, 0x10, 0x69, 0x63, 0x30, 0x37, + 0x00, 0x00, 0x00, 0x00, + ], + method: "calculate", + }, + { + name: "JXL zero-length box", + module: "jxl", + handler: "JXL", + input: [ + 0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a, + 0x00, 0x00, 0x00, 0x00, 0x6a, 0x75, 0x6e, 0x6b, + ], + method: "validate", + }, + { + name: "HEIF zero-length box", + module: "heif", + handler: "HEIF", + input: [ + 0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66, + ], + method: "calculate", + }, +]; + +for (const testCase of cases) { + const script = ` + const handler = require(${JSON.stringify( + path.join(packageRoot, "dist", "types", "PLACEHOLDER.js"), + )}.replace("PLACEHOLDER", ${JSON.stringify(testCase.module)}))[${JSON.stringify( + testCase.handler, + )}]; + try { + handler[${JSON.stringify(testCase.method)}](Uint8Array.from(${JSON.stringify( + testCase.input, + )})); + } catch {} + `; + + const result = spawnSync(process.execPath, ["-e", script], { + timeout: 1_000, + encoding: "utf8", + }); + + if (result.error?.code === "ETIMEDOUT") { + throw new Error(`${testCase.name} trapped the parser in an infinite loop`); + } + if (result.status !== 0) { + throw new Error( + `${testCase.name} failed unexpectedly: ${result.stderr || result.error}`, + ); + } + + console.log(`PASS ${testCase.name}`); +}