fix(cli-upload): replace image-size with probe-image-size (PER-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28
, '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-10489) - #2390

Merged
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size
Aug 19, 2026
Merged

fix(cli-upload): replace image-size with probe-image-size (PER-10489)#2390
aryanku-dev merged 2 commits into
masterfrom
fix/PER-10489-replace-image-size

Conversation

@aryanku-dev

Copy link
Copy Markdown
Contributor

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 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 upstream is archived, 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 (it requires >=16.x).

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 exact pinned version, with a 64-byte ICNS buffer whose first entry declares a length of zero:

$ node poc.cjs
image-size 1.0.2 - parsing crafted ICNS...
...no further output; killed by SIGKILL after 10s (exit 137)

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

I surveyed the alternatives rather than assuming:

PackageDepsNode floorImmune to this bug class?
image-meta (unjs)0noneNo — I read the source; its ICNS loop has the identical zero-length defect, merely unreported
image-dimensions (sindresorhus)0>=18Yes
image-size-safe, image-size-next, @localnerve/image-size0>=16Yes, but all published within the last 6 weeks by single maintainers with <2k weekly downloads
probe-image-size (nodeca)3 directnoneYes

image-dimensions is the cleanest library, but it declares engines: node >=18. Yarn 1 treats that as fatal, so it would break both this repo's CI and any customer on Node 14:

error image-dimensions@2.5.1: The engine "node" is incompatible with this module. Expected version ">=18". Got "14.18.3"
error Found incompatible module.

Every modern zero-dependency option requires Node >=16. probe-image-size is 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 audit on its tree reports 0 vulnerabilities.

Pinned to ^7.3.0 rather than ^7.4.0: 7.4.0 is four days old and is currently quarantined by BrowserStack's package-manager guard, so yarn install fails 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:

let{default: probeImageSize}=awaitimport('probe-image-size/stream.js');letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(()=>null);if(!size){log.info(`Skipping file with unreadable image data: ${relativePath}`);continue;}

Two things this buys over calling the package's index:

  • stream.js, not the package index. Verified by inspecting require.cache after import:
    stream.js loads: probe-image-size, stream-parser
    index.js loads: has-flag, lodash.merge, ms, needle, probe-image-size, sax, stream-parser, supports-color
    
    The http/needle machinery is installed but never loaded.
  • No bounded-read code of our own. The stream prober stops reading and tears the stream down as soon as it has the dimensions, so a multi-megabyte PNG is not pulled into memory just to read its header. image-size did this internally with a 512 KiB cap; that behaviour is preserved by the library rather than reimplemented here.

Lockfile: image-size and queue are removed; probe-image-size, needle, sax and stream-parser are added. debug, iconv-lite, ms, safer-buffer and lodash.merge were 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 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 — img.type is 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:

Executed 14 of 14 specs SUCCESS
-----------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------|---------|----------|---------|---------|
upload.js | 100 | 100 | 100 | 100 |

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

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

Reads both real images at the correct dimensions, skips the crafted one, no hang, exit 0.

🤖 Generated with Claude Code

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
aryanku-dev requested a review from a team as a code ownerAugust 19, 2026 03:42

@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) — 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);

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] .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:

letsize=awaitprobeImageSize(fs.createReadStream(absolutePath)).catch(err=>{log.debug(`Probe failed for ${relativePath}: ${err.message}`);returnnull;});

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);

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

@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:9dcbe39Reviewers: stack-code-reviewer

Summary

Replaces the archived, CVE-affected image-size dependency in @percy/cli-upload with probe-image-size's stream.js entrypoint, closing CVE-2025-71330 / CVE-2025-71329 (CWE-835 infinite loop reachable because image-size picked its parser from magic bytes while upload.js filtered by extension). A file with an accepted extension but unreadable contents is now skipped with a log line instead of failing the whole run.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNo credentials in the diff.
HighSecurityAuthentication/authorization checks presentPassExisting ALLOWED_TOKEN_TYPES gate at upload.js:85 is untouched.
HighSecurityInput validation and sanitizationPassThis is the point of the PR — the crafted-magic-bytes DoS path is removed. probe-image-size ships no ICNS/HEIF/JXL parser at all, and its ISOBMFF reader rejects a box smaller than its own header rather than advancing by it. Verified by the reviewer against the extracted 7.3.0 tarball: the exact craftedIcns fixture rejects in ~5 ms instead of hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access in this diff.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassIndependently verified at head: fs is imported (upload.js:1), so fs.createReadStream is in scope; continue sits directly inside for (let relativePath of pathnames) (upload.js:97) and correctly advances to the next file; img.type is reassigned from the extension at upload.js:112, so the extra fields probe-image-size returns (mime, wUnits, hUnits) cannot change upload behaviour. getImageResources destructures only the six fields it needs, so nothing leaks into the payload.
HighCorrectnessError handling is explicit, no swallowed exceptionsPassThe .catch(() => null) is deliberate and surfaces a user-visible skip line, mirroring the existing Skipping unsupported file type branch. It does collapse the underlying error detail — tracked below as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe probe is awaited inside a sequential for…of; no shared mutable state introduced. No FD leak: stream.js drives the read through stream.pipeline, which destroys the fs.createReadStream on both the resolve and reject paths.
MediumTestingNew code has corresponding testsPassOne spec added for the new skip branch, using the CVE-2025-71330 PoC as its fixture; author reports 14/14 specs passing with 100% statement/branch/function/line coverage on upload.js.
MediumTestingError paths and edge cases testedPassThe unreadable-image path is covered and asserts both the skip line and that the run still completes (Uploading 3 snapshots…, Finalized build #1…). The reviewer confirmed the crafted bytes genuinely trigger the CVE against the real dependency rather than being a synthetic no-op.
MediumTestingExisting tests still pass (no regressions)PassAll 13 pre-existing specs unchanged and passing per the PR description.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassThe stream prober tears the stream down as soon as it has the dimensions, preserving the bounded-read behaviour image-size did internally with a 512 KiB cap — without reimplementing it locally.
MediumPerformanceLong-running tasks use background jobsN/ANot applicable to a local CLI directory scan.
MediumQualityFollows existing codebase patternsPassThe skip branch mirrors the adjacent Skipping unsupported file type handling; the dynamic await import matches the existing lazy-import style.
MediumQualityChanges are focused (single concern)PassThree source files plus the lockfile, all serving the one dependency swap.
LowQualityMeaningful names, no dead codePassimage-size and its only-consumer transitive dep queue@6.0.2 are fully removed from yarn.lock; no other package in the monorepo referenced either.
LowQualityComments explain why, not whatPassBoth added comments explain rationale (why stream.js over the package index; why skip rather than fail).
LowQualityNo unnecessary dependencies addedPassneedle, sax, stream-parser come in as probe-image-size deps; debug, iconv-lite, ms, safer-buffer and lodash.merge were already in the tree, so the net change is +2 installed packages. stream.js never requires needle, so the HTTP machinery is installed but never loaded. Justified: every zero-dependency alternative requires Node ≥16, which would break the repo's Node 14 support.

Findings

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: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 file-descriptor pressure — reach the same rejection path as "unrecognized image format". .catch(() => null) discards all of them and every case logs 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 behaviour but preserve the cause at debug level: .catch(err => { log.debug(\Probe failed for ${relativePath}: ${err.message}`); return null; })`.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: On the BYOS path (tokenType === 'generic', upload.js:117) the probed dimensions are discarded entirely — BYOS_TAG is a fixed {width: 1, height: 1} and only img.absolutePath is used. The probe still runs for every file. 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.
  • Suggestion: Move the probe inside the non-generic branch so BYOS uploads skip it, or short-circuit with if (tokenType === 'generic') before probing.

  • File:packages/cli-upload/src/upload.js:104
  • Severity: Low
  • Reviewer: stack-code-reviewer
  • Issue: The loop now awaits a stream-based probe per file where it previously did a synchronous buffered read, so a directory with hundreds or thousands of images pays the per-file stream-setup and event-loop overhead sequentially.
  • Suggestion: Not blocking for a security fix. If large upload directories become a reported pain point, bound this with config.concurrency (already plumbed through for the discovery/snapshot queue) via a limited Promise.all.

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 (stream.pipeline destroys the read stream on both paths); no property collision or field leak into the uploaded payload; continue is valid in the enclosing for…of; probe-image-size declares no exports map so the probe-image-size/stream.js subpath resolves on Node 14, and its CJS module.exports = function interops correctly with { default: probeImageSize }; ico.js — the closest analog to the vulnerable ICNS loop — bounds its per-entry loop by a uint16 count rather than attacker-controlled lengths, so the bug class is not reintroduced.


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>
@aryanku-dev

Copy link
Copy Markdown
ContributorAuthor

Claude Code PR Review

PR:#2390Head:5c8dce0Reviewers: stack-code-reviewer

Continues the previous review — changes since 9dcbe39 (delta).

Disclosure: the delta commit reviewed here (5c8dce0) was authored by Claude earlier in the same session that is running this review. It was reviewed by a subagent given an explicit instruction to treat it with added skepticism rather than deference, and its central claims were re-verified against packages/core/src/page.js by the orchestrator. It has still had no independent human review — weigh this section accordingly.

Summary

Two commits: 9dcbe39 swaps the CVE-affected image-size dependency in @percy/cli-upload for probe-image-size's stream.js entrypoint; 5c8dce0 replaces a setInterval-driven counter in the visual-regression fixture with a single deterministic mutation, removing an intermittent snapshot diff.

Review Table

PriorityCategoryCheckStatusNotes
HighSecurityNo hardcoded secrets or credentialsPassNone in either commit.
HighSecurityAuthentication/authorization checks presentPassALLOWED_TOKEN_TYPES gate untouched.
HighSecurityInput validation and sanitizationPassThe point of 9dcbe39: CVE-2025-71330/71329 (CWE-835) closed. probe-image-size ships no ICNS/HEIF/JXL parser; verified empirically last run — the crafted fixture rejects in ~5 ms rather than hanging.
HighSecurityNo IDOR — resource ownership validatedN/ANo multi-tenant resource access.
HighSecurityNo SQL injection (parameterized queries)N/ANo SQL.
HighCorrectnessLogic is correct, handles edge casesPassDelta verified against the real capture pipeline: <closed-dynamic-card> is in static markup, so the constructor runs at upgrade and connectedCallback fires synchronously after it — this._shadow is always assigned before it is read. ShadowRoot.getElementById is valid (DocumentOrShadowRoot mixin) and already used by AsyncDataCard in the same file. Re-entry is safe: textContent = '42' is idempotent, where the old count++ would have been actively wrong on reconnection.
HighCorrectnessError handling is explicit, no swallowed exceptionsPass.catch(() => null) is deliberate and logs a visible skip line; it does discard the cause — carried forward as a Low finding, not a gate failure.
HighCorrectnessNo race conditions or concurrency issuesPassThe delta removes a race rather than adding one. No FD leak in 9dcbe39 (stream.pipeline destroys the read stream on both paths). See the Medium finding below for a pre-existing latent race on the same page.
MediumTestingNew code has corresponding testsPass9dcbe39 adds a spec for the skip branch using the CVE PoC as its fixture (14/14, 100% coverage on upload.js). 5c8dce0 is itself test-fixture code.
MediumTestingError paths and edge cases testedPassUnreadable-image path covered, asserting both the skip line and that the run completes.
MediumTestingExisting tests still pass (no regressions)Pass13 pre-existing specs unchanged. No other fixture or spec asserts on the Count: N digit, so the delta is correctly scoped to this one page.
MediumPerformanceNo N+1 queries or unbounded data fetchingPassStream prober tears down as soon as dimensions are known.
MediumPerformanceLong-running tasks use background jobsN/ALocal CLI.
MediumQualityFollows existing codebase patternsPassSkip branch mirrors the adjacent Skipping unsupported file type; the fixture's connectedCallback mutation matches AsyncDataCard's existing shape.
MediumQualityChanges are focused (single concern)PassTwo concerns now — the dependency swap and an unrelated regression-fixture fix. Landed together deliberately: the flake was turning this PR's own Percy build red, so it is the PR's blocker rather than unrelated drive-by work.
LowQualityMeaningful names, no dead codePassimage-size and its only consumer queue@6.0.2 fully removed from yarn.lock.
LowQualityComments explain why, not whatPassThe delta's comment accurately describes the mechanism — confirmed against closed-shadow.js, which resolves a live CDP object reference rather than a snapshot taken at attachShadow time, so the constructor-vs-connectedCallback distinction is real and not decorative.
LowQualityNo unnecessary dependencies addedPassNet +2 installed packages; every zero-dependency alternative requires Node ≥16, which would break Node 14 support.

Findings

New this run

  • File:test/regression/pages/interactive-states.html:582
  • Severity: Medium
  • Reviewer: stack-code-reviewer
  • Issue:AsyncDataCard.connectedCallback schedules a setTimeout(..., 1000) that swaps #content from "Loading data…" to the loaded list — the same flake shape the delta just fixed, on the same page. It is not covered by Percy's custom-elements wait: WAIT_FOR_CUSTOM_ELEMENTS_BODY only polls :not(:defined), and async-data-card is defined synchronously at line 596, so the wait never applies to it. Tracing the real capture budget in packages/core/src/page.js:273-297: network.idle() (~150 ms at the configured networkIdleTimeout) then the custom-elements wait, which is capped at DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500 (page.js:19) — and on this page lazy-defined-widget stays undefined for its full 2000 ms, so that wait will burn its entire 500 ms ceiling every run rather than resolving early. That is ~650 ms before any CDP overhead for exposeClosedShadowRoots, insertPercyDom and serialization, against a 1000 ms window: roughly 350 ms of margin. This is pre-existing and not introduced by this delta, but it is structurally the same race, and the root cause of the bug just fixed was exactly CDP/CI overhead pushing past a nominal 1 s boundary.
  • Suggestion: either push the delay well clear of the ~650 ms+ budget (mirroring the 2000 ms margin lazy-defined-widget already uses), or convert it to a deterministic single mutation the way ClosedDynamicCard just was — set the loaded state directly in connectedCallback instead of racing a timer against capture. Worth a follow-up ticket; not a reason to hold this PR.

Carried forward from 9dcbe39 (unresolved — this delta does not touch upload.js)

  • packages/cli-upload/src/upload.js:104Low.catch(() => null) collapses ENOENT/EACCES/EMFILE into the same "unreadable image data" line, losing the cause for support triage. Suggestion: log the error at debug level before returning null.
  • packages/cli-upload/src/upload.js:104Low — the probe runs even on the BYOS (generic token) path, where dimensions are discarded for a fixed {1,1} tag. Suggestion: move the probe inside the non-generic branch.
  • packages/cli-upload/src/upload.js:104Low — the per-file probe is awaited sequentially where it was previously a synchronous read. Suggestion: bound with config.concurrency if large directories become a pain point.

Verified, no action needed

percy-delayed-card (500 ms, dom-structures.html) looked like the closest call but does not actually race: the custom-elements wait's 500 ms deadline is set afternetwork.idle() has already elapsed, while the widget's 500 ms counts from navigation — so the deadline is always strictly later and the wait observes the definition and resolves early. lazy-defined-widget (2000 ms) is comfortably outside the budget and deterministically captures its undefined state. Both are fine as-is.


Verdict: PASS — the delta removes a real flake without hollowing out the case it covers, and the one new finding is a pre-existing Medium worth a follow-up rather than a blocker.

@aryanku-dev
aryanku-dev merged commit 6a3f872 into masterAug 19, 2026
61 of 68 checks passed
@aryanku-dev
aryanku-dev deleted the fix/PER-10489-replace-image-size branch August 19, 2026 13:39
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

2 participants

@aryanku-dev@pranavz28