Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions packages/cli-upload/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
},
"dependencies": {
"@percy/cli-command": "1.32.7-beta.0",
"fast-glob": "^3.2.11",
"image-size": "~1.0.2"
"fast-glob": "^3.2.11"
}
}
105 changes: 105 additions & 0 deletions packages/cli-upload/src/image-size.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import fs from 'fs';

// Minimal PNG/JPEG dimension reader. `percy upload` only ever accepts png, jpg and
// jpeg files (see ALLOWED_FILE_TYPES in upload.js), so a general purpose image
// parser is more dependency — and more attack surface — than this command needs.
// The previous dependency (`image-size`) is archived upstream and carries
// unfixable infinite-loop advisories in parsers we never wanted in the first
// place (CVE-2025-71329, CVE-2025-71330).

const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const IHDR = Buffer.from('IHDR', 'ascii');

// SOFn markers carry frame dimensions. 0xc4 (DHT), 0xc8 (JPG) and 0xcc (DAC)
// sit in the same range but are not frame headers.
const JPEG_SOF_MARKERS = new Set([
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
]);

// Markers that stand alone — no length-prefixed payload follows them.
const JPEG_STANDALONE_MARKERS = new Set([
0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8
]);

// Reads exactly `length` bytes at `position`, or returns null on a short read.
function readAt(fd, length, position) {
let buffer = Buffer.alloc(length);
let bytesRead = fs.readSync(fd, buffer, 0, length, position);
return bytesRead === length ? buffer : null;
}

// signature (8) + chunk length (4) + chunk type (4) + width (4) + height (4)
function pngSize(fd) {
let header = readAt(fd, 24, 0);
if (!header?.subarray(12, 16).equals(IHDR)) return null;

return {
width: header.readUInt32BE(16),
height: header.readUInt32BE(20)
};
}

function jpegSize(fd, fileSize) {
// start just past the SOI marker
let offset = 2;

while (offset + 4 <= fileSize) {
// the loop bound guarantees these four bytes exist, so this cannot short read
let header = readAt(fd, 4, offset);
// every marker begins with 0xff — anything else means we've walked out of
// the segment chain and into entropy-coded data
if (header[0] !== 0xff) return null;

let marker = header[1];
// 0xff may repeat as fill bytes before the marker itself
if (marker === 0xff) { offset += 1; continue; }
if (JPEG_STANDALONE_MARKERS.has(marker)) { offset += 2; continue; }
// SOS begins scan data and EOI ends the image — a frame header should
// already have been seen by now, so there is nothing left to find
if (marker === 0xda || marker === 0xd9) return null;

let length = header.readUInt16BE(2);
// A segment length always counts its own two length bytes. Anything shorter
// is malformed, and advancing by it would not move `offset` forward — the
// exact shape of the infinite loops that made `image-size` unfixable.
if (length < 2) return null;

if (JPEG_SOF_MARKERS.has(marker)) {
// precision (1) + height (2) + width (2)
let frame = readAt(fd, 5, offset + 4);
if (!frame) return null;

return {
width: frame.readUInt16BE(3),
height: frame.readUInt16BE(1)
};
}

offset += 2 + length;
}

return null;
}

// Returns `{ width, height }` for a PNG or JPEG file, or null when the file is
// neither — including when its extension disagrees with its actual contents.
export function imageSize(absolutePath) {
let fd = fs.openSync(absolutePath, 'r');

try {
let signature = readAt(fd, 8, 0);
if (!signature) return null;

if (signature.equals(PNG_SIGNATURE)) return pngSize(fd);
if (signature[0] === 0xff && signature[1] === 0xd8) {
return jpegSize(fd, fs.fstatSync(fd).size);
}

return null;
} finally {
fs.closeSync(fd);
}
}

export default imageSize;
11 changes: 9 additions & 2 deletions packages/cli-upload/src/upload.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const upload = command('upload', {
exit(1, 'Invalid Token Type. Only "web" and "self-managed" token types are allowed.');
}

let { default: imageSize } = await import('image-size');
let { imageSize } = await import('./image-size.js');
let { getImageResources } = await import('./utils.js');

// the internal discovery queue shares a concurrency with the snapshots queue
Expand All @@ -97,7 +97,14 @@ export const upload = command('upload', {
log.info(`Skipping unsupported file type: ${relativePath}`);
} else {
let absolutePath = path.resolve(args.dirname, relativePath);
let img = { relativePath, absolutePath, ...imageSize(absolutePath) };
let size = imageSize(absolutePath);

if (!size) {
log.info(`Skipping file with unreadable image data: ${relativePath}`);
continue;
}

let img = { relativePath, absolutePath, ...size };
let { dir, name, ext } = path.parse(relativePath);
img.type = ext === '.png' ? 'png' : 'jpeg';
img.name = path.join(dir, name);
Expand Down
143 changes: 143 additions & 0 deletions packages/cli-upload/test/fixtures.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Real image bytes, kept as buffers rather than strings — the PNG signature
// starts with 0x89, which does not survive a round trip through UTF-8.

const b64 = str => Buffer.from(str, 'base64');

// 1x1 red PNG
export const PNG_PIXEL = b64(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ' +
'/pLvAAAAAElFTkSuQmCC'
);

// 120x80 red PNG
export const PNG_120X80 = b64(
'iVBORw0KGgoAAAANSUhEUgAAAHgAAABQCAIAAABd+SbeAAAA4klEQVR4nO3OQQ0AIAADsfk3' +
'DS7o40gqoDvb94AfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4' +
'QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E' +
'+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQf' +
'RPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRPhBhB9E+EGEH0T4QYQfRFwNVlys/6lZ' +
'IQAAAABJRU5ErkJggg=='
);

// 1x1 red JPEG
export const JPEG_PIXEL = b64(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9' +
'PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhC' +
'Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAAR' +
'CAABAAEDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA' +
'AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK' +
'FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG' +
'h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl' +
'5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA' +
'AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk' +
'NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE' +
'hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk' +
'5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDFoooryz7w/9k='
);

// 200x150 red JPEG — its frame header sits past several kilobyte-scale
// quantization and Huffman tables, so reading it exercises segment walking
// rather than a fixed header offset.
export const JPEG_200X150 = b64(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0oOjM9' +
'PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8aGi9jQjhC' +
'Y2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2P/wAAR' +
'CACWAMgDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAA' +
'AgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkK' +
'FhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWG' +
'h4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl' +
'5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREA' +
'AgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYk' +
'NOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOE' +
'hYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk' +
'5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDFoooryz7wKKKKACiiigAooooAKKKKACii' +
'igAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA' +
'KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACii' +
'igAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA' +
'KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACii' +
'igAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA' +
'KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACii' +
'igAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA' +
'KKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACii' +
'igAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA/9k='
);

// A GIF — accepted by neither the extension filter nor the size reader.
export const GIF_PIXEL = b64('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');

// An ICNS buffer with valid magic bytes and a zero-valued entry length.
// This is the CVE-2025-71330 proof of concept: the archived `image-size`
// package looped forever on it because a zero-length entry never advanced the
// read offset. `percy upload` filters by extension, not magic bytes, so a file
// named `.png` reached that parser.
export function icnsZeroLengthEntry() {
let buffer = Buffer.alloc(64);
buffer.write('icns', 0, 'ascii');
buffer.writeUInt32BE(64, 4); // file length
buffer.write('ic09', 8, 'ascii'); // first entry type
buffer.writeUInt32BE(0, 12); // first entry length
return buffer;
}

// A JPEG whose first segment declares a length of zero. Advancing by a
// self-inclusive length below 2 would leave the read offset stationary.
export function jpegZeroLengthSegment() {
let buffer = Buffer.alloc(32);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffe0, 2); // APP0
buffer.writeUInt16BE(0, 4); // segment length
return buffer;
}

// A JPEG whose first segment length lands the walk on bytes that do not begin a
// marker — i.e. off the segment chain and into data.
export function jpegWalksIntoData() {
let buffer = Buffer.alloc(32);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffe0, 2); // APP0
buffer.writeUInt16BE(4, 4); // length 4 ⇒ next marker expected at offset 8
// offset 8 onward is left zeroed, so no 0xff marker prefix is found
return buffer;
}

// A JPEG carrying a standalone marker (RST0, no length payload) ahead of the
// frame header. Mis-skipping it would desynchronise the walk.
export function jpegStandaloneMarkerBeforeFrame(width, height) {
let buffer = Buffer.alloc(16);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffd0, 2); // RST0 — standalone
buffer.writeUInt16BE(0xffc0, 4); // SOF0
buffer.writeUInt16BE(11, 6); // segment length
buffer.writeUInt8(8, 8); // sample precision
buffer.writeUInt16BE(height, 9);
buffer.writeUInt16BE(width, 11);
return buffer;
}

// A JPEG that reaches the start of scan without ever declaring a frame.
export function jpegScanWithoutFrame() {
let buffer = Buffer.alloc(32);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffda, 2); // SOS
buffer.writeUInt16BE(12, 4); // segment length
return buffer;
}

// A JPEG that reaches end of image without ever declaring a frame.
export function jpegEndsWithoutFrame() {
let buffer = Buffer.alloc(32);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffd9, 2); // EOI
return buffer;
}

// A JPEG that announces a frame header and then ends before it. Must stay at
// least 8 bytes long, or it is rejected at the signature read and never reaches
// the segment walk this is meant to exercise.
export function jpegTruncatedFrameHeader() {
let buffer = Buffer.alloc(8);
buffer.writeUInt16BE(0xffd8, 0); // SOI
buffer.writeUInt16BE(0xffc0, 2); // SOF0
buffer.writeUInt16BE(11, 4); // length claims a payload the file does not have
return buffer;
}
115 changes: 115 additions & 0 deletions packages/cli-upload/test/unit/image-size.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { imageSize } from '../../src/image-size.js';
import {
PNG_PIXEL,
PNG_120X80,
JPEG_PIXEL,
JPEG_200X150,
GIF_PIXEL,
icnsZeroLengthEntry,
jpegZeroLengthSegment,
jpegWalksIntoData,
jpegStandaloneMarkerBeforeFrame,
jpegScanWithoutFrame,
jpegEndsWithoutFrame,
jpegTruncatedFrameHeader
} from '../fixtures.js';

describe('unit / image-size', () => {
let dirname, index = 0;

// these tests read real files — `imageSize` opens a descriptor and reads at
// offsets, which is the behaviour worth exercising against a real filesystem
beforeAll(() => {
dirname = fs.mkdtempSync(path.join(os.tmpdir(), 'percy-image-size-'));
});

afterAll(() => {
fs.rmSync(dirname, { recursive: true, force: true });
});

let write = contents => {
let filename = path.join(dirname, `fixture-${index++}`);
fs.writeFileSync(filename, contents);
return filename;
};

it('reads PNG dimensions', () => {
expect(imageSize(write(PNG_PIXEL))).toEqual({ width: 1, height: 1 });
expect(imageSize(write(PNG_120X80))).toEqual({ width: 120, height: 80 });
});

it('reads JPEG dimensions', () => {
expect(imageSize(write(JPEG_PIXEL))).toEqual({ width: 1, height: 1 });
expect(imageSize(write(JPEG_200X150))).toEqual({ width: 200, height: 150 });
});

it('returns null for other image formats', () => {
expect(imageSize(write(GIF_PIXEL))).toBeNull();
});

it('returns null for files that are not images', () => {
expect(imageSize(write('not an image'))).toBeNull();
expect(imageSize(write(Buffer.alloc(0)))).toBeNull();
});

it('returns null for a truncated PNG', () => {
expect(imageSize(write(PNG_PIXEL.subarray(0, 16)))).toBeNull();
});

it('returns null for a PNG whose first chunk is not IHDR', () => {
let png = Buffer.from(PNG_PIXEL);
png.write('IDAT', 12, 'ascii');
expect(imageSize(write(png))).toBeNull();
});

it('returns null for a JPEG too short to hold a signature', () => {
expect(imageSize(write(JPEG_PIXEL.subarray(0, 4)))).toBeNull();
});

it('returns null when the segment chain leads into non-marker data', () => {
expect(imageSize(write(jpegWalksIntoData()))).toBeNull();
});

it('returns null for a JPEG that starts its scan without a frame', () => {
expect(imageSize(write(jpegScanWithoutFrame()))).toBeNull();
});

it('returns null for a JPEG that ends without a frame', () => {
expect(imageSize(write(jpegEndsWithoutFrame()))).toBeNull();
});

it('returns null for a JPEG that ends before its frame header', () => {
expect(imageSize(write(jpegTruncatedFrameHeader()))).toBeNull();
});

it('skips standalone markers to reach the frame header', () => {
expect(imageSize(write(jpegStandaloneMarkerBeforeFrame(320, 240))))
.toEqual({ width: 320, height: 240 });
});

// CVE-2025-71330 / CVE-2025-71329 — the advisories that made `image-size`
// unusable were all the same shape: a zero-valued length field left the read
// offset unchanged, so the parser looped forever and wedged the event loop.
it('terminates on an ICNS buffer with a zero-length entry', () => {
// named `.png` because `percy upload` filters on extension, which is how
// this buffer reached the ICNS parser in the first place
let filename = path.join(dirname, 'crafted.png');
fs.writeFileSync(filename, icnsZeroLengthEntry());

expect(imageSize(filename)).toBeNull();
});

it('terminates on a JPEG segment with a zero length', () => {
expect(imageSize(write(jpegZeroLengthSegment()))).toBeNull();
});

it('terminates on a JPEG of nothing but 0xff fill bytes', () => {
let buffer = Buffer.alloc(4096, 0xff);
buffer.writeUInt16BE(0xffd8, 0);

expect(imageSize(write(buffer))).toBeNull();
});
});
Loading
Loading