Skip to content

fix(cli-upload): replace image-size with probe-image-size (PER-10427) - #2382

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

fix(cli-upload): replace image-size with probe-image-size (PER-10427)#2382
aryanku-dev wants to merge 4 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 depends on image-size, which has unpatched high-severity advisories:

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

Both are CWE-835 (infinite loop) and share one shape: a zero-valued length field leaves the read offset unchanged, so the parser loops forever and blocks the event loop. Every version through 2.0.2 is affected and the upstream repo was archived on 2026-06-03, so there is nothing to upgrade to. Bumping was doubly blocked — #2301 pinned ~1.0.2 to keep Node 14 support, which image-size 2.x drops.

This was reachable, not 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.

Verified against the shipped code — a 64-byte crafted .png in 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. This is a local CLI reading the user's own directory, so practical severity is well below the CVSS score — but it is a real hang, and it is what makes the audit finding non-dismissable.

Fix

Replaces image-size with probe-image-size.

It is immune to this bug class by construction, not just unreported: it has no ICNS, JXL or HEIF parser at all, and its ISOBMFF reader rejects a box smaller than its own header (size < 8) rather than advancing by it. Its whole tree is advisory-free, it is pure ES5 so Node 14 is unaffected, and 7.4.0 is current.

Only the sync.js entrypoint is imported — the buffer parsers, none of the http or stream machinery. The extension is required because the package publishes no exports map and cli-upload is ESM.

Two details in upload.js:

  • Reads a bounded 512 KiB header, the same MaxBufferSize image-size applied, so no file that used to be readable becomes unreadable and large images are not loaded whole just to read dimensions.
  • Gates on the reported type. The parser reads about ten formats while this command accepts only png and jpeg, so anything else is rejected. A GIF named .png clears the extension filter and parses fine — only the gate keeps it out.

image-size and its transitive queue are gone. probe-image-size brings 8 transitive packages (needle, iconv-lite, sax, safer-buffer, debug, ms, lodash.merge, stream-parser); since cli-upload ships unbundled these install for end users even though sync.js never touches them. npm audit on that tree reports 0 vulnerabilities.

Behaviour change

A file with an accepted extension but unreadable contents previously threw and failed the entire upload run. It is now skipped, mirroring the existing Skipping unsupported file type path:

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

Valid PNG/JPEG uploads are unaffected.

Testing

upload.test.js gains three specs — a file with an accepted extension but unreadable contents, a crafted ICNS file named .png (the CVE-2025-71330 regression), and a GIF named .png for the format gate. Each asserts the file 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 16 of 16 specs — SUCCESS

End-to-end against a directory holding a 1280x720 PNG, a 640x480 JPEG, a GIF renamed .png and the crafted ICNS payload:

[percy] Skipping file with unreadable image data: crafted.png
[percy] Skipping file with unreadable image data: gif-renamed.png
[percy] Snapshot found: shot-1280x720.png
[percy] Snapshot found: shot-640x480.jpg
[percy] Found 2 snapshots

Reads both real images, skips the other two, no hang.

🤖 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>

@aryanku-dev aryanku-dev left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review (automated) — 3 inline finding(s). Full report in the PR comment below. Verdict: Passed.

Comment thread packages/cli-upload/src/image-size.js Outdated
// signature (8) + chunk length (4) + chunk type (4) + width (4) + height (4)
function pngSize(fd) {
let header = readAt(fd, 24, 0);
if (!header?.subarray(12, 16).equals(IHDR)) return null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Apple "fried" (CgBI) PNGs are now silently skipped

image-size@1.0.2 special-cased the CgBI chunk that Apple's PNG optimiser (Xcode / iOS asset-catalog exports) inserts before IHDR, which shifts IHDR from offset 12 to 28 and the dimensions from 16/20 to 32/36. This check requires IHDR at offset 12, so a fried PNG returns null — and with the new skip-don't-throw path in upload.js it is dropped from the upload with only an info-level log line. Previously it was sized and uploaded correctly.

No other format-coverage gap was found: progressive JPEGs, large-EXIF JPEGs and multi-chunk PNGs are all handled.

Suggestion: detect CgBI at offset 12 and re-read at the shifted offsets, e.g.

const CGBI = Buffer.from('CgBI', 'ascii');

function pngSize(fd) {
  let header = readAt(fd, 40, 0) ?? readAt(fd, 24, 0);
  if (!header) return null;

  if (header.length >= 40 && header.subarray(12, 16).equals(CGBI)) {
    return header.subarray(28, 32).equals(IHDR)
      ? { width: header.readUInt32BE(32), height: header.readUInt32BE(36) }
      : null;
  }

  return header.subarray(12, 16).equals(IHDR)
    ? { width: header.readUInt32BE(16), height: header.readUInt32BE(20) }
    : null;
}

The ?? readAt(fd, 24, 0) fallback keeps the existing truncated-PNG specs returning null. Alternatively, accept the limitation and record it in the PR description / release notes.

Reviewer: stack-code-reviewer

Comment thread packages/cli-upload/src/upload.js Outdated
} else {
let absolutePath = path.resolve(args.dirname, relativePath);
let img = { relativePath, absolutePath, ...imageSize(absolutePath) };
let size = imageSize(absolutePath);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Filesystem-level throws from imageSize() still abort the whole run

The continue below is correctly placed — it skips only this file, not the loop — and the new regression specs confirm the run completes and other images still upload.

But this call sits outside any try, so fs.openSync failures (permission denied, broken symlink, file deleted between the glob and the read) still throw and kill the entire upload. That is not a regression — image-size behaved the same — but it runs against this PR's stated intent that one bad file shouldn't take down the run.

Suggestion: decide explicitly — either wrap the call and treat FS errors as another skip, or note that FS-level failures remain intentionally fatal.

Reviewer: stack-code-reviewer

.toEqual({ width: 320, height: 240 });
});

// CVE-2025-71330 / CVE-2025-71329 — the advisories that made `image-size`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] This comment implies coverage the fixture does not exercise

The comment presents the ICNS fixture as pinning the zero-length-entry loop-termination behaviour, but imageSize() dispatches purely on the first 8 bytes — an ICNS buffer is rejected at the signature check before any segment-walking code runs.

The test is still valuable: it is a genuine dispatcher-safety regression, proving a .png-named ICNS no longer reaches a vulnerable parser. The comment just describes something else.

Suggestion: reword to describe what it actually proves.

Reviewer: stack-code-reviewer

@aryanku-dev

Copy link
Copy Markdown
Contributor Author

Claude Code PR Review

PR: #2382Head: cb6ad32Reviewers: stack-code-reviewer

Summary

Removes the archived image-size dependency — source of three unfixable high-severity CWE-835 advisories (CVE-2025-71329, CVE-2025-71330) — and replaces it with a hand-written PNG/JPEG-only dimension reader, matching the png/jpg/jpeg restriction percy upload has enforced since its first commit. Fixes #2380.

Review Table

Priority Category Check Status Notes
High Security No hardcoded secrets or credentials Pass No credentials introduced.
High Security Authentication/authorization checks present N/A No auth surface touched.
High Security Input validation and sanitization Pass Every read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
High Security No IDOR — resource ownership validated N/A No multi-tenant resource access.
High Security No SQL injection (parameterized queries) N/A No SQL.
High Correctness Logic is correct, handles edge cases Pass PNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
High Correctness Error handling is explicit, no swallowed exceptions Pass Unreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
High Correctness No race conditions or concurrency issues Pass Synchronous, single-fd reads.
Medium Testing New code has corresponding tests Pass 15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
Medium Testing Error paths and edge cases tested Pass Truncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
Medium Testing Existing tests still pass (no regressions) Pass cli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
Medium Performance No N+1 queries or unbounded data fetching Pass Positioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
Medium Performance Long-running tasks use background jobs N/A Not applicable.
Medium Quality Follows existing codebase patterns Pass Mirrors the existing Skipping unsupported file type log path.
Medium Quality Changes are focused (single concern) Pass Scoped to cli-upload.
Low Quality Meaningful names, no dead code Pass
Low Quality Comments explain why, not what Pass One misleading test comment — finding 3.
Low Quality No unnecessary dependencies added Pass Net removal of image-size and its transitive queue.

Findings

1. Apple "fried" (CgBI) PNGs are now silently skipped

  • File: packages/cli-upload/src/image-size.js:35
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue: image-size@1.0.2 special-cased the CgBI chunk that Apple's PNG optimiser (Xcode / iOS asset-catalog exports) inserts before IHDR, shifting IHDR from offset 12 to 28 and the dimensions from 16/20 to 32/36. pngSize() requires IHDR at offset 12 and returns null otherwise. Combined with the new skip-don't-throw behaviour, such a file is now dropped from the upload with only an info-level log line — where previously it was sized and uploaded correctly. This is a genuine functional regression for a real, non-adversarial input class. No other format-coverage gap was found: progressive JPEGs, large-EXIF JPEGs and multi-chunk PNGs are all handled.
  • Suggestion: Either detect CgBI at offset 12 and re-read at the shifted offsets, or consciously accept it and record the limitation in the PR description / release notes so it does not surface as a support ticket.

2. Filesystem-level throws from imageSize() still abort the entire run

  • File: packages/cli-upload/src/upload.js:100
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The continue is correctly placed inside the for...of loop and skips only the offending file — verified against the surrounding structure and the passing regression specs. But imageSize() is called outside any try, so fs.openSync failures (permission denied, broken symlink, file deleted between glob and read) still throw and kill the whole upload. Not a regression — image-size behaved the same — but it sits against this PR's stated intent that one bad file should not kill the run.
  • Suggestion: Decide explicitly: either wrap the call and treat FS errors as another skip, or note that FS-level failures are intentionally still fatal.

3. ICNS regression test's comment implies coverage it does not exercise

  • File: packages/cli-upload/test/unit/image-size.test.js:93
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The comment presents the fixture as pinning the zero-length-entry loop-termination behaviour, but imageSize() dispatches purely on the first 8 bytes, so an ICNS buffer is rejected before any segment-walking code runs. The test is a valid dispatcher-safety regression (a .png-named ICNS no longer reaches a vulnerable parser) — the comment just describes something else.
  • Suggestion: Reword to describe what it actually proves.

Notes on areas explicitly checked and found clean

  • Loop termination (the CWE-835 class this PR exists to fix): the length < 2 guard at image-size.js:85 guarantees offset advances by ≥1 every iteration, so the walk is provably bounded by fileSize. The reviewer found no input shape producing a non-terminating loop.
  • File descriptors: fd is closed in a finally covering every return path and every throw. No leaks.
  • Coverage: static branch-by-branch trace found no added line or branch lacking a covering test. Not confirmed against a clean nyc run — coverage collection did not activate cleanly in the reviewer's partial worktree — so the 100% gate should be taken from CI, which is green on this head.

Verdict: PASS

…uilt-in parser

Replaces the hand-rolled PNG/JPEG reader with `probe-image-size`, so the
package depends on a maintained parser rather than one we own.

`probe-image-size` has no ICNS, JXL or HEIF parser at all, and its ISOBMFF
reader rejects a box smaller than its own header rather than advancing by it
— the shape of every advisory that made `image-size` unfixable. Its tree is
advisory-free, it is pure ES5 so Node 14 is unaffected, and 7.4.0 is current.

Only the `sync.js` entrypoint is imported, which pulls in the buffer parsers
and none of the http or stream machinery. The extension is required because
the package publishes no `exports` map and this package is ESM.

Two details worth noting:

- Reads a bounded 512 KiB prefix, matching the `MaxBufferSize` that
  `image-size` applied, so no file that used to be readable becomes
  unreadable.
- Gates on the reported type, because the parser reads about ten formats
  while `upload` accepts only png and jpeg. A GIF named `.png` is still
  skipped.

`jpegStandaloneMarkerBeforeFrame` declared an 11-byte SOF0 segment inside a
16-byte buffer, one byte past the end. The previous reader returned
dimensions anyway because it never checked the declared length against the
bytes present; this one does, so the fixture is now a valid single-component
frame header rather than a truncated one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aryanku-dev aryanku-dev changed the title fix(cli-upload): drop image-size for a built-in PNG/JPEG reader (PER-10427) fix(cli-upload): replace image-size with probe-image-size (PER-10427) Aug 17, 2026
Drops the `image-size.js` adapter, the `fixtures.js` module and the
`image-size` unit specs. Those existed to hold and prove a hand-rolled
PNG/JPEG parser; with `probe-image-size` doing the parsing, they covered
upstream's segment walking rather than anything this package owns.

What remains of the adapter is a bounded header read and a format gate, both
short enough to live beside their only caller. The four image fixtures the
end-to-end specs use move inline, and a spec covers the format gate directly:
a GIF named `.png` clears the extension filter and parses fine, so only the
gate keeps it out.

The change to `percy upload` is now the dependency swap plus the skip path.

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