From 55bdfa710a6e879d5514f3a59cdf450ba3083415 Mon Sep 17 00:00:00 2001 From: madtisa Date: Mon, 12 Aug 2024 12:12:32 +0300 Subject: [PATCH 1/9] (fix) Search `acTL` only among chunk types Byte sequence `acTL` can be encountered in other parts of PNG, that are not chunk type (e.g. in chunk data or crc), so we should check only chunk types to avoid false positive detection. --- src/images/staticWithMetadata.png | Bin 0 -> 2020 bytes src/index.test.js | 44 +++++++++++++------ src/index.ts | 69 ++++++++++++++++++++---------- 3 files changed, 78 insertions(+), 35 deletions(-) create mode 100644 src/images/staticWithMetadata.png diff --git a/src/images/staticWithMetadata.png b/src/images/staticWithMetadata.png new file mode 100644 index 0000000000000000000000000000000000000000..9b5194506d3404620bc053d6b4af1f0d410f02d1 GIT binary patch literal 2020 zcmbVN4NMbf7(SRK3S*m@I)65E90?4xy=y77oRlA16qEwPP7pHWdUu7B>)m;GrNw0m zs3;3#YUZ3w#h=0aAx>f>I)^{7p$?HPN@C2Gpd$V*>fD0pkbP~TL+4^r zyw6y2 zOxVbaG#snKlq7~>ur5}m)~QL2&NdU)U>XgI=}~ozQmw}^4X%xXhd&5NCsH;%%QR;= z8F(@vMY1g5DC%@NRn90CFBYQe*w|PU)1VrS5+IaPDJK&yB`1ZBFqmkG6d6Hgcn)?m z5?0EsenW~f_@ zfEz`cka^L<^X$k@WsZ~#YqTmg{BAbGQM^-%7!^R92$?n@VAr%tOs`aHENTpoj=?eg z3@i%AFfY{1Q;ecQ&8X;ijavHf+-TW@uHmof*CteNTUK*h`_!!;zpk3 zMUWU&7v-tKY{pYLNhUawPBj@2pjpK*6t1JJYC?m>Dq~13skBCGqm@=$v{tFp>8u2) zi;2+_UKJqT#FGy9&W^-W|1&;AWWdoP*q7!3CjjV{8JVSr4?_|w53g9p?Mj>_xI&;# zNzpdK!OBQHL0Zz{P`Axl20%S4ZxlL;%i}aWg^_>8c>e*&@;yH!X(}%I|-3aDg;~bVvl-*$Rf}W8cZ=1K}Xbkb)nBn z_H<^{Xc&QmdE;LCCLMV2%}SaB)FPOKz2CIdK#+fQswv5m)AqxrB<_6TgvyUrvFn-N z+m9sHWID=C3zK&H=lyiJd1X#tKyLu;m)Gha(NS_LAY!lOLc@f}lm7LQhb}cNEs1Zv z9d~|SOjTjn{haDQ?rs=3-rsfh%ckf%ugKe+oA0zaN<#bAte@Dk^~zdB+n(;y zS>@?%>#D2k0u#rmgq5F07@PF34$ciqw5;e(kF-R0-7<$jwFSvnKf?z%LOZ{`K1nyU zZ}Y@EGZpI^2W~Z?7lI!u9c6UPmPc8u$<`AtH-X~ zzsi { t.false(check(dir + '/images/static.jpg')) }) +test('returns false on static PNG with `acTL` text in metadata', (t) => { + t.false(check(dir + '/images/staticWithMetadata.png')) +}) + test('returns true when IDAT follows acTL', (t) => { t.true( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, - // IDAT + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT 0x49, 0x44, 0x41, 0x54, ]), ), ) }) -test('returns false when IDAT precedes acTL', (t) => { +test('returns false when acTL is not a chunk type', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // IDAT - 0x49, 0x44, 0x41, 0x54, - // acTL + // Chunk length: 4 + 0x00, 0x00, 0x00, 0x04, + // Chunk type: any + 0x00, 0x00, 0x00, 0x01, + // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), ) }) -test('returns false when missing IDAT', (t) => { +test('returns false when IDAT precedes acTL', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // acTL + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT + 0x49, 0x44, 0x41, 0x54, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL 0x61, 0x63, 0x54, 0x4c, ]), ), @@ -72,10 +92,10 @@ test('chunks should be found when preceded by a partial of themselves', (t) => { new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // a acTL - 0x61, 0x61, 0x63, 0x54, 0x4c, - // I IDAT - 0x49, 0x49, 0x44, 0x41, 0x54, + // Chunk length: a + 0x00, 0x00, 0x00, 0x61, + // Chunk type: acTL + 0x61, 0x63, 0x54, 0x4c, ]), ), ) diff --git a/src/index.ts b/src/index.ts index 1cf86b3..6c58e4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,34 @@ +function convertToInt(bytes: Uint8Array) { + return bytes.reduce((value, byte) => (value << 8) + byte) +} + +function isEqual(first, second) { + return ( + first[0] === second[0] && + first[1] === second[1] && + first[2] === second[2] && + first[3] === second[3] + ) +} + const encoder = new TextEncoder() -const sequences = { - animationControlChunk: encoder.encode('acTL'), - imageDataChunk: encoder.encode('IDAT'), +const chunkTypes = { + animationControl: encoder.encode('acTL'), + imageData: encoder.encode('IDAT'), +} + +/** + * @see http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html + */ +const headerSizes = { + /** Number of bytes reserved for PNG signature */ + SIGNATURE: 8, + /** Number of bytes reserved for chunk type */ + LENGTH: 4, + /** Number of bytes reserved for chunk type */ + TYPE: 4, + /** Number of bytes reserved for chunk CRC */ + CRC: 4, } export default function isApng(buffer: Buffer | Uint8Array): boolean { @@ -33,32 +60,28 @@ export default function isApng(buffer: Buffer | Uint8Array): boolean { // APNGs have an animation control chunk (acTL) preceding any IDAT(s). // See: https://en.wikipedia.org/wiki/APNG#File_format - buffer = buffer.subarray(8) + buffer = buffer.subarray(headerSizes.SIGNATURE) - let firstIndex = 0 - let secondIndex = 0 - for (let i = 0; i < buffer.length; i++) { - if (buffer[i] !== sequences.animationControlChunk[firstIndex]) { - firstIndex = 0 - } + const minBufferSize = headerSizes.LENGTH + headerSizes.TYPE + while (buffer.length >= minBufferSize) { + const chunkLength = convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + const chunkType = buffer.subarray( + headerSizes.LENGTH, + headerSizes.LENGTH + headerSizes.TYPE, + ) - if (buffer[i] === sequences.animationControlChunk[firstIndex]) { - firstIndex++ - if (firstIndex === sequences.animationControlChunk.length) { - return true - } + if (isEqual(chunkType, chunkTypes.animationControl)) { + return true } - if (buffer[i] !== sequences.imageDataChunk[secondIndex]) { - secondIndex = 0 + if (isEqual(chunkType, chunkTypes.imageData)) { + return false } - if (buffer[i] === sequences.imageDataChunk[secondIndex]) { - secondIndex++ - if (secondIndex === sequences.imageDataChunk.length) { - return false - } - } + const nextChunkPosition = + headerSizes.LENGTH + headerSizes.TYPE + chunkLength + headerSizes.CRC + + buffer = buffer.subarray(nextChunkPosition) } return false From 8a2109f3f423f0506086e92507aef8c2e8f80bcf Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Mon, 12 Aug 2024 12:47:58 +0200 Subject: [PATCH 2/9] perf: Some refactoring --- src/index.ts | 65 +++++++++++++++++++++++----------------------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/src/index.ts b/src/index.ts index 6c58e4e..0a3ef0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,23 +2,18 @@ function convertToInt(bytes: Uint8Array) { return bytes.reduce((value, byte) => (value << 8) + byte) } -function isEqual(first, second) { - return ( - first[0] === second[0] && - first[1] === second[1] && - first[2] === second[2] && - first[3] === second[3] - ) -} - -const encoder = new TextEncoder() -const chunkTypes = { - animationControl: encoder.encode('acTL'), - imageData: encoder.encode('IDAT'), +function isEqual(first, second, length = 4) { + while (length > 0) { + length-- + if (first[length] !== second[length]) { + return false + } + } + return true; } /** - * @see http://www.libpng.org/pub/png/spec/1.2/PNG-Structure.html + * @see https://www.w3.org/TR/png/#5DataRep */ const headerSizes = { /** Number of bytes reserved for PNG signature */ @@ -31,55 +26,53 @@ const headerSizes = { CRC: 4, } +const chunkTypes = { + signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + animationControl: [0x61, 0x63, 0x54, 0x4c], // 'acTL' + imageData: [0x49, 0x44, 0x41, 0x54], // 'IDAT' +} + export default function isApng(buffer: Buffer | Uint8Array): boolean { + const minChunkSize = headerSizes.LENGTH + headerSizes.TYPE + headerSizes.CRC + if ( !buffer || !( (typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) || buffer instanceof Uint8Array ) || - buffer.length < 16 + buffer.length < headerSizes.SIGNATURE + minChunkSize ) { return false } - const isPNG = - buffer[0] === 0x89 && - buffer[1] === 0x50 && - buffer[2] === 0x4e && - buffer[3] === 0x47 && - buffer[4] === 0x0d && - buffer[5] === 0x0a && - buffer[6] === 0x1a && - buffer[7] === 0x0a - - if (!isPNG) { + /** Check for PNG signature */ + if (!isEqual(buffer, chunkTypes.signature, headerSizes.SIGNATURE)) { return false } - // APNGs have an animation control chunk (acTL) preceding any IDAT(s). - // See: https://en.wikipedia.org/wiki/APNG#File_format - buffer = buffer.subarray(headerSizes.SIGNATURE) - const minBufferSize = headerSizes.LENGTH + headerSizes.TYPE - while (buffer.length >= minBufferSize) { - const chunkLength = convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + /** + * APNGs have an animation control (acTL) chunk preceding any image data (IDAT) chunks. + * @see: https://www.w3.org/TR/png/#5ChunkOrdering + */ + + while (buffer.length >= minChunkSize) { const chunkType = buffer.subarray( headerSizes.LENGTH, headerSizes.LENGTH + headerSizes.TYPE, ) - if (isEqual(chunkType, chunkTypes.animationControl)) { + if (isEqual(chunkType, chunkTypes.animationControl, headerSizes.TYPE)) { return true } - if (isEqual(chunkType, chunkTypes.imageData)) { + if (isEqual(chunkType, chunkTypes.imageData, headerSizes.TYPE)) { return false } - const nextChunkPosition = - headerSizes.LENGTH + headerSizes.TYPE + chunkLength + headerSizes.CRC + const nextChunkPosition = minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH)) buffer = buffer.subarray(nextChunkPosition) } From 9ba5bbf760327594dd6129d47e23c61e044f7fca Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:37:01 +0200 Subject: [PATCH 3/9] chore: types and formatting cleanup --- src/index.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0a3ef0e..bf59464 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,14 +2,18 @@ function convertToInt(bytes: Uint8Array) { return bytes.reduce((value, byte) => (value << 8) + byte) } -function isEqual(first, second, length = 4) { +function isEqual( + first: Uint8Array | number[], + second: Uint8Array | number[], + length = 4, +) { while (length > 0) { length-- if (first[length] !== second[length]) { return false } } - return true; + return true } /** @@ -32,15 +36,12 @@ const chunkTypes = { imageData: [0x49, 0x44, 0x41, 0x54], // 'IDAT' } -export default function isApng(buffer: Buffer | Uint8Array): boolean { +export default function isApng(buffer: Uint8Array): boolean { const minChunkSize = headerSizes.LENGTH + headerSizes.TYPE + headerSizes.CRC - + if ( !buffer || - !( - (typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) || - buffer instanceof Uint8Array - ) || + !(buffer instanceof Uint8Array) || buffer.length < headerSizes.SIGNATURE + minChunkSize ) { return false @@ -72,7 +73,8 @@ export default function isApng(buffer: Buffer | Uint8Array): boolean { return false } - const nextChunkPosition = minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + const nextChunkPosition = + minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH)) buffer = buffer.subarray(nextChunkPosition) } From ad57624fbd4f60ddad0c809f695ab26069963b05 Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Mon, 12 Aug 2024 13:59:53 +0200 Subject: [PATCH 4/9] test: Improved tests * Added additional tests for input validation handling and data structure constraints * Removed test related to string matching --- src/index.test.js | 99 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/src/index.test.js b/src/index.test.js index 6c1bdea..f069a12 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -4,6 +4,42 @@ import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' import isApng from '../dist/index.mjs' +/** Input validation tests */ + +test('returns false on number 0 input', (t) => { + t.false(isApng(0)) +}) + +test('returns false on number 1 input', (t) => { + t.false(isApng(1)) +}) + +test('returns false on empty string input', (t) => { + t.false(isApng('')) +}) + +test('returns false on non-empty string input', (t) => { + t.false(isApng('string')) +}) + +test('returns false on boolean true input', (t) => { + t.false(isApng(true)) +}) + +test('returns false on boolean false input', (t) => { + t.false(isApng(false)) +}) + +test('returns false on object input', (t) => { + t.false(isApng({})) +}) + +test('returns false on array input', (t) => { + t.false(isApng([])) +}) + +/** Real image tests */ + const dir = typeof __dirname !== 'undefined' ? __dirname @@ -19,15 +55,17 @@ test('returns false on static PNG', (t) => { t.false(check(dir + '/images/static.png')) }) +test('returns false on static PNG with `acTL` text in metadata', (t) => { + t.false(check(dir + '/images/staticWithMetadata.png')) +}) + test('returns false on JPG', (t) => { t.false(check(dir + '/images/static.jpg')) }) -test('returns false on static PNG with `acTL` text in metadata', (t) => { - t.false(check(dir + '/images/staticWithMetadata.png')) -}) +/** Mock data tests */ -test('returns true when IDAT follows acTL', (t) => { +test('returns true when acTL precedes any IDAT', (t) => { t.true( isApng( new Uint8Array([ @@ -43,6 +81,31 @@ test('returns true when IDAT follows acTL', (t) => { 0x00, 0x00, 0x00, 0x00, // Chunk type: IDAT 0x49, 0x44, 0x41, 0x54, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + ]), + ), + ) +}) + +test('returns false when any IDAT precedes acTL', (t) => { + t.false( + isApng( + new Uint8Array([ + // PNG header + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: IDAT + 0x49, 0x44, 0x41, 0x54, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: acTL + 0x61, 0x63, 0x54, 0x4c, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, ]), ), ) @@ -60,42 +123,36 @@ test('returns false when acTL is not a chunk type', (t) => { 0x00, 0x00, 0x00, 0x01, // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, ]), ), ) }) -test('returns false when IDAT precedes acTL', (t) => { +test('returns false on too small PNG', (t) => { t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // Chunk length: 0 - 0x00, 0x00, 0x00, 0x00, - // Chunk type: IDAT - 0x49, 0x44, 0x41, 0x54, - // Chunk CRC - 0x00, 0x00, 0x00, 0x00, - // Chunk length: 0 - 0x00, 0x00, 0x00, 0x00, - // Chunk type: acTL - 0x61, 0x63, 0x54, 0x4c, + // Content omitted ]), ), ) }) -test('chunks should be found when preceded by a partial of themselves', (t) => { - t.true( +test('returns false on invalid chunk', (t) => { + t.false( isApng( new Uint8Array([ // PNG header 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // Chunk length: a - 0x00, 0x00, 0x00, 0x61, - // Chunk type: acTL - 0x61, 0x63, 0x54, 0x4c, + // Chunk length: 4 + 0x00, 0x00, 0x00, 0x04, + // Chunk type: any + 0x00, 0x00, 0x00, 0x01, + // Chunk CRC omitted ]), ), ) From 64ff9544105d979ad31af44e512638c7f45dc09f Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Mon, 12 Aug 2024 14:05:06 +0200 Subject: [PATCH 5/9] test: fixed 'too small next chunk' test --- src/index.test.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/index.test.js b/src/index.test.js index f069a12..47f81c9 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -142,7 +142,7 @@ test('returns false on too small PNG', (t) => { ) }) -test('returns false on invalid chunk', (t) => { +test('returns false when next chunk size is too small', (t) => { t.false( isApng( new Uint8Array([ @@ -152,6 +152,12 @@ test('returns false on invalid chunk', (t) => { 0x00, 0x00, 0x00, 0x04, // Chunk type: any 0x00, 0x00, 0x00, 0x01, + // Chunk CRC + 0x00, 0x00, 0x00, 0x00, + // Chunk length: 4 + 0x00, 0x00, 0x00, 0x04, + // Chunk type: any + 0x00, 0x00, 0x00, 0x02, // Chunk CRC omitted ]), ), From 60087bc761a385f489fc4b468b043af804a220c8 Mon Sep 17 00:00:00 2001 From: madtisa Date: Mon, 12 Aug 2024 19:49:24 +0300 Subject: [PATCH 6/9] fix: Validate chunk types Check chunk types symbol range and whether it's known critical chunk --- src/index.test.js | 36 ++++++++++++++++++++++-- src/index.ts | 71 ++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/index.test.js b/src/index.test.js index 47f81c9..070d920 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -120,7 +120,7 @@ test('returns false when acTL is not a chunk type', (t) => { // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x00, 0x00, 0x00, 0x01, + 0x66, 0x66, 0x66, 0x66, // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, // Chunk CRC @@ -151,15 +151,45 @@ test('returns false when next chunk size is too small', (t) => { // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x00, 0x00, 0x00, 0x01, + 0x66, 0x66, 0x66, 0x66, // Chunk CRC 0x00, 0x00, 0x00, 0x00, // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x00, 0x00, 0x00, 0x02, + 0x66, 0x66, 0x66, 0x66, // Chunk CRC omitted ]), ), ) }) + +test('returns false when chunk type is invalid', (t) => { + t.false( + isApng( + new Uint8Array([ + // PNG header + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: invalid bytes + 0x00, 0x00, 0x00, 0x01, + ]), + ), + ) +}) + +test('returns false when unknown critical chunk type', (t) => { + t.false( + isApng( + new Uint8Array([ + // PNG header + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + // Chunk length: 0 + 0x00, 0x00, 0x00, 0x00, + // Chunk type: unknown critical + 0x55, 0x55, 0x55, 0x55, + ]), + ), + ) +}) diff --git a/src/index.ts b/src/index.ts index bf59464..d0bf997 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,33 @@ -function convertToInt(bytes: Uint8Array) { - return bytes.reduce((value, byte) => (value << 8) + byte) +function bytesToInt(bytes: Uint8Array): number { + return bytes.reduce((value, byte) => (value << 8) + byte, 0) +} + +function chunkTypeToInt(bytes: Uint8Array | number[]): number { + if (bytes.length !== headerSizes.TYPE) { + throw new Error(`Invalid chunk type size ${bytes.length}`) + } + + let value = 0 + for (let i = 0; i < bytes.length; i++) { + const byte = bytes[i] + + if (!(byte >= 0x41 && byte <= 0x5a) && !(byte >= 0x61 && byte <= 0x7a)) { + const bytesText = Array.from(bytes) + .map((byte) => `0x${byte.toString(16)}`) + .join() + throw new Error(`Invalid chunk type ${bytesText}`) + } + + value = (value << 8) + bytes[i] + } + + return value } function isEqual( first: Uint8Array | number[], second: Uint8Array | number[], - length = 4, + length, ) { while (length > 0) { length-- @@ -32,10 +54,25 @@ const headerSizes = { const chunkTypes = { signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], - animationControl: [0x61, 0x63, 0x54, 0x4c], // 'acTL' - imageData: [0x49, 0x44, 0x41, 0x54], // 'IDAT' + /** `IHDR` chunk type */ + imageHeader: chunkTypeToInt([0x49, 0x48, 0x44, 0x52]), + /** `PLTE` chunk type */ + palette: chunkTypeToInt([0x50, 0x4c, 0x54, 0x45]), + /** `acTL` chunk type */ + animationControl: chunkTypeToInt([0x61, 0x63, 0x54, 0x4c]), + /** `IDAT` chunk type */ + imageData: chunkTypeToInt([0x49, 0x44, 0x41, 0x54]), + /** `IEND` chunk type */ + imageEnd: chunkTypeToInt([0x49, 0x45, 0x4e, 0x44]), } +const knownCriticalChunkTypes = new Set([ + chunkTypes.imageHeader, + chunkTypes.palette, + chunkTypes.imageData, + chunkTypes.imageEnd, +]) + export default function isApng(buffer: Uint8Array): boolean { const minChunkSize = headerSizes.LENGTH + headerSizes.TYPE + headerSizes.CRC @@ -60,21 +97,29 @@ export default function isApng(buffer: Uint8Array): boolean { */ while (buffer.length >= minChunkSize) { - const chunkType = buffer.subarray( - headerSizes.LENGTH, - headerSizes.LENGTH + headerSizes.TYPE, + const chunkType = chunkTypeToInt( + buffer.subarray( + headerSizes.LENGTH, + headerSizes.LENGTH + headerSizes.TYPE, + ), ) - if (isEqual(chunkType, chunkTypes.animationControl, headerSizes.TYPE)) { - return true + // Sixth bit of the first byte of the chunk type is critical property + // (0 - critical, 1 - not) + const isCriticalChunk = !(chunkType & 0x20000000) + if (isCriticalChunk && !knownCriticalChunkTypes.has(chunkType)) { + return false } - if (isEqual(chunkType, chunkTypes.imageData, headerSizes.TYPE)) { - return false + switch (chunkType) { + case chunkTypes.animationControl: + return true + case chunkTypes.imageData: + return false } const nextChunkPosition = - minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH)) + minChunkSize + bytesToInt(buffer.subarray(0, headerSizes.LENGTH)) buffer = buffer.subarray(nextChunkPosition) } From 9a7232a0034fc9edf87d602f5f29c2633afb656b Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:27:00 +0200 Subject: [PATCH 7/9] Revert "fix: Validate chunk types" This reverts commit 60087bc761a385f489fc4b468b043af804a220c8. --- src/index.test.js | 36 ++---------------------- src/index.ts | 71 +++++++++-------------------------------------- 2 files changed, 16 insertions(+), 91 deletions(-) diff --git a/src/index.test.js b/src/index.test.js index 070d920..47f81c9 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -120,7 +120,7 @@ test('returns false when acTL is not a chunk type', (t) => { // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x66, 0x66, 0x66, 0x66, + 0x00, 0x00, 0x00, 0x01, // Chunk data: acTL 0x61, 0x63, 0x54, 0x4c, // Chunk CRC @@ -151,45 +151,15 @@ test('returns false when next chunk size is too small', (t) => { // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x66, 0x66, 0x66, 0x66, + 0x00, 0x00, 0x00, 0x01, // Chunk CRC 0x00, 0x00, 0x00, 0x00, // Chunk length: 4 0x00, 0x00, 0x00, 0x04, // Chunk type: any - 0x66, 0x66, 0x66, 0x66, + 0x00, 0x00, 0x00, 0x02, // Chunk CRC omitted ]), ), ) }) - -test('returns false when chunk type is invalid', (t) => { - t.false( - isApng( - new Uint8Array([ - // PNG header - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // Chunk length: 0 - 0x00, 0x00, 0x00, 0x00, - // Chunk type: invalid bytes - 0x00, 0x00, 0x00, 0x01, - ]), - ), - ) -}) - -test('returns false when unknown critical chunk type', (t) => { - t.false( - isApng( - new Uint8Array([ - // PNG header - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, - // Chunk length: 0 - 0x00, 0x00, 0x00, 0x00, - // Chunk type: unknown critical - 0x55, 0x55, 0x55, 0x55, - ]), - ), - ) -}) diff --git a/src/index.ts b/src/index.ts index d0bf997..bf59464 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,33 +1,11 @@ -function bytesToInt(bytes: Uint8Array): number { - return bytes.reduce((value, byte) => (value << 8) + byte, 0) -} - -function chunkTypeToInt(bytes: Uint8Array | number[]): number { - if (bytes.length !== headerSizes.TYPE) { - throw new Error(`Invalid chunk type size ${bytes.length}`) - } - - let value = 0 - for (let i = 0; i < bytes.length; i++) { - const byte = bytes[i] - - if (!(byte >= 0x41 && byte <= 0x5a) && !(byte >= 0x61 && byte <= 0x7a)) { - const bytesText = Array.from(bytes) - .map((byte) => `0x${byte.toString(16)}`) - .join() - throw new Error(`Invalid chunk type ${bytesText}`) - } - - value = (value << 8) + bytes[i] - } - - return value +function convertToInt(bytes: Uint8Array) { + return bytes.reduce((value, byte) => (value << 8) + byte) } function isEqual( first: Uint8Array | number[], second: Uint8Array | number[], - length, + length = 4, ) { while (length > 0) { length-- @@ -54,25 +32,10 @@ const headerSizes = { const chunkTypes = { signature: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], - /** `IHDR` chunk type */ - imageHeader: chunkTypeToInt([0x49, 0x48, 0x44, 0x52]), - /** `PLTE` chunk type */ - palette: chunkTypeToInt([0x50, 0x4c, 0x54, 0x45]), - /** `acTL` chunk type */ - animationControl: chunkTypeToInt([0x61, 0x63, 0x54, 0x4c]), - /** `IDAT` chunk type */ - imageData: chunkTypeToInt([0x49, 0x44, 0x41, 0x54]), - /** `IEND` chunk type */ - imageEnd: chunkTypeToInt([0x49, 0x45, 0x4e, 0x44]), + animationControl: [0x61, 0x63, 0x54, 0x4c], // 'acTL' + imageData: [0x49, 0x44, 0x41, 0x54], // 'IDAT' } -const knownCriticalChunkTypes = new Set([ - chunkTypes.imageHeader, - chunkTypes.palette, - chunkTypes.imageData, - chunkTypes.imageEnd, -]) - export default function isApng(buffer: Uint8Array): boolean { const minChunkSize = headerSizes.LENGTH + headerSizes.TYPE + headerSizes.CRC @@ -97,29 +60,21 @@ export default function isApng(buffer: Uint8Array): boolean { */ while (buffer.length >= minChunkSize) { - const chunkType = chunkTypeToInt( - buffer.subarray( - headerSizes.LENGTH, - headerSizes.LENGTH + headerSizes.TYPE, - ), + const chunkType = buffer.subarray( + headerSizes.LENGTH, + headerSizes.LENGTH + headerSizes.TYPE, ) - // Sixth bit of the first byte of the chunk type is critical property - // (0 - critical, 1 - not) - const isCriticalChunk = !(chunkType & 0x20000000) - if (isCriticalChunk && !knownCriticalChunkTypes.has(chunkType)) { - return false + if (isEqual(chunkType, chunkTypes.animationControl, headerSizes.TYPE)) { + return true } - switch (chunkType) { - case chunkTypes.animationControl: - return true - case chunkTypes.imageData: - return false + if (isEqual(chunkType, chunkTypes.imageData, headerSizes.TYPE)) { + return false } const nextChunkPosition = - minChunkSize + bytesToInt(buffer.subarray(0, headerSizes.LENGTH)) + minChunkSize + convertToInt(buffer.subarray(0, headerSizes.LENGTH)) buffer = buffer.subarray(nextChunkPosition) } From 27ba2f4ee969dd5ecbbf273c0c65ea1ecdf8c7d5 Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:37:16 +0200 Subject: [PATCH 8/9] Update readme.md --- readme.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index b8555c9..63c84bf 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,10 @@ # is-apng   [![Latest GitHub release][release-image]][release-url] [![Latest NPM version][npm-image]][npm-url] [![Build Status][ci-image]][ci-url] -> Check if a Buffer/Uint8Array is a [Animated PNG / APNG](https://en.wikipedia.org/wiki/APNG) image +> Checks if a Buffer/Uint8Array contains a [Animated PNG / APNG](https://en.wikipedia.org/wiki/APNG) image. + +> [!IMPORTANT] +> This is _only_ a quick detect method and does not do full (A)PNG file validation.
+> For security and file integrity sensitive operations, use a true (A)PNG validator (see also [specs](https://www.w3.org/TR/png/)). ## Install From 1f59b54fec6069a02795f20d23200170e090d57a Mon Sep 17 00:00:00 2001 From: Philip <17368112+vHeemstra@users.noreply.github.com> Date: Tue, 13 Aug 2024 01:38:42 +0200 Subject: [PATCH 9/9] Update readme.md --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 63c84bf..d4e5e57 100644 --- a/readme.md +++ b/readme.md @@ -44,7 +44,7 @@ isApng(new Uint8Array(buffer)) #### As old-school global script tag Url for latest version: `https://unpkg.com/is-apng`
-Url for specific version: `https://unpkg.com/is-apng@1.1.3/dist/index.js` +Url for specific version: `https://unpkg.com/is-apng@1.1.4/dist/index.js` ```html @@ -58,7 +58,7 @@ Url for specific version: `https://unpkg.com/is-apng@1.1.3/dist/index.js` #### As module Url for latest version: `https://unpkg.com/is-apng/dist/index.mjs`
-Url for specific version: `https://unpkg.com/is-apng@1.1.3/dist/index.mjs` +Url for specific version: `https://unpkg.com/is-apng@1.1.4/dist/index.mjs` ```html