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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

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

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-devaryanku-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:

CVEParserPatched
CVE-2025-71330ICNSnone
CVE-2025-71329JXL, HEIFnone

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 MaxBufferSizeimage-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 ownerAugust 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-devaryanku-dev left a comment

Copy link
Copy Markdown
ContributorAuthor

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.

// 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
ContributorAuthor

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.

constCGBI=Buffer.from('CgBI','ascii');functionpngSize(fd){letheader=readAt(fd,40,0)??readAt(fd,24,0);if(!header)returnnull;if(header.length>=40&&header.subarray(12,16).equals(CGBI)){returnheader.subarray(28,32).equals(IHDR)
? {width: header.readUInt32BE(32),height: header.readUInt32BE(36)}
: null;}returnheader.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 threadpackages/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
ContributorAuthor

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
ContributorAuthor

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
ContributorAuthor

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

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials introduced.
HighSecurityAuthentication/authorization checks presentN/ANo auth surface touched.
HighSecurityInput validation and sanitizationPassEvery read is bounded; readAt returns null on short reads and all callers null-check before deriving offsets.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassPNG IHDR offsets (16/20) correct; JPEG SOF vs DHT/JPG/DAC vs standalone-marker classification correct.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassUnreadable images now skip with a log line instead of aborting the run. See finding 2 for an unhandled sub-case.
HighCorrectnessNo race conditions or concurrency issuesPassSynchronous, single-fd reads.
MediumTestingNew code has corresponding testsPass15 new image-size specs + 2 upload.test.js regressions; reviewer ran the suite and all pass.
MediumTestingError paths and edge cases testedPassTruncated PNG, non-IHDR first chunk, walk-into-data, SOS/EOI-without-frame, zero-length segment, truncated frame header, all-0xff fill all covered.
MediumTestingExisting tests still pass (no regressions)Passcli-upload suite green (one unrelated pre-existing @percy/dom dist-resolution failure in the reviewer's environment, not caused by this diff).
MediumPerformanceNo N+1 queries or unbounded data fetchingPassPositioned reads only; no longer loads the whole file into a 512 KB buffer as image-size did.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable.
MediumQualityFollows existing codebase patternsPassMirrors the existing Skipping unsupported file type log path.
MediumQualityChanges are focused (single concern)PassScoped to cli-upload.
LowQualityMeaningful names, no dead codePass
LowQualityComments explain why, not whatPassOne misleading test comment — finding 3.
LowQualityNo unnecessary dependencies addedPassNet 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-devaryanku-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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Closing as superseded by #2390, which merged on 19 Aug.

Both PRs replace image-size to clear the unpatched CWE-835 advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq). #2390 took the same probe-image-size dependency but let the package do the bounded read, rather than keeping the hand-written one here.

image-size is now gone from yarn.lock entirely, so nothing from this branch is still needed. Also worth noting for anyone tracing the history: the Node 20 upgrade (#2386) originally listed this PR as a merge-order blocker on packages/cli-upload/package.json. That constraint is resolved — #2386's only change to that file is the engines line, and it now carries both engines: >=20 and probe-image-size: ^7.3.0 cleanly.

Sign up for freeto 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

@aryanku-dev