From 9a20c9f2b393f06eafbf657641107f0e0d0825dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammed=20Tanr=C4=B1kulu?= Date: Tue, 24 Sep 2024 01:40:24 +0200 Subject: [PATCH] increase coverage, add header test, allow octet-stream as a mimetype --- package.json | 4 +- src/utils/isImageURI.ts | 28 ++-- src/utils/resolveURI.ts | 64 +++++++- test/resolver.test.ts | 126 +++++++++++++++- test/utils.test.ts | 324 ++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + yarn.lock | 31 ++++ 7 files changed, 560 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 183d0ce..75269db 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "main": "dist/index.js", "module": "dist/index.esm.js", @@ -57,9 +57,11 @@ "@size-limit/preset-small-lib": "^11.0.1", "@types/dompurify": "^3.0.5", "@types/jsdom": "^21.1.6", + "@types/moxios": "^0.4.17", "@types/url-join": "^4.0.1", "dotenv": "^16.3.1", "esbuild": "^0.14.21", + "moxios": "^0.4.0", "nock": "^13.2.2", "rollup": "^4.9.1", "size-limit": "^11.0.1", diff --git a/src/utils/isImageURI.ts b/src/utils/isImageURI.ts index 1302129..98a8eca 100644 --- a/src/utils/isImageURI.ts +++ b/src/utils/isImageURI.ts @@ -4,6 +4,7 @@ import { Buffer } from 'buffer/'; import { fetch } from './fetch'; export const ALLOWED_IMAGE_MIMETYPES = [ + 'application/octet-stream', 'image/jpeg', 'image/png', 'image/gif', @@ -16,6 +17,14 @@ export const ALLOWED_IMAGE_MIMETYPES = [ 'image/jxl', ]; +export const IMAGE_SIGNATURES = { + 'FFD8FF': 'image/jpeg', + '89504E47': 'image/png', + '47494638': 'image/gif', + '424D': 'image/bmp', + 'FF0A': 'image/jxl', +}; + const MAX_FILE_SIZE = 300 * 1024 * 1024; // 300 MB function isURIEncoded(uri: string): boolean { @@ -56,19 +65,18 @@ async function isStreamAnImage(url: string): Promise { if (response.data instanceof ArrayBuffer) { magicNumbers = new DataView(response.data).getUint32(0).toString(16); } else { + if ( + !response.data || + typeof response.data === 'string' || + !('readUInt32BE' in response.data) + ) { + throw 'isStreamAnImage: unsupported data, instance is not BufferLike'; + } magicNumbers = response.data.readUInt32BE(0).toString(16); } - const imageSignatures = [ - 'ffd8ff', // JPEG - '89504e47', // PNG - '47494638', // GIF - '424d', // BMP - 'ff0a', // JPEG XL - ]; - - const isBinaryImage = imageSignatures.some(signature => - magicNumbers.startsWith(signature) + const isBinaryImage = Object.keys(IMAGE_SIGNATURES).some(signature => + magicNumbers.toUpperCase().startsWith(signature) ); // Check for SVG image diff --git a/src/utils/resolveURI.ts b/src/utils/resolveURI.ts index 5d5c9d2..0cec0ec 100644 --- a/src/utils/resolveURI.ts +++ b/src/utils/resolveURI.ts @@ -2,6 +2,7 @@ import urlJoin from 'url-join'; import { Gateways } from '../types'; import { isCID } from './isCID'; +import { IMAGE_SIGNATURES } from './isImageURI'; const IPFS_SUBPATH = '/ipfs/'; const IPNS_SUBPATH = '/ipns/'; @@ -9,6 +10,67 @@ const networkRegex = /(?ipfs:\/|ipns:\/|ar:\/)?(?\/)?(? const base64Regex = /^data:([a-zA-Z\-/+]*);base64,([^"].*)/; const dataURIRegex = /^data:([a-zA-Z\-/+]*)?(;[a-zA-Z0-9].*?)?(,)/; +function _getImageMimeType(uri: string) { + const base64Data = uri.replace(base64Regex, '$2'); + const buffer = Buffer.from(base64Data, 'base64'); + + if (buffer.length < 12) { + return null; // not enough data to determine the type + } + + // get the hex representation of the first 12 bytes + const hex = buffer.toString('hex', 0, 12).toUpperCase(); + + // check against magic number mapping + for (const [magicNumber, mimeType] of Object.entries({ + ...IMAGE_SIGNATURES, + '52494646': 'special_webp_check', + '3C737667': 'image/svg+xml', + })) { + if (hex.startsWith(magicNumber.toUpperCase())) { + if (mimeType === 'special_webp_check') { + return hex.slice(8, 12) === '5745' ? 'image/webp' : null; + } + return mimeType; + } + } + + return null; +} + +function _isValidBase64(uri: string) { + if (typeof uri !== 'string') { + return false; + } + + // check if the string matches the Base64 pattern + if (!base64Regex.test(uri)) { + return false; + } + + const [header, str] = uri.split('base64,'); + + const mimeType = _getImageMimeType(uri); + + if (!mimeType || !header.includes(mimeType)) { + return false; + } + + // length must be multiple of 4 + if (str.length % 4 !== 0) { + return false; + } + + try { + // try to encode/decode the string, to see if matches + const buffer = Buffer.from(str, 'base64'); + const encoded = buffer.toString('base64'); + return encoded === str; + } catch (e) { + return false; + } +} + function _replaceGateway(uri: string, source: string, target?: string) { if (uri.startsWith(source) && target) { try { @@ -28,7 +90,7 @@ export function resolveURI( customGateway?: string ): { uri: string; isOnChain: boolean; isEncoded: boolean } { // resolves uri based on its' protocol - const isEncoded = base64Regex.test(uri); + const isEncoded = _isValidBase64(uri); if (isEncoded || uri.startsWith('http')) { uri = _replaceGateway(uri, 'https://ipfs.io/', gateways?.ipfs); uri = _replaceGateway(uri, 'https://arweave.net/', gateways?.arweave); diff --git a/test/resolver.test.ts b/test/resolver.test.ts index 78f8744..ca5bdde 100644 --- a/test/resolver.test.ts +++ b/test/resolver.test.ts @@ -92,13 +92,14 @@ function jsonRpcResult(result: string): JsonRpcResult { }; } +const provider = new JsonRpcProvider(INFURA_URL.toString(), 'mainnet'); +const avt = new AvatarResolver(provider, { + apiKey: { + opensea: 'api-key', + }, +}); + describe('get avatar', () => { - const provider = new JsonRpcProvider(INFURA_URL.toString(), 'mainnet'); - const avt = new AvatarResolver(provider, { - apiKey: { - opensea: 'a2b184238ee8460d9d2f58b0d3177c23', - }, - }); it('retrieves image uri with erc721 spec', async () => { mockInfuraChainId(); @@ -518,3 +519,116 @@ describe('get avatar', () => { // expect(await avt.getAvatar({ ens: 'testname.eth' })).toMatch(/^(data:image\/svg\+xml;base64,).*$/); // }); }); + +describe('get banner/header', () => { + it('retrieves image uri with custom spec', async () => { + mockInfuraChainId(); + + nockInfuraBatch( + [ + ethCallParams( + ENSRegistryWithFallback.toString(), + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + chainIdParams(), + ethCallParams( + ENSRegistryWithFallback.toString(), + '0x0178b8bfb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + ], + [ + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' + ), + jsonRpcResult('0x1'), + jsonRpcResult( + '0x0000000000000000000000004976fb03c32e5b8cfe2b6ccb31c09ba78ebaba41' + ), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ), + jsonRpcResult('0x1'), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x3b3b57deb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000d59d0f7dcc0fbf0a3305ce0261863aaf7ab685c' + ), + jsonRpcResult('0x1'), + ] + ); + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x01ffc9a79061b92300000000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000000' + ), + jsonRpcResult('0x1'), + ] + ); + + nockInfuraBatch( + [ + ethCallParams( + PublicResolver.toLowerCase(), + '0x59d1d43cb47a0edaf3c702800c923ca4c44a113d0d718cb1f42ecdce70c5fd05fa36a63f000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000066865616465720000000000000000000000000000000000000000000000000000' + ), + chainIdParams(), + ], + [ + jsonRpcResult( + '0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000004368747470733a2f2f697066732e696f2f697066732f516d55536867666f5a5153484b3354517975546655707363385566654e6644384b77505576444255645a346e6d520000000000000000000000000000000000000000000000000000000000' + ), // Encoded HEADER URI + jsonRpcResult('0x1'), + ] + ); + + + const HEADER_URI_TANRIKULU = new URL( + 'https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR' + ); + + /* mock head call */ + nock(HEADER_URI_TANRIKULU.origin) + .head(HEADER_URI_TANRIKULU.pathname) + .reply(200, {}, { + ...CORS_HEADERS, + 'content-type': 'image/png', + } as any); + /* mock get call */ + nock(HEADER_URI_TANRIKULU.origin) + .get(HEADER_URI_TANRIKULU.pathname) + .reply(200, {}, { + ...CORS_HEADERS, + 'content-type': 'image/png', + } as any); + expect(await avt.getHeader('tanrikulu.eth')).toEqual( + 'https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR' + ); + }); +}); diff --git a/test/utils.test.ts b/test/utils.test.ts index bbc79c5..7cd92b4 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,8 +1,13 @@ +import { JSDOM } from 'jsdom'; +import moxios from 'moxios'; +import { fetch } from '../src/utils'; import { CID } from 'multiformats/cid'; import { + ALLOWED_IMAGE_MIMETYPES, assert, BaseError, isCID, + isImageURI, parseNFT, resolveURI, getImageURI, @@ -24,6 +29,7 @@ describe('resolve ipfs', () => { 'ipns/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json', 'ipns/ipns.com', '/ipns/github.com', + 'https://ipfs.io/ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', ]; const arweaveCases = [ @@ -53,6 +59,13 @@ describe('resolve ipfs', () => { } }); + it('resolve different ipfs uri cases with custom gateway', () => { + for (let uri of ipfsCases) { + const { uri: resolvedURI } = resolveURI(uri, { ipfs: 'https://custom-ipfs.io' }); + expect(resolvedURI).toMatch(/^https:\/\/custom-ipfs.io\/?/); + } + }); + it('resolve http and base64 cases', () => { for (let uri of httpOrDataCases) { const { uri: resolvedURI } = resolveURI(uri); @@ -195,3 +208,314 @@ describe('remove refresh meta tags', () => { expect(result).toBe(sanitizedBase64svg); }); }); + +describe('getImageURI', () => { + const jsdomWindow = new JSDOM().window; + + it('should throw an error when image is not available', () => { + expect(() => getImageURI({ metadata: {}, jsdomWindow })).toThrow( + 'Image is not available' + ); + }); + + it('should handle image_url', () => { + const result = getImageURI({ + metadata: { image_url: 'https://example.com/image.png' }, + jsdomWindow, + }); + expect(result).toBe('https://example.com/image.png'); + }); + + it('should handle image_data', () => { + const svgData = + ''; + const result = getImageURI({ + metadata: { image_data: svgData }, + jsdomWindow, + }); + expect(result).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it('should sanitize SVG content', () => { + const maliciousSVG = + ''; + const result = getImageURI({ + metadata: { image: maliciousSVG }, + jsdomWindow, + }); + expect(result).not.toContain('