Skip to content

Commit 9b0eb20

Browse files
committed
fix: bound png inflate output
1 parent da27026 commit 9b0eb20

2 files changed

Lines changed: 110 additions & 11 deletions

File tree

src/utils/__tests__/png.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,56 @@ test('PNG sync reader decodes indexed color and transparency', () => {
5656
assert.deepEqual(readPngPixel(png, 3, 0), [20, 30, 40, 255]);
5757
});
5858

59+
test('PNG sync reader decodes RGBA alpha', () => {
60+
const png = PNG.sync.read(
61+
encodeTestPng({
62+
width: 1,
63+
height: 1,
64+
bitDepth: 8,
65+
colorType: 6,
66+
rawScanlines: Buffer.from([0, 10, 20, 30, 40]),
67+
}),
68+
);
69+
70+
assert.deepEqual(readPngPixel(png, 0, 0), [10, 20, 30, 40]);
71+
});
72+
73+
test('PNG sync reader scales 16-bit samples to 8-bit output', () => {
74+
const rawScanlines = Buffer.alloc(7);
75+
rawScanlines[0] = 0;
76+
rawScanlines.writeUInt16BE(0x00ff, 1);
77+
rawScanlines.writeUInt16BE(0x0100, 3);
78+
rawScanlines.writeUInt16BE(0xffff, 5);
79+
80+
const png = PNG.sync.read(
81+
encodeTestPng({
82+
width: 1,
83+
height: 1,
84+
bitDepth: 16,
85+
colorType: 2,
86+
rawScanlines,
87+
}),
88+
);
89+
90+
assert.deepEqual(readPngPixel(png, 0, 0), [1, 1, 255, 255]);
91+
});
92+
93+
test('PNG sync reader applies packed grayscale transparency', () => {
94+
const png = PNG.sync.read(
95+
encodeTestPng({
96+
width: 2,
97+
height: 1,
98+
bitDepth: 4,
99+
colorType: 0,
100+
transparency: Buffer.from([0, 2]),
101+
rawScanlines: Buffer.from([0, 0x25]),
102+
}),
103+
);
104+
105+
assert.deepEqual(readPngPixel(png, 0, 0), [34, 34, 34, 0]);
106+
assert.deepEqual(readPngPixel(png, 1, 0), [85, 85, 85, 255]);
107+
});
108+
59109
test('PNG sync reader decodes Adam7 interlaced RGB image data', () => {
60110
const png = PNG.sync.read(
61111
encodeTestPng({
@@ -105,6 +155,18 @@ test('PNG sync reader rejects invalid chunk CRCs', () => {
105155
assert.throws(() => PNG.sync.read(bytes), /Invalid PNG .* chunk CRC/);
106156
});
107157

158+
test('PNG sync reader rejects inflated data larger than IHDR scanlines', () => {
159+
const bytes = encodeTestPng({
160+
width: 1,
161+
height: 1,
162+
bitDepth: 8,
163+
colorType: 6,
164+
rawScanlines: Buffer.from([0, 1, 2, 3, 4, 5]),
165+
});
166+
167+
assert.throws(() => PNG.sync.read(bytes), /PNG pixel data exceeds expected length 5/);
168+
});
169+
108170
function tmpPngPath(prefix: string): string {
109171
return path.join(
110172
fs.mkdtempSync(path.join(os.tmpdir(), `agent-device-png-${prefix}-`)),

src/utils/png-codec.ts

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function readPng(buffer: Buffer): PNG {
6868
const { metadata, idatChunks } = collectPngChunks(buffer);
6969
if (!metadata) throw new Error('PNG is missing IHDR');
7070
if (idatChunks.length === 0) throw new Error('PNG is missing IDAT');
71-
const inflated = inflateSync(Buffer.concat(idatChunks));
71+
const inflated = inflatePngData(idatChunks, metadata);
7272
return new PNG({
7373
width: metadata.width,
7474
height: metadata.height,
@@ -129,6 +129,20 @@ function* iteratePngChunks(buffer: Buffer): Generator<PngChunk> {
129129
}
130130
}
131131

132+
function inflatePngData(idatChunks: Buffer[], metadata: PngMetadata): Buffer {
133+
const expectedLength = inflatedByteLength(metadata);
134+
try {
135+
const inflated = inflateSync(Buffer.concat(idatChunks), { maxOutputLength: expectedLength });
136+
if (inflated.length !== expectedLength) throw new Error('PNG pixel data is truncated');
137+
return inflated;
138+
} catch (error) {
139+
if (isZlibOutputLimitError(error)) {
140+
throw new Error(`PNG pixel data exceeds expected length ${expectedLength}`);
141+
}
142+
throw error;
143+
}
144+
}
145+
132146
function writePng(png: PNG): Buffer {
133147
const scanlineLength = png.width * 4;
134148
const raw = Buffer.alloc((scanlineLength + 1) * png.height);
@@ -297,6 +311,23 @@ function decodeInterlacedPixels(inflated: Buffer, metadata: PngMetadata): Buffer
297311
return output;
298312
}
299313

314+
function inflatedByteLength(metadata: PngMetadata): number {
315+
if (metadata.interlace === 0) return filteredScanlineByteLength(metadata, metadata.height);
316+
317+
let byteLength = 0;
318+
for (const pass of ADAM7_PASSES) {
319+
const width = interlacePassSize(metadata.width, pass.x, pass.dx);
320+
const height = interlacePassSize(metadata.height, pass.y, pass.dy);
321+
if (width === 0 || height === 0) continue;
322+
byteLength += filteredScanlineByteLength({ ...metadata, width, height }, height);
323+
}
324+
return byteLength;
325+
}
326+
327+
function filteredScanlineByteLength(metadata: PngMetadata, height: number): number {
328+
return (scanlineByteLength(metadata) + 1) * height;
329+
}
330+
300331
function interlacePassSize(size: number, start: number, step: number): number {
301332
if (size <= start) return 0;
302333
return Math.floor((size - start + step - 1) / step);
@@ -313,25 +344,22 @@ function readPixel(
313344
const bytesPerSample = metadata.bitDepth === 16 ? 2 : 1;
314345
const channels = COLOR_CHANNELS.get(metadata.colorType)!;
315346
const offset = x * channels * bytesPerSample;
316-
const sample = (channel: number): number => line[offset + channel * bytesPerSample]!;
317-
const fullSample = (channel: number): number =>
318-
metadata.bitDepth === 16 ? line.readUInt16BE(offset + channel * 2) : sample(channel);
347+
const rawSample = (channel: number): number =>
348+
metadata.bitDepth === 16
349+
? line.readUInt16BE(offset + channel * 2)
350+
: line[offset + channel * bytesPerSample]!;
351+
const sample = (channel: number): number => scaleSample(rawSample(channel), metadata.bitDepth);
319352

320353
if (metadata.colorType === 0) {
321354
const gray = sample(0);
322-
const transparent = matchesTransparentGray(fullSample(0), metadata);
355+
const transparent = matchesTransparentGray(rawSample(0), metadata);
323356
return [gray, gray, gray, transparent ? 0 : 255];
324357
}
325358
if (metadata.colorType === 2) {
326359
const red = sample(0);
327360
const green = sample(1);
328361
const blue = sample(2);
329-
const transparent = matchesTransparentRgb(
330-
fullSample(0),
331-
fullSample(1),
332-
fullSample(2),
333-
metadata,
334-
);
362+
const transparent = matchesTransparentRgb(rawSample(0), rawSample(1), rawSample(2), metadata);
335363
return [red, green, blue, transparent ? 0 : 255];
336364
}
337365
if (metadata.colorType === 4) {
@@ -391,6 +419,11 @@ function filterBytesPerPixel(metadata: PngMetadata): number {
391419
return Math.max(1, Math.ceil((channels * metadata.bitDepth) / 8));
392420
}
393421

422+
function scaleSample(sample: number, bitDepth: number): number {
423+
if (bitDepth === 16) return Math.round((sample / 0xffff) * 0xff);
424+
return sample;
425+
}
426+
394427
function matchesTransparentGray(sample: number, metadata: PngMetadata): boolean {
395428
if (!metadata.transparency || metadata.transparency.length < 2) return false;
396429
return sample === metadata.transparency.readUInt16BE(0);
@@ -444,3 +477,7 @@ function validateDimension(value: number, label: string): number {
444477
if (!Number.isInteger(value) || value < 1) throw new Error(`PNG ${label} must be positive`);
445478
return value;
446479
}
480+
481+
function isZlibOutputLimitError(error: unknown): boolean {
482+
return error instanceof Error && 'code' in error && error.code === 'ERR_BUFFER_TOO_LARGE';
483+
}

0 commit comments

Comments
 (0)