fix(cli-upload): replace image-size with probe-image-size (PER-10489) - #2390
Conversation
image-size has two unpatched high-severity advisories (CVE-2025-71330, CVE-2025-71329) and the upstream project is archived, so there is no version to upgrade to. Both are CWE-835 infinite loops reached from a zero-valued length field. They were reachable here, not theoretical: image-size picks its parser from magic bytes while upload.js filters candidates by extension, so a crafted ICNS buffer named .png reached the ICNS parser and hung the run. probe-image-size has no ICNS/JXL/HEIF parser at all and its whole tree is advisory-free. Only the stream entrypoint is imported, which pulls in the parsers and nothing else — none of the http/needle machinery — and it stops reading each file once it has the dimensions, so there is no hand-rolled bounded read. A file with an accepted extension but unreadable contents is now skipped rather than throwing and failing the whole upload. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aryanku-dev
left a comment
There was a problem hiding this comment.
Claude Code Review (automated) — 2 inline finding(s). Full report in the PR comment below. Verdict: Passed.
| let img = { relativePath, absolutePath, ...imageSize(absolutePath) }; | ||
| // rejects when the contents are not an image the parsers recognise, | ||
| // whatever the extension claims — skip that file rather than fail the run | ||
| let size = await probeImageSize(fs.createReadStream(absolutePath)).catch(() => null); |
There was a problem hiding this comment.
[Low] .catch(() => null) collapses every failure mode into one message
probe-image-size's stream.js wires src.on('error', reject), so errors from fs.createReadStream itself — ENOENT if a file is deleted mid-run, EACCES on a permissions problem, EMFILE under fd pressure — reach the same rejection path as "unrecognized image format". All of them now log the identical Skipping file with unreadable image data: … line, so a permissions or fd-exhaustion problem in the field is indistinguishable from a corrupt image in a support ticket.
Suggestion: keep the skip, preserve the cause at debug level:
let size = await probeImageSize(fs.createReadStream(absolutePath)).catch(err => {
log.debug(`Probe failed for ${relativePath}: ${err.message}`);
return null;
});Reviewer: stack-code-reviewer
| let img = { relativePath, absolutePath, ...imageSize(absolutePath) }; | ||
| // rejects when the contents are not an image the parsers recognise, | ||
| // whatever the extension claims — skip that file rather than fail the run | ||
| let size = await probeImageSize(fs.createReadStream(absolutePath)).catch(() => null); |
There was a problem hiding this comment.
[Low] Probe runs unconditionally, including on the BYOS path that discards the result
When tokenType === 'generic' (line 117) the probed dimensions are thrown away — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. This was already true of the old synchronous imageSize() call, but the per-file cost is now a stream open plus a pipe-to-N-parsers setup rather than one buffered sync read, so the wasted work is more expensive. Relatedly, the loop awaits this per file where it previously did a sync read, so a directory with thousands of images pays that setup cost sequentially.
Suggestion: move the probe inside the non-generic branch (or short-circuit before it) so BYOS uploads skip it. If large directories become a real pain point, bound the probes with config.concurrency, already plumbed through for the discovery queue.
Reviewer: stack-code-reviewer
Claude Code PR ReviewPR: #2390 • Head: 9dcbe39 • Reviewers: stack-code-reviewer SummaryReplaces the archived, CVE-affected Review Table
Findings
Non-blocking nit (test intent): the new spec relies on Jasmine's default timeout to catch a reintroduced hang rather than asserting a bound explicitly. That works — a real hang would fail the suite — but an explicit timeout guard would make the regression's intent self-documenting. Verified, no action needed: no file-descriptor leak ( Verdict: PASS — the dependency swap is correct, the CVE fix was verified empirically against the real package rather than taken on trust, and the three open findings are all Low-severity follow-ups. |
The closed-shadow "dynamic content" card mutated its counter on a 1s setInterval, so capture landed on a different digit depending on how long the page took to reach network idle: Count: 0 normally, 1+ whenever CI was slow. That produced an intermittent visual diff against master on PRs that changed nothing visual. It is what turned this PR's build red (#901, 1 snapshot changed, 0.80% diff, isolated to the single digit after "Count:"). Mutate once to a fixed value instead of on a timer. The case still covers what it was there for: a closed shadow root whose content changes after the constructor's template is assigned, so capture has to serialize the live DOM rather than the initial innerHTML. Only the run-to-run variance is gone. The deliberate async cases are left alone: the lazy-defined widget and async data card on this page, and the delayed custom element in dom-structures.html, exist to check that capture handles deferred rendering, and they land on a consistent pre-timeout state at the configured 150ms networkIdleTimeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude Code PR ReviewPR: #2390 • Head: 5c8dce0 • Reviewers: stack-code-reviewer Continues the previous review — changes since
SummaryTwo commits: Review Table
FindingsNew this run
Carried forward from
|
Fixes #2380 / PER-10489 (customer escalation — Foresters Financial). Supersedes #2382, which took the same dependency but kept a hand-written bounded read; this one lets the package do that work.
Root cause
npm auditfails for anyone installing@percy/clibecausepackages/cli-uploaddepends onimage-size, which has unpatched high-severity advisories: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 upstream is archived, so there is nothing to upgrade to. Bumping was doubly blocked — #2301 pinned
~1.0.2to keep Node 14 support, whichimage-size2.x drops (it requires>=16.x).This was reachable, not theoretical
image-sizeselects its parser from magic bytes, whileupload.jsfilters candidates by extension (ALLOWED_FILE_TYPES). A file namedscreenshot.pngwhose contents begin with the ICNS magic bytes therefore reached the ICNS parser.Verified against the exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:
Because the loop blocks the event loop the process cannot handle a 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 for customers running GitHub Advanced Security gates.Why
probe-image-sizeI surveyed the alternatives rather than assuming:
image-meta(unjs)image-dimensions(sindresorhus)image-size-safe,image-size-next,@localnerve/image-sizeprobe-image-size(nodeca)image-dimensionsis the cleanest library, but it declaresengines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:Every modern zero-dependency option requires Node >=16.
probe-image-sizeis the only maintained one that still installs on Node 14, so it is the only choice that fixes the advisory without a second breaking change on top.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 rather than advancing by it.
npm auditon its tree reports 0 vulnerabilities.Pinned to
^7.3.0rather than^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, soyarn installfails for anyone inside the corp network. The caret still resolves forward to 7.4.0 for end users once it ages out.The change
13 lines. The package does the work:
Two things this buys over calling the package's index:
stream.js, not the package index. Verified by inspectingrequire.cacheafter import:needlemachinery is installed but never loaded.image-sizedid this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.Lockfile:
image-sizeandqueueare removed;probe-image-size,needle,saxandstream-parserare added.debug,iconv-lite,ms,safer-bufferandlodash.mergewere already in the tree, so the net change is +2 installed packages.Behaviour change
A file with an accepted extension but unreadable contents previously threw and failed the entire
uploadrun. It is now skipped, mirroring the existingSkipping unsupported file typepath:Valid PNG/JPEG uploads are unaffected —
img.typeis still derived from the extension exactly as before, so no file that used to upload stops uploading.Testing
All 13 existing specs pass unchanged, on Node 14 to match CI. One spec is added for the new skip branch, using the CVE-2025-71330 proof of concept as its fixture:
End-to-end against a directory holding a real 1280x720 PNG, a real 640x480 JPEG and the crafted ICNS payload named
.png:Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.
🤖 Generated with Claude Code