Skip to content

fix(cli-upload): drop image-size for a built-in PNG/JPEG reader (PER-10427) - #2382

Open
aryanku-dev wants to merge 2 commits into
masterfrom
fix/PER-10427-drop-image-size
Open

fix(cli-upload): drop image-size for a built-in PNG/JPEG reader (PER-10427)#2382
aryanku-dev wants to merge 2 commits into
masterfrom
fix/PER-10427-drop-image-size

Conversation

@aryanku-dev

@aryanku-dev aryanku-dev commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #2380 / PER-10427.

Root cause

npm audit fails for anyone installing @percy/cli because packages/cli-upload/package.json depends on image-size, which has three high-severity advisories and no patched release:

CVE Parser CVSS Patched
CVE-2025-71330 ICNS 8.7 none
CVE-2025-71329 JXL, HEIF 8.7 none

All are CWE-835 (infinite loop) and all share one shape: a zero-valued length field leaves the read offset unchanged, so the parser loops forever and blocks the event loop. Every published version through 2.0.2 is affected, and the upstream repo was archived on 2026-06-03 — the maintainer has said they will not keep fielding these reports on GitHub. There is nothing to upgrade to.

Bumping was not an option for a second reason: #2301 deliberately pinned ~1.0.2 to keep Node 14 support, which image-size 2.x drops.

This was reachable, not just theoretical

image-size selects its parser from magic bytes, while upload.js filters candidates by extension (ALLOWED_FILE_TYPES). A file named screenshot.png whose contents begin with the ICNS magic bytes therefore reached the ICNS parser at packages/cli-upload/src/upload.js:100.

Verified against the shipped code — a 64-byte crafted .png dropped into an upload directory:

$ percy upload ./images --dry-run
[percy] Percy has started!
    ...hangs indefinitely, and does not respond to SIGTERM

Because the loop blocks the event loop, the process cannot handle the signal and needs SIGKILL. Note this is a local CLI reading the user's own directory, so the practical severity is well below the 8.7 CVSS — but it is a real hang, and it is what makes the audit finding non-dismissable.

Why we depended on it in the first place

Worth stating, because it changes how this reads: image-size was never a considered choice for the formats it supports. It arrived in the package's very first commit — 1175e55, "✨ Add @percy/cli-upload package", Mar 2020 — as a one-line convenience to get { width, height } for three uses that still exist today: the clamped widths, minHeight, and the <img width height> attributes on the generated root DOM resource.

That same first commit already restricted the command to PNG and JPEG, in both places:

const ALLOWED_IMAGE_TYPES = /\.(png|jpg|jpeg)$/i;   // upload.js
default: '**/*.{png,jpg,jpeg}'                       // config.js

Across the package's entire history the regex has been renamed (ALLOWED_IMAGE_TYPESALLOWED_FILE_TYPES) but never broadened — git log -S over every revision of the file yields exactly one distinct value. So we have carried a ~20-format parser for a two-format command for five years, and the ICNS parser that hangs was never something this command wanted.

This PR therefore isn't narrowing scope to dodge a CVE; it aligns the dependency with a constraint that has been in place since day one.

Fix

percy upload only ever accepts png, jpg and jpeg, so a general-purpose parser for ~20 formats was always more surface than this command needed. packages/cli-upload/src/image-size.js reads the two formats we actually support, with bounded positioned reads:

  • PNG — dimensions from the IHDR chunk at a fixed offset.
  • JPEG — walks the segment chain to the SOFn frame header. A segment length below 2 is rejected rather than used to advance the offset, which is precisely the bug class above.

Anything else — a GIF, a truncated file, a crafted ICNS buffer — returns null and the file is skipped with a log line instead of crashing or hanging the whole upload.

This removes image-size (and its sole transitive dep queue) from the tree entirely, so the audit finding goes away permanently rather than waiting on an archived project.

Behaviour change

Previously a file with an accepted extension but unreadable contents threw and failed the entire upload run. It is now skipped:

[percy] Skipping file with unreadable image data: crafted.png

This mirrors the existing Skipping unsupported file type path. Valid PNG/JPEG uploads are unaffected.

Testing

packages/cli-upload/test/unit/image-size.test.js — 10 new specs covering PNG and JPEG dimension reads (including a 200x150 JPEG whose frame header sits past kilobyte-scale quantization/Huffman tables, so segment walking is exercised rather than a fixed offset), non-images, truncated input, and termination on all three malformed shapes.

upload.test.js gains an end-to-end regression asserting a crafted ICNS file named .png is skipped and the build still finalizes. Its fixtures now use real PNG/JPEG bytes — the previous fixture was a GIF written through .toString(), which only survived because GIF headers happen to be UTF-8 safe.

Executed 25 of 25 specs — all cli-upload specs pass

End-to-end against a directory holding a 1280x720 PNG, a 640x480 JPEG and the crafted payload: completes in ~6s, reads both real images, skips the crafted one.

🤖 Generated with Claude Code

…10427)

`image-size` is archived upstream and carries three unfixable high-severity
advisories (CVE-2025-71329, CVE-2025-71330), so `npm audit` fails for anyone
who installs @percy/cli. There is no patched version to move to — every
published release through 2.0.2 is affected — and 2.x would also undo the
Node 14 support that #2301 pinned ~1.0.2 to keep.

The command only ever accepts png, jpg and jpeg (ALLOWED_FILE_TYPES), so a
general purpose image parser was always more surface than this needed. Reading
the two formats we actually support is about eighty lines and removes the
dependency outright.

The advisories were reachable here, not just theoretical: `image-size` picks
its parser from magic bytes while `percy upload` filters on extension, so an
ICNS buffer named `.png` reached the ICNS parser and wedged the event loop —
`percy upload` hung indefinitely and did not respond to SIGTERM. Such a file
is now skipped with a log line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aryanku-dev
aryanku-dev requested a review from a team as a code owner August 10, 2026 19:24
CI enforces 100% coverage; image-size.js left branches 51, 56-59 and 70
unhit. Adds fixtures for the four segment-walk exits that had no test:
walking off the chain into non-marker data, a standalone marker preceding
the frame header, SOS/EOI reached before any frame, and a file that ends
before the frame payload it announced.

Also drops the optional chaining on the marker read. The loop bound
`offset + 4 <= fileSize` already proves those four bytes exist, so the
null arm was unreachable and could never be covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

image-size dependency has three high-severity CVEs with no fixes

1 participant