├── .gitignore ├── example ├── ens.png ├── build.js ├── node.js └── browser.js ├── .npmignore ├── src ├── utils │ ├── assert.ts │ ├── error.ts │ ├── isCID.ts │ ├── handleSettled.ts │ ├── detectPlatform.ts │ ├── index.ts │ ├── parseNFT.ts │ ├── getImageURI.ts │ ├── resolveURI.ts │ ├── fetch.ts │ ├── isImageURI.ts │ └── sanitize.ts ├── specs │ ├── uri.ts │ ├── erc721.ts │ └── erc1155.ts ├── types.ts └── index.ts ├── .github └── workflows │ ├── size.yml │ ├── main.yml │ └── pages.yml ├── test ├── setup.js ├── utils.test.ts └── resolver.test.ts ├── LICENSE ├── rollup.config.mjs ├── tsconfig.json ├── index.html ├── package.json ├── README.md └── SECURITY.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .env 4 | node_modules 5 | coverage 6 | dist 7 | -------------------------------------------------------------------------------- /example/ens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ensdomains/ens-avatar/HEAD/example/ens.png -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | .env 4 | .github 5 | coverage 6 | example 7 | node_modules 8 | test 9 | .gitignore 10 | tsconfig.json 11 | yarn.lock 12 | -------------------------------------------------------------------------------- /src/utils/assert.ts: -------------------------------------------------------------------------------- 1 | // simple assert without nested check 2 | export function assert(condition: any, message: string) { 3 | if (!condition) { 4 | throw message; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/error.ts: -------------------------------------------------------------------------------- 1 | export interface BaseError {} 2 | export class BaseError extends Error { 3 | __proto__: Error; 4 | constructor(message?: string) { 5 | const trueProto = new.target.prototype; 6 | super(message); 7 | 8 | this.__proto__ = trueProto; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /src/utils/isCID.ts: -------------------------------------------------------------------------------- 1 | import { CID } from 'multiformats'; 2 | 3 | export function isCID(hash: any) { 4 | // check if given string or object is a valid IPFS CID 5 | try { 6 | if (typeof hash === 'string') { 7 | return Boolean(CID.parse(hash)); 8 | } 9 | 10 | return Boolean(CID.asCID(hash)); 11 | } catch (_error) { 12 | return false; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/handleSettled.ts: -------------------------------------------------------------------------------- 1 | export async function handleSettled(promises: Promise[]) { 2 | const values = []; 3 | const results = await Promise.allSettled(promises); 4 | for (let result of results) { 5 | if (result.status === 'fulfilled') values.push(result.value); 6 | else if (result.status === 'rejected') values.push(null); 7 | } 8 | return values; 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/detectPlatform.ts: -------------------------------------------------------------------------------- 1 | const isBrowser: boolean = 2 | typeof window !== 'undefined' && typeof window.document !== 'undefined'; 3 | 4 | // Detect Node.js environment 5 | const isNode = 6 | typeof process !== 'undefined' && process.release?.name === 'node'; 7 | 8 | // Detect Cloudflare Workers and other edge runtimes (not browser, not Node.js) 9 | const isCloudflareWorker = !isBrowser && !isNode; 10 | 11 | export { isBrowser, isNode, isCloudflareWorker }; 12 | -------------------------------------------------------------------------------- /example/build.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | require('esbuild') 3 | .build({ 4 | bundle: true, 5 | entryPoints: ['example/browser.js'], 6 | external: ['dotenv'], 7 | loader: { 8 | '.html': 'text', 9 | }, 10 | outfile: 'example/dist/index.js', 11 | define: { 12 | process: `{ 13 | "env": { 14 | "INFURA_KEY": '${process.env.INFURA_KEY}', 15 | "OPENSEA_KEY": '${process.env.OPENSEA_KEY}' 16 | }, 17 | }`, 18 | }, 19 | }) 20 | .catch(() => process.exit(1)); 21 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | // Jest setup file to polyfill fetch for test environment 2 | // In Node.js 18+, fetch is built-in but may not be available in Jest's test environment 3 | // We'll use a lightweight fetch polyfill for testing 4 | 5 | /* eslint-env node */ 6 | /* global globalThis */ 7 | 8 | try { 9 | // Try to load native fetch from node (Node 18+) 10 | const nodeFetch = globalThis.fetch; 11 | if (!nodeFetch) { 12 | throw new Error('Fetch not available'); 13 | } 14 | } catch (e) { 15 | // If native fetch is not available, we'll let axios fall back to http adapter 16 | // which is perfectly fine for testing purposes 17 | console.log( 18 | 'Note: Using HTTP adapter for tests (fetch not available in Jest environment)' 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { assert } from './assert'; 2 | import { handleSettled } from './handleSettled'; 3 | import { parseNFT } from './parseNFT'; 4 | import { BaseError } from './error'; 5 | import { convertToRawSVG, getImageURI } from './getImageURI'; 6 | import { resolveURI } from './resolveURI'; 7 | import { 8 | createAgentAdapter, 9 | createCacheAdapter, 10 | createFetcher, 11 | fetch, 12 | } from './fetch'; 13 | import { isCID } from './isCID'; 14 | import { ALLOWED_IMAGE_MIMETYPES, isImageURI } from './isImageURI'; 15 | import { sanitizeSVG } from './sanitize'; 16 | 17 | export { 18 | ALLOWED_IMAGE_MIMETYPES, 19 | BaseError, 20 | assert, 21 | convertToRawSVG, 22 | createAgentAdapter, 23 | createCacheAdapter, 24 | createFetcher, 25 | fetch, 26 | getImageURI, 27 | handleSettled, 28 | isCID, 29 | isImageURI, 30 | parseNFT, 31 | resolveURI, 32 | sanitizeSVG, 33 | }; 34 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | env: 3 | INFURA_KEY: ${{ secrets.INFURA_KEY }} 4 | OPENSEA_KEY: ${{ secrets.OPENSEA_KEY }} 5 | on: [push] 6 | jobs: 7 | build: 8 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 9 | 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | node: ['21.x', '22.x'] 14 | os: [ubuntu-latest, windows-latest, macOS-latest] 15 | 16 | steps: 17 | - name: Checkout repo 18 | uses: actions/checkout@v2 19 | 20 | - name: Use Node ${{ matrix.node }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node }} 24 | cache: 'yarn' 25 | 26 | - run: yarn install --immutable --immutable-cache --check-cache 27 | 28 | - name: Lint 29 | run: yarn lint 30 | 31 | - name: Test 32 | run: yarn test --ci --coverage --maxWorkers=2 33 | 34 | - name: Build 35 | run: yarn build 36 | -------------------------------------------------------------------------------- /src/specs/uri.ts: -------------------------------------------------------------------------------- 1 | import { AvatarResolverOpts } from '../types'; 2 | import { createFetcher, isImageURI, resolveURI } from '../utils'; 3 | 4 | export default class URI { 5 | async getMetadata(uri: string, options?: AvatarResolverOpts) { 6 | // Create a configured fetch instance for this request 7 | const fetch = createFetcher({ 8 | ttl: options?.cache, 9 | agents: options?.agents, 10 | allowPrivateIPs: options?.allowPrivateIPs, 11 | }); 12 | 13 | const { uri: resolvedURI, isOnChain } = resolveURI(uri, options); 14 | if (isOnChain) { 15 | return resolvedURI; 16 | } 17 | 18 | if (options?.urlDenyList?.includes(new URL(resolvedURI).hostname)) { 19 | return { image: null }; 20 | } 21 | 22 | // check if resolvedURI is an image, if it is return the url 23 | const isImage = await isImageURI(resolvedURI); 24 | if (isImage) { 25 | return { image: resolvedURI }; 26 | } 27 | 28 | // if resolvedURI is not an image, try retrieve the data. 29 | const response = await fetch.get(encodeURI(resolvedURI)); 30 | return await response?.data; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ethereum Name Service 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /example/node.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { StaticJsonRpcProvider } = require('@ethersproject/providers'); 3 | const { AvatarResolver, utils: avtUtils } = require('../dist/index'); 4 | const { JSDOM } = require('jsdom'); 5 | 6 | const jsdom = new JSDOM().window; 7 | const ensName = process.argv[2]; 8 | if (!ensName) { 9 | console.log( 10 | 'Please provide an ENS name as an argument (> node demo.js nick.eth)' 11 | ); 12 | process.exit(1); 13 | } 14 | const IPFS = 'https://cf-ipfs.com'; 15 | const provider = new StaticJsonRpcProvider( 16 | `https://mainnet.infura.io/v3/${process.env.INFURA_KEY}` 17 | ); 18 | const avt = new AvatarResolver(provider, { 19 | ipfs: IPFS, 20 | apiKey: { opensea: process.env.OPENSEA_KEY }, 21 | }); 22 | avt 23 | .getMetadata(ensName) 24 | .then(metadata => { 25 | if (!metadata) { 26 | console.log('Avatar not found!'); 27 | return; 28 | } 29 | const avatar = avtUtils.getImageURI({ 30 | metadata, 31 | gateways: { 32 | ipfs: IPFS, 33 | }, 34 | jsdomWindow: jsdom, 35 | }); 36 | console.log('avatar: ', avatar); 37 | }) 38 | .catch(console.log); 39 | 40 | try { 41 | avt 42 | .getHeader(ensName, { jsdomWindow: jsdom }) 43 | .then(header => { 44 | console.log('header: ', header); 45 | }) 46 | .catch(console.log); 47 | } catch {} 48 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import tsPlugin from '@rollup/plugin-typescript'; 4 | 5 | export default { 6 | input: 'src/index.ts', 7 | output: [ 8 | { 9 | format: 'cjs', 10 | file: './dist/index.js', 11 | }, 12 | { 13 | format: 'es', 14 | dir: 'dist', 15 | preserveModules: true, 16 | preserveModulesRoot: 'src', 17 | entryFileNames: chunk => { 18 | return `${chunk.name === 'index' ? 'index.esm' : chunk.name}.js`; 19 | }, 20 | }, 21 | ], 22 | plugins: [ 23 | tsPlugin({ 24 | declarationDir: './dist', 25 | sourceMap: false, 26 | }), 27 | removeDist(), 28 | ], 29 | }; 30 | 31 | function removeDist(options = {}) { 32 | const { hook = 'buildStart', buildDir = 'dist' } = options; 33 | 34 | return { 35 | name: 'remove-dist', 36 | [hook]: async () => { 37 | const folderPath = path.join(process.cwd(), buildDir); 38 | try { 39 | fs.accessSync(folderPath, fs.F_OK); 40 | fs.rmSync(folderPath, { recursive: true, force: true }); 41 | } catch (err) { 42 | if (err.code !== 'ENOENT') throw err; 43 | try { 44 | fs.rmSync(folderPath, { recursive: true, force: true }); 45 | } catch (innerErr) { 46 | console.log('An error occurred while removing the folder:', innerErr); 47 | } 48 | } 49 | }, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/utils/parseNFT.ts: -------------------------------------------------------------------------------- 1 | import { assert } from './assert'; 2 | import { BaseError } from './error'; 3 | 4 | export interface NFTURIParsingError {} 5 | export class NFTURIParsingError extends BaseError {} 6 | 7 | export function parseNFT(uri: string, seperator: string = '/') { 8 | // parse valid nft spec (CAIP-22/CAIP-29) 9 | // @see: https://github.com/ChainAgnostic/CAIPs/tree/master/CAIPs 10 | try { 11 | assert(uri, 'parameter URI cannot be empty'); 12 | 13 | if (uri.startsWith('did:nft:')) { 14 | // convert DID to CAIP 15 | uri = uri.replace('did:nft:', '').replace(/_/g, '/'); 16 | } 17 | 18 | const [reference, asset_namespace, tokenID] = uri.split(seperator); 19 | const [eip_namespace, chainID] = reference.split(':'); 20 | const [erc_namespace, contractAddress] = asset_namespace.split(':'); 21 | 22 | assert( 23 | eip_namespace && eip_namespace.toLowerCase() === 'eip155', 24 | 'Only EIP-155 is supported' 25 | ); 26 | assert(chainID, 'chainID not found'); 27 | assert(contractAddress, 'contractAddress not found'); 28 | assert(erc_namespace, 'erc namespace not found'); 29 | assert(tokenID, 'tokenID not found'); 30 | 31 | return { 32 | chainID: Number(chainID), 33 | namespace: erc_namespace.toLowerCase(), 34 | contractAddress, 35 | tokenID, 36 | }; 37 | } catch (error) { 38 | throw new NFTURIParsingError(`${error as string} - ${uri}`); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types", "test"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "target": "ES2018", 8 | "importHelpers": true, 9 | "downlevelIteration": true, 10 | // output .d.ts declaration files for consumers 11 | "declaration": true, 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | // stricter type-checking for stronger correctness. Recommended by TS 17 | "strict": true, 18 | // linter checks for common issues 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 22 | "noUnusedLocals": false, 23 | "noUnusedParameters": true, 24 | "useUnknownInCatchVariables": false, 25 | // use Node's module resolution algorithm, instead of the legacy TS one 26 | "moduleResolution": "node", 27 | // transpile JSX to React.createElement 28 | "jsx": "react", 29 | // interop between ESM and CJS modules. Recommended by TS 30 | "esModuleInterop": true, 31 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 32 | "skipLibCheck": true, 33 | // error out if import and file system have a casing mismatch. Recommended by TS 34 | "forceConsistentCasingInFileNames": true, 35 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 36 | "noEmit": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/specs/erc721.ts: -------------------------------------------------------------------------------- 1 | import { Contract, Provider } from 'ethers'; 2 | import { Buffer } from 'buffer/'; 3 | import { createFetcher, resolveURI } from '../utils'; 4 | import { AvatarResolverOpts } from '../types'; 5 | 6 | const abi = [ 7 | 'function tokenURI(uint256 tokenId) external view returns (string memory)', 8 | 'function ownerOf(uint256 tokenId) public view returns (address)', 9 | ]; 10 | 11 | export default class ERC721 { 12 | async getMetadata( 13 | provider: Provider, 14 | ownerAddress: string | undefined | null, 15 | contractAddress: string, 16 | tokenID: string, 17 | options?: AvatarResolverOpts 18 | ) { 19 | // Create a configured fetch instance for this request 20 | const fetch = createFetcher({ 21 | ttl: options?.cache, 22 | agents: options?.agents, 23 | allowPrivateIPs: options?.allowPrivateIPs, 24 | }); 25 | 26 | const contract = new Contract(contractAddress, abi, provider); 27 | const [tokenURI, owner] = await Promise.all([ 28 | contract.tokenURI(tokenID), 29 | ownerAddress && contract.ownerOf(tokenID), 30 | ]); 31 | // if user has valid address and if owner of the nft matches with the owner address 32 | const isOwner = !!( 33 | ownerAddress && owner.toLowerCase() === ownerAddress.toLowerCase() 34 | ); 35 | 36 | const { uri: resolvedURI, isOnChain, isEncoded } = resolveURI( 37 | tokenURI, 38 | options 39 | ); 40 | let _resolvedUri = resolvedURI; 41 | if (isOnChain) { 42 | if (isEncoded) { 43 | _resolvedUri = Buffer.from( 44 | resolvedURI.replace('data:application/json;base64,', ''), 45 | 'base64' 46 | ).toString(); 47 | } 48 | const metadata = JSON.parse(_resolvedUri); 49 | return { ...metadata, is_owner: isOwner }; 50 | } 51 | const response = await fetch.get( 52 | encodeURI(resolvedURI.replace(/(?:0x)?{id}/, tokenID)) 53 | ); 54 | const metadata = await response?.data; 55 | return { ...metadata, is_owner: isOwner }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/specs/erc1155.ts: -------------------------------------------------------------------------------- 1 | import { Contract, Provider } from 'ethers'; 2 | import { Buffer } from 'buffer/'; 3 | import { createFetcher, resolveURI } from '../utils'; 4 | import { AvatarResolverOpts } from '../types'; 5 | 6 | const abi = [ 7 | 'function uri(uint256 _id) public view returns (string memory)', 8 | 'function balanceOf(address account, uint256 id) public view returns (uint256)', 9 | ]; 10 | 11 | function getMarketplaceAPIKey(uri: string, options?: AvatarResolverOpts) { 12 | if ( 13 | uri.startsWith('https://api.opensea.io/') && 14 | options?.apiKey?.['opensea'] 15 | ) { 16 | return { 'X-API-KEY': options.apiKey.opensea }; 17 | } 18 | return false; 19 | } 20 | 21 | export default class ERC1155 { 22 | async getMetadata( 23 | provider: Provider, 24 | ownerAddress: string | undefined | null, 25 | contractAddress: string, 26 | tokenID: string, 27 | options?: AvatarResolverOpts 28 | ) { 29 | // Create a configured fetch instance for this request 30 | const fetch = createFetcher({ 31 | ttl: options?.cache, 32 | agents: options?.agents, 33 | allowPrivateIPs: options?.allowPrivateIPs, 34 | }); 35 | 36 | // exclude opensea api which does not follow erc1155 spec 37 | const tokenIDHex = !tokenID.startsWith('https://api.opensea.io/') 38 | ? tokenID.replace('0x', '').padStart(64, '0') 39 | : tokenID; 40 | const contract = new Contract(contractAddress, abi, provider); 41 | const [tokenURI, balance] = await Promise.all([ 42 | contract.uri(tokenID), 43 | ownerAddress ? contract.balanceOf(ownerAddress, tokenID) : BigInt(0), 44 | ]); 45 | // if user has valid address and if token balance of given address is greater than 0 46 | const isOwner = !!(ownerAddress && balance > BigInt(0)); 47 | 48 | const { uri: resolvedURI, isOnChain, isEncoded } = resolveURI( 49 | tokenURI, 50 | options 51 | ); 52 | let _resolvedUri = resolvedURI; 53 | if (isOnChain) { 54 | if (isEncoded) { 55 | _resolvedUri = Buffer.from( 56 | resolvedURI.replace('data:application/json;base64,', ''), 57 | 'base64' 58 | ).toString(); 59 | } 60 | const metadata = JSON.parse(_resolvedUri); 61 | return { ...metadata, is_owner: isOwner }; 62 | } 63 | 64 | const marketplaceKey = getMarketplaceAPIKey(resolvedURI, options); 65 | 66 | const response = await fetch.get( 67 | encodeURI(resolvedURI.replace(/(?:0x)?{id}/, tokenIDHex)), 68 | marketplaceKey ? { headers: marketplaceKey } : {} 69 | ); 70 | const metadata = await response?.data; 71 | return { ...metadata, is_owner: isOwner }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/utils/getImageURI.ts: -------------------------------------------------------------------------------- 1 | import isSVG from 'is-svg'; 2 | 3 | import { ImageURIOpts } from '../types'; 4 | import { assert } from './assert'; 5 | import { resolveURI } from './resolveURI'; 6 | import { sanitizeSVG } from './sanitize'; 7 | 8 | function isSVGDataUri(uri: string): boolean { 9 | const svgDataUriPrefix = 'data:image/svg+xml'; 10 | return uri.startsWith(svgDataUriPrefix); 11 | } 12 | 13 | function isImageDataUri(uri: string): boolean { 14 | const imageFormats = ['jpeg', 'png', 'gif', 'bmp', 'webp']; 15 | const dataUriPattern = /^data:image\/([a-zA-Z0-9]+)(?:;base64)?,/; 16 | 17 | const match = uri.match(dataUriPattern); 18 | if (!match || match.length < 2) { 19 | return false; 20 | } 21 | 22 | const format = match[1].toLowerCase(); 23 | return imageFormats.includes(format); 24 | } 25 | 26 | export function convertToRawSVG(input: string): string | null { 27 | const base64Prefix = 'data:image/svg+xml;base64,'; 28 | const encodedPrefix = 'data:image/svg+xml,'; 29 | 30 | if (input.startsWith(base64Prefix)) { 31 | const base64Data = input.substring(base64Prefix.length); 32 | try { 33 | return Buffer.from(base64Data, 'base64').toString(); 34 | } catch (error) { 35 | console.error('Invalid base64 encoded SVG'); 36 | return null; 37 | } 38 | } else if (input.startsWith(encodedPrefix)) { 39 | const encodedData = input.substring(encodedPrefix.length); 40 | try { 41 | return decodeURIComponent(encodedData); 42 | } catch (error) { 43 | console.error('Invalid URL encoded SVG'); 44 | return null; 45 | } 46 | } else { 47 | // The input is already a raw SVG (or another format if not used with isSVGDataUri) 48 | return input; 49 | } 50 | } 51 | 52 | function _sanitize(data: string, jsDomWindow?: any): Buffer { 53 | // Use platform-specific sanitization (DOMPurify or sanitize-html) 54 | const cleanSVG = sanitizeSVG(data, jsDomWindow); 55 | return Buffer.from(cleanSVG); 56 | } 57 | 58 | export function getImageURI({ 59 | metadata, 60 | customGateway, 61 | gateways, 62 | jsdomWindow, 63 | urlDenyList, 64 | }: ImageURIOpts) { 65 | // retrieves image uri from metadata, if image is onchain then convert to base64 66 | const { image, image_url, image_data } = metadata; 67 | 68 | const _image = image || image_url || image_data; 69 | assert(_image, 'Image is not available'); 70 | const { uri: parsedURI } = resolveURI(_image, gateways, customGateway); 71 | 72 | if (isSVG(parsedURI) || isSVGDataUri(parsedURI)) { 73 | // svg - image_data 74 | const rawSVG = convertToRawSVG(parsedURI)?.replace( 75 | /\s*(<[^>]+>)\s*/g, 76 | '$1' 77 | ); 78 | if (!rawSVG) return null; 79 | 80 | const data = _sanitize(rawSVG, jsdomWindow); 81 | return `data:image/svg+xml;base64,${data.toString('base64')}`; 82 | } 83 | 84 | if (isImageDataUri(parsedURI) || parsedURI.startsWith('http')) { 85 | if (urlDenyList?.includes(new URL(parsedURI).hostname)) return null; 86 | return parsedURI; 87 | } 88 | 89 | return null; 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy ESBuild site to Pages 2 | 3 | on: 4 | # Runs on pushes targeting the default branch 5 | push: 6 | branches: ["main"] 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 18 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 19 | concurrency: 20 | group: "pages" 21 | cancel-in-progress: false 22 | 23 | env: 24 | BUILD_PATH: "." # default value when not using subfolders 25 | 26 | jobs: 27 | build: 28 | name: Build 29 | runs-on: ubuntu-latest 30 | env: 31 | INFURA_KEY: ${{ secrets.INFURA_KEY }} 32 | OPENSEA_KEY: ${{ secrets.OPENSEA_KEY }} 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v3 36 | - name: Detect package manager 37 | id: detect-package-manager 38 | run: | 39 | if [ -f "${{ github.workspace }}/yarn.lock" ]; then 40 | echo "manager=yarn" >> $GITHUB_OUTPUT 41 | echo "command=install" >> $GITHUB_OUTPUT 42 | echo "runner=yarn" >> $GITHUB_OUTPUT 43 | exit 0 44 | elif [ -f "${{ github.workspace }}/package.json" ]; then 45 | echo "manager=npm" >> $GITHUB_OUTPUT 46 | echo "command=ci" >> $GITHUB_OUTPUT 47 | echo "runner=npx --no-install" >> $GITHUB_OUTPUT 48 | exit 0 49 | else 50 | echo "Unable to determine package manager" 51 | exit 1 52 | fi 53 | - name: Setup Node 54 | uses: actions/setup-node@v3 55 | with: 56 | node-version: "18" 57 | cache: ${{ steps.detect-package-manager.outputs.manager }} 58 | - name: Setup yarn 59 | run: npm install -g yarn 60 | - name: Setup Pages 61 | id: pages 62 | uses: actions/configure-pages@v3 63 | - name: Install dependencies 64 | run: ${{ steps.detect-package-manager.outputs.manager }} ${{ steps.detect-package-manager.outputs.command }} 65 | working-directory: ${{ env.BUILD_PATH }} 66 | - name: Build with esbuild 67 | run: | 68 | ${{ steps.detect-package-manager.outputs.runner }} build && ${{ steps.detect-package-manager.outputs.runner }} build:demo 69 | working-directory: ${{ env.BUILD_PATH }} 70 | - name: Upload artifact 71 | uses: actions/upload-pages-artifact@v1 72 | with: 73 | path: ${{ env.BUILD_PATH }} 74 | 75 | deploy: 76 | environment: 77 | name: github-pages 78 | url: ${{ steps.deployment.outputs.page_url }} 79 | needs: build 80 | runs-on: ubuntu-latest 81 | name: Deploy 82 | steps: 83 | - name: Deploy to GitHub Pages 84 | id: deployment 85 | uses: actions/deploy-pages@v2 86 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ENS Avatar Resolver Library Demo 7 | 8 | 9 |
10 |
11 |
12 |
13 |

Query ENS Avatar

14 |
15 | 22 |
23 | 31 |
32 | (type ENS name and hit the enter button) 33 |
34 |
35 |
36 |
37 |
38 |
39 | 40 | 107 | 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.4", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "module": "dist/index.esm.js", 6 | "typings": "dist/index.d.ts", 7 | "sideEffects": false, 8 | "files": [ 9 | "dist", 10 | "src" 11 | ], 12 | "engines": { 13 | "node": ">=18" 14 | }, 15 | "scripts": { 16 | "start": "rollup -c --watch", 17 | "build": "rollup -c", 18 | "build:demo": "node ./example/build.js", 19 | "test": "tsdx test", 20 | "lint": "tsdx lint", 21 | "size": "size-limit" 22 | }, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "tsdx lint src test" 26 | } 27 | }, 28 | "prettier": { 29 | "printWidth": 80, 30 | "semi": true, 31 | "singleQuote": true, 32 | "trailingComma": "es5" 33 | }, 34 | "jest": { 35 | "testTimeout": 20000, 36 | "transformIgnorePatterns": [ 37 | "node_modules/(?!(axios|axios-cache-interceptor))" 38 | ], 39 | "moduleNameMapper": { 40 | "^axios$": "axios/dist/node/axios.cjs" 41 | }, 42 | "setupFilesAfterEnv": [ 43 | "/test/setup.js" 44 | ] 45 | }, 46 | "name": "@ensdomains/ens-avatar", 47 | "author": "Muhammed Tanrıkulu ", 48 | "size-limit": [ 49 | { 50 | "path": "dist/index.js", 51 | "limit": "240 KB", 52 | "ignore": [ 53 | "jsdom", 54 | "http", 55 | "https", 56 | "ssrf-req-filter" 57 | ] 58 | }, 59 | { 60 | "path": "dist/index.esm.js", 61 | "limit": "150 KB", 62 | "ignore": [ 63 | "jsdom", 64 | "http", 65 | "https", 66 | "ssrf-req-filter" 67 | ] 68 | } 69 | ], 70 | "devDependencies": { 71 | "@rollup/plugin-typescript": "^11.1.5", 72 | "@size-limit/preset-small-lib": "^11.0.1", 73 | "@types/dompurify": "^3.0.5", 74 | "@types/jsdom": "^21.1.6", 75 | "@types/moxios": "^0.4.17", 76 | "@types/sanitize-html": "^2.16.0", 77 | "@types/url-join": "^4.0.1", 78 | "@typescript-eslint/eslint-plugin": "^4.0.0", 79 | "@typescript-eslint/parser": "^4.0.0", 80 | "babel-eslint": "^10.1.0", 81 | "dotenv": "^16.3.1", 82 | "esbuild": "^0.14.21", 83 | "eslint-config-react-app": "6", 84 | "eslint-plugin-import": "^2.32.0", 85 | "moxios": "^0.4.0", 86 | "nock": "^13.2.2", 87 | "rollup": "^4.9.1", 88 | "sanitize-html": "^2.17.0", 89 | "size-limit": "^11.0.1", 90 | "tsdx": "^0.14.1", 91 | "typescript": "^4.5.5", 92 | "undici": "^7.16.0" 93 | }, 94 | "dependencies": { 95 | "@ethersproject/contracts": "^5.7.0", 96 | "@ethersproject/providers": "^5.7.0", 97 | "assert": "^2.1.0", 98 | "axios": "^1.12.2", 99 | "axios-cache-interceptor": "^1.8.3", 100 | "buffer": "^6.0.3", 101 | "dompurify": "^3.0.6", 102 | "ethers": "6.12.0", 103 | "is-svg": "^4.3.2", 104 | "multiformats": "^9.6.2", 105 | "ssrf-req-filter": "^1.1.1", 106 | "url-join": "^4.0.1" 107 | }, 108 | "volta": { 109 | "node": "18.14.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { Provider } from 'ethers'; 2 | 3 | export interface Spec { 4 | getMetadata: ( 5 | provider: Provider, 6 | ownerAddress: string | undefined | null, 7 | contractAddress: string, 8 | tokenID: string, 9 | options?: AvatarResolverOpts 10 | ) => Promise; 11 | } 12 | 13 | export type MARKETPLACES = 'opensea' | 'coinbase' | 'looksrare' | 'x2y2'; 14 | export type MarketplaceAPIKey = Partial< 15 | { 16 | [key in MARKETPLACES]: string; 17 | } 18 | >; 19 | 20 | /** 21 | * Custom HTTP/HTTPS agents for Node.js environments 22 | * 23 | * SECURITY NOTE: When you provide custom agents, ens-avatar will use them as-is 24 | * without applying SSRF (Server-Side Request Forgery) protection. You are responsible 25 | * for ensuring your custom agents have appropriate security measures. 26 | * 27 | * If no custom agents are provided, ens-avatar creates default agents with built-in 28 | * SSRF protection that blocks requests to private IP addresses (localhost, 10.x.x.x, 29 | * 192.168.x.x, etc.) unless allowPrivateIPs is set to true. 30 | */ 31 | export interface AxiosAgents { 32 | httpAgent?: Function; 33 | httpsAgent?: Function; 34 | } 35 | 36 | export type MediaKey = 'avatar' | 'header' | 'banner'; 37 | 38 | /** 39 | * Configuration options for AvatarResolver 40 | * 41 | * SSRF PROTECTION: 42 | * By default, ens-avatar protects against Server-Side Request Forgery (SSRF) attacks 43 | * by blocking requests to private IP addresses. This behavior depends on your configuration: 44 | * 45 | * 1. No custom agents (default): SSRF protection enabled - blocks localhost, 10.x.x.x, etc. 46 | * 2. Custom agents provided: Uses your agents as-is - YOU are responsible for SSRF protection 47 | * 3. allowPrivateIPs: true: Disables SSRF protection - only for local development 48 | */ 49 | export interface AvatarResolverOpts { 50 | /** Cache time-to-live in seconds */ 51 | cache?: number; 52 | /** Custom IPFS gateway URL */ 53 | ipfs?: string; 54 | /** Custom Arweave gateway URL */ 55 | arweave?: string; 56 | /** API keys for NFT marketplaces (OpenSea, etc.) */ 57 | apiKey?: MarketplaceAPIKey; 58 | /** List of hostnames to block (in addition to SSRF protection) */ 59 | urlDenyList?: string[]; 60 | /** 61 | * Custom HTTP/HTTPS agents for Node.js 62 | * WARNING: When provided, SSRF protection is NOT applied to your custom agents. 63 | * You are responsible for securing your agents against SSRF attacks. 64 | */ 65 | agents?: AxiosAgents; 66 | /** Maximum response content length in bytes */ 67 | maxContentLength?: number; 68 | /** 69 | * Allow requests to private IP addresses (localhost, 127.0.0.1, 10.x.x.x, 192.168.x.x, etc.) 70 | * 71 | * WARNING: Only use this for local development (e.g., local IPFS nodes, test servers). 72 | * NEVER enable this in production as it disables SSRF protection. 73 | * 74 | * This flag is ignored if you provide custom agents (you control security in that case). 75 | * 76 | * @default false 77 | */ 78 | allowPrivateIPs?: boolean; 79 | } 80 | 81 | export interface AvatarRequestOpts { 82 | jsdomWindow?: any; 83 | } 84 | 85 | export interface HeaderRequestOpts { 86 | jsdomWindow?: any; 87 | mediaKey?: Exclude; 88 | } 89 | 90 | export type Gateways = { 91 | ipfs?: string; 92 | arweave?: string; 93 | }; 94 | 95 | export interface ImageURIOpts { 96 | metadata: any; 97 | customGateway?: string; 98 | gateways?: Gateways; 99 | jsdomWindow?: any; 100 | urlDenyList?: string[]; 101 | } 102 | -------------------------------------------------------------------------------- /src/utils/resolveURI.ts: -------------------------------------------------------------------------------- 1 | import urlJoin from 'url-join'; 2 | 3 | import { Gateways } from '../types'; 4 | import { isCID } from './isCID'; 5 | import { IMAGE_SIGNATURES } from './isImageURI'; 6 | 7 | const IPFS_SUBPATH = '/ipfs/'; 8 | const IPNS_SUBPATH = '/ipns/'; 9 | const networkRegex = /(?ipfs:\/|ipns:\/|ar:\/)?(?\/)?(?ipfs\/|ipns\/)?(?[\w\-.]+)(?\/.*)?/; 10 | const base64Regex = /^data:([a-zA-Z\-/+]*);base64,([^"].*)/; 11 | const dataURIRegex = /^data:([a-zA-Z\-/+]*)?(;[a-zA-Z0-9].*?)?(,)/; 12 | const JSON_MIMETYPE = 'data:application/json;'; 13 | 14 | function _getImageMimeType(uri: string) { 15 | const base64Data = uri.replace(base64Regex, '$2'); 16 | const buffer = Buffer.from(base64Data, 'base64'); 17 | 18 | if (buffer.length < 12) { 19 | return null; // not enough data to determine the type 20 | } 21 | 22 | // get the hex representation of the first 12 bytes 23 | const hex = buffer.toString('hex', 0, 12).toUpperCase(); 24 | 25 | // check against magic number mapping 26 | for (const [magicNumber, mimeType] of Object.entries({ 27 | ...IMAGE_SIGNATURES, 28 | '52494646': 'special_webp_check', 29 | '3C737667': 'image/svg+xml', 30 | })) { 31 | if (hex.startsWith(magicNumber.toUpperCase())) { 32 | if (mimeType === 'special_webp_check') { 33 | return hex.slice(8, 12) === '5745' ? 'image/webp' : null; 34 | } 35 | return mimeType; 36 | } 37 | } 38 | 39 | return null; 40 | } 41 | 42 | function _isValidBase64(uri: string) { 43 | if (typeof uri !== 'string') { 44 | return false; 45 | } 46 | 47 | // check if the string matches the Base64 pattern 48 | if (!base64Regex.test(uri)) { 49 | return false; 50 | } 51 | 52 | const [header, str] = uri.split('base64,'); 53 | if (header != JSON_MIMETYPE) { 54 | const mimeType = _getImageMimeType(uri); 55 | if (!mimeType || !header.includes(mimeType)) { 56 | return false; 57 | } 58 | } 59 | 60 | // length must be multiple of 4 61 | if (str.length % 4 !== 0) { 62 | return false; 63 | } 64 | 65 | try { 66 | // try to encode/decode the string, to see if matches 67 | const buffer = Buffer.from(str, 'base64'); 68 | const encoded = buffer.toString('base64'); 69 | return encoded === str; 70 | } catch (e) { 71 | return false; 72 | } 73 | } 74 | 75 | function _replaceGateway(uri: string, source: string, target?: string) { 76 | if (uri.startsWith(source) && target) { 77 | try { 78 | let _uri = new URL(uri); 79 | _uri.hostname = new URL(target).hostname; 80 | return _uri.toString(); 81 | } catch (_error) { 82 | return uri; 83 | } 84 | } 85 | return uri; 86 | } 87 | 88 | export function resolveURI( 89 | uri: string, 90 | gateways?: Gateways, 91 | customGateway?: string 92 | ): { uri: string; isOnChain: boolean; isEncoded: boolean } { 93 | // resolves uri based on its' protocol 94 | const isEncoded = _isValidBase64(uri); 95 | if (isEncoded || uri.startsWith('http')) { 96 | uri = _replaceGateway(uri, 'https://ipfs.io/', gateways?.ipfs); 97 | uri = _replaceGateway(uri, 'https://arweave.net/', gateways?.arweave); 98 | return { uri, isOnChain: isEncoded, isEncoded }; 99 | } 100 | 101 | // customGateway option will be depreciated after 2 more version bump 102 | if (!gateways?.ipfs && !!customGateway) { 103 | console.warn( 104 | "'customGateway' option depreciated, please use 'gateways: {ipfs: YOUR_IPFS_GATEWAY }' instead" 105 | ); 106 | gateways = { ...gateways, ipfs: customGateway }; 107 | } 108 | 109 | const ipfsGateway = gateways?.ipfs || 'https://ipfs.io'; 110 | const arGateway = gateways?.arweave || 'https://arweave.net'; 111 | const networkRegexResult = uri.match(networkRegex); 112 | const { protocol, subpath, target, subtarget = '' } = 113 | networkRegexResult?.groups || {}; 114 | if ((protocol === 'ipns:/' || subpath === 'ipns/') && target) { 115 | return { 116 | uri: urlJoin(ipfsGateway, IPNS_SUBPATH, target, subtarget), 117 | isOnChain: false, 118 | isEncoded: false, 119 | }; 120 | } else if (isCID(target)) { 121 | // Assume that it's a regular IPFS CID and not an IPNS key 122 | return { 123 | uri: urlJoin(ipfsGateway, IPFS_SUBPATH, target, subtarget), 124 | isOnChain: false, 125 | isEncoded: false, 126 | }; 127 | } else if (protocol === 'ar:/' && target) { 128 | return { 129 | uri: urlJoin(arGateway, target, subtarget || ''), 130 | isOnChain: false, 131 | isEncoded: false, 132 | }; 133 | } else { 134 | // we may want to throw error here 135 | return { 136 | uri: uri.replace(dataURIRegex, ''), 137 | isOnChain: true, 138 | isEncoded: false, 139 | }; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { JsonRpcProvider } from 'ethers'; 2 | import ERC1155 from './specs/erc1155'; 3 | import ERC721 from './specs/erc721'; 4 | import URI from './specs/uri'; 5 | import * as utils from './utils'; 6 | import { 7 | BaseError, 8 | getImageURI, 9 | handleSettled, 10 | isImageURI, 11 | parseNFT, 12 | } from './utils'; 13 | import { 14 | AvatarRequestOpts, 15 | AvatarResolverOpts, 16 | HeaderRequestOpts, 17 | MediaKey, 18 | Spec, 19 | } from './types'; 20 | 21 | export const specs: { [key: string]: new () => Spec } = Object.freeze({ 22 | erc721: ERC721, 23 | erc1155: ERC1155, 24 | }); 25 | 26 | export interface UnsupportedNamespace {} 27 | export class UnsupportedNamespace extends BaseError {} 28 | 29 | export interface UnsupportedMediaKey {} 30 | export class UnsupportedMediaKey extends BaseError {} 31 | 32 | export interface AvatarResolver { 33 | provider: JsonRpcProvider; 34 | options?: AvatarResolverOpts; 35 | getAvatar(ens: string, data: AvatarRequestOpts): Promise; 36 | getHeader(ens: string, data: HeaderRequestOpts): Promise; 37 | getMetadata(ens: string, key?: MediaKey): Promise; 38 | } 39 | 40 | export class AvatarResolver implements AvatarResolver { 41 | constructor(provider: JsonRpcProvider, options?: AvatarResolverOpts) { 42 | this.provider = provider; 43 | this.options = options; 44 | // Note: fetch instance configuration is now handled in createFetcher 45 | // The global fetch instance already has proper configuration 46 | // This constructor no longer needs to modify the fetch instance 47 | // as options are passed directly to API methods that create their own instances 48 | } 49 | 50 | async getMetadata(ens: string, key: MediaKey = 'avatar') { 51 | // retrieve registrar address and resolver object from ens name 52 | const [resolvedAddress, resolver] = await handleSettled([ 53 | this.provider.resolveName(ens), 54 | this.provider.getResolver(ens), 55 | ]); 56 | if (!resolver) return null; 57 | 58 | // retrieve 'avatar' text recored from resolver 59 | const mediaURI = await resolver.getText(key); 60 | if (!mediaURI) return null; 61 | 62 | // test case-insensitive in case of uppercase records 63 | if (!/eip155:/i.test(mediaURI)) { 64 | const uriSpec = new URI(); 65 | const metadata = await uriSpec.getMetadata(mediaURI, this.options); 66 | return { uri: ens, ...metadata }; 67 | } 68 | 69 | // parse retrieved avatar uri 70 | const { chainID, namespace, contractAddress, tokenID } = parseNFT(mediaURI); 71 | // detect avatar spec by namespace 72 | const Spec = specs[namespace]; 73 | if (!Spec) 74 | throw new UnsupportedNamespace(`Unsupported namespace: ${namespace}`); 75 | const spec = new Spec(); 76 | 77 | // add meta information of the avatar record 78 | const host_meta = { 79 | chain_id: chainID, 80 | namespace, 81 | contract_address: contractAddress, 82 | token_id: tokenID, 83 | reference_url: `https://opensea.io/assets/${contractAddress}/${tokenID}`, 84 | }; 85 | 86 | // retrieve metadata 87 | const metadata = await spec.getMetadata( 88 | this.provider, 89 | resolvedAddress, 90 | contractAddress, 91 | tokenID, 92 | this.options 93 | ); 94 | return { uri: ens, host_meta, ...metadata }; 95 | } 96 | 97 | async getAvatar( 98 | ens: string, 99 | data?: AvatarRequestOpts 100 | ): Promise { 101 | return this._getMedia(ens, 'avatar', data); 102 | } 103 | 104 | async getHeader( 105 | ens: string, 106 | data?: HeaderRequestOpts 107 | ): Promise { 108 | const mediaKey = data?.mediaKey || 'header'; 109 | if (!['header', 'banner'].includes(mediaKey)) { 110 | throw new UnsupportedMediaKey('Unsupported media key'); 111 | } 112 | return this._getMedia(ens, mediaKey, data); 113 | } 114 | 115 | async _getMedia( 116 | ens: string, 117 | mediaKey: MediaKey = 'avatar', 118 | data?: HeaderRequestOpts 119 | ) { 120 | const metadata = await this.getMetadata(ens, mediaKey); 121 | if (!metadata) return null; 122 | const imageURI = getImageURI({ 123 | metadata, 124 | gateways: { 125 | ipfs: this.options?.ipfs, 126 | arweave: this.options?.arweave, 127 | }, 128 | jsdomWindow: data?.jsdomWindow, 129 | urlDenyList: this.options?.urlDenyList, 130 | }); 131 | if ( 132 | // do check only NFTs since raw uri has this check built-in 133 | metadata.hasOwnProperty('host_meta') && 134 | imageURI?.startsWith('http') 135 | ) { 136 | const isImage = await isImageURI(imageURI); 137 | return isImage ? imageURI : null; 138 | } 139 | return imageURI; 140 | } 141 | } 142 | 143 | export { utils }; 144 | -------------------------------------------------------------------------------- /example/browser.js: -------------------------------------------------------------------------------- 1 | const { StaticJsonRpcProvider } = require('@ethersproject/providers'); 2 | const { AvatarResolver, utils: avtUtils } = require('../dist/index'); 3 | 4 | const ensNames = [ 5 | 'achal.eth', 6 | 'alisha.eth', 7 | 'jefflau.eth', 8 | 'leontalbert.eth', 9 | 'matoken.eth', 10 | 'nick.eth', 11 | 'ricmoo.eth', 12 | 'tanrikulu.eth', 13 | 'taytems.eth', 14 | 'validator.eth', 15 | 'brantly.eth', 16 | 'coinbase.eth', 17 | 'she256.eth', 18 | 'cory.eth', 19 | 'avsa.eth', 20 | 'lefteris.eth', 21 | 'rainbowwallet.eth', 22 | 'fireeyesdao.eth', 23 | 'griff.eth', 24 | ]; 25 | 26 | const notFoundImage = 27 | 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMzAwcHgiIGhlaWdodD0iMzAwcHgiIHZpZXdCb3g9IjAgMCAzMDAgMzAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPHRpdGxlPkdyb3VwPC90aXRsZT4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPgogICAgICAgIDxnIGlkPSJHcm91cCI+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUiIGZpbGw9IiMwMDAwMDAiIHg9IjAiIHk9IjAiIHdpZHRoPSIzMDAiIGhlaWdodD0iMzAwIj48L3JlY3Q+CiAgICAgICAgICAgIDx0ZXh0IGlkPSJDb3VsZC1ub3QtbG9hZC06KCIgZm9udC1mYW1pbHk9IlBsdXNKYWthcnRhU2Fucy1Cb2xkLCBQbHVzIEpha2FydGEgU2FucyIgZm9udC1zaXplPSIzMCIgZm9udC13ZWlnaHQ9ImJvbGQiIGZpbGw9IiNGRkZGRkYiPgogICAgICAgICAgICAgICAgPHRzcGFuIHg9IjEwNi4wMDUiIHk9IjEyNCI+Q291bGQgPC90c3Bhbj4KICAgICAgICAgICAgICAgIDx0c3BhbiB4PSIxMjUuMjgiIHk9IjE2MiI+bm90IDwvdHNwYW4+CiAgICAgICAgICAgICAgICA8dHNwYW4geD0iMTAzLjYzNSIgeT0iMjAwIj5sb2FkIDooPC90c3Bhbj4KICAgICAgICAgICAgPC90ZXh0PgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+'; 28 | const provider = new StaticJsonRpcProvider( 29 | `https://mainnet.infura.io/v3/${process.env.INFURA_KEY}` 30 | ); 31 | const avt = new AvatarResolver(provider, { 32 | apiKey: { opensea: process.env.OPENSEA_KEY }, 33 | }); 34 | for (let ens of ensNames) { 35 | avt 36 | .getMetadata(ens) 37 | .then(metadata => { 38 | const avatar = avtUtils.getImageURI({ 39 | metadata, 40 | gateways: { ipfs: 'https://ipfs.io' }, 41 | }); 42 | createImage(ens, avatar); 43 | }) 44 | .catch(error => { 45 | console.warn(error); 46 | createImage(ens); 47 | }); 48 | } 49 | 50 | function createImage(ens, avatarUri = notFoundImage) { 51 | const elem = document.createElement('img'); 52 | elem.setAttribute('src', avatarUri); 53 | elem.setAttribute('height', '300'); 54 | elem.setAttribute('width', '300'); 55 | elem.setAttribute('alt', ens); 56 | elem.style = 'border-radius: 5px;'; 57 | elem.style.opacity = '0'; 58 | elem.addEventListener('load', fadeImg); 59 | document.getElementById('avatars').appendChild(elem); 60 | } 61 | 62 | function fadeImg() { 63 | this.style.transition = 'opacity 2s'; 64 | this.style.opacity = '1'; 65 | } 66 | 67 | function setImage(ens, avatarUri = notFoundImage, warn = false, headerUri) { 68 | const elem = document.getElementById('queryImage'); 69 | const headerContainer = document.getElementById('headerContainer'); 70 | elem.setAttribute('src', avatarUri); 71 | elem.setAttribute('alt', ens); 72 | headerContainer.style.backgroundImage = headerUri ? `url("${headerUri}")` : 'none'; 73 | const warnText = document.getElementById('warnText'); 74 | if (warn) { 75 | if (warnText) return; 76 | const newWarnText = document.createElement('div'); 77 | newWarnText.id = 'warnText'; 78 | newWarnText.textContent = 'The query is not valid'; 79 | newWarnText.style.color = 'red'; 80 | newWarnText.style.lineHeight = '10px'; 81 | elem.setAttribute('height', 290); 82 | elem.parentNode.insertBefore(newWarnText, elem.nextSibling); 83 | } else { 84 | elem.setAttribute('height', 300); 85 | warnText && warnText.remove(); 86 | } 87 | } 88 | 89 | document.getElementById('queryInput').addEventListener('change', event => { 90 | let ens = event.target.value; 91 | ens = ens.toLowerCase().trim(); 92 | 93 | if (ens === 'nevergonnagiveyouup' || ens === 'rickroll') { 94 | setImage( 95 | 'rickroll', 96 | 'http://ipfs.io/ipfs/QmPmU7h1rcZkivDntjvfh8BJB5Yk32ozMjPd12HNMoAZZ8' 97 | ); 98 | return; 99 | } 100 | 101 | if (ens.length < 7 || !ens.endsWith('.eth')) { 102 | setImage( 103 | 'fail', 104 | 'http://ipfs.io/ipfs/QmYVZtV4Xtbqqj6hKojgbLskf5b1rV2wNfpAwgZ2EBuQnD', 105 | true 106 | ); 107 | return; 108 | } 109 | 110 | const elem = document.getElementById('queryImage'); 111 | elem.style.filter = 'blur(5px) grayscale(70%)'; 112 | elem.style.transition = 'filter .5s'; 113 | avt 114 | .getMetadata(ens) 115 | .then(metadata => { 116 | const avatar = avtUtils.getImageURI({ metadata }); 117 | avt.getHeader(ens).then(header => { 118 | setImage(ens, avatar, false, header); 119 | }); 120 | elem.style.filter = 'none'; 121 | }) 122 | .catch(error => { 123 | console.warn(error); 124 | setImage(ens); 125 | elem.style.filter = 'none'; 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | import axios, { Axios, AxiosInstance } from 'axios'; 2 | import { isBrowser, isNode } from './detectPlatform'; 3 | import { AxiosAgents } from '../types'; 4 | 5 | let http: any; 6 | let https: any; 7 | let requestFilterHandler: any; 8 | 9 | // Dynamically import Node.js modules only in Node.js environment 10 | if (isNode) { 11 | http = require('http'); 12 | https = require('https'); 13 | const ssrfFilter = require('ssrf-req-filter'); 14 | requestFilterHandler = ssrfFilter.requestFilterHandler; 15 | } 16 | 17 | /** 18 | * Creates an axios instance with fetch adapter and optional configuration 19 | * 20 | * SSRF PROTECTION BEHAVIOR: 21 | * - If NO custom agents provided: Automatically creates agents with SSRF protection 22 | * that blocks private IPs (localhost, 127.x.x.x, 10.x.x.x, 192.168.x.x, etc.) 23 | * - If custom agents provided: Uses them as-is WITHOUT wrapping or modifying them. 24 | * You are responsible for ensuring your custom agents have appropriate security. 25 | * - If allowPrivateIPs=true: Disables SSRF protection (only for local development) 26 | * 27 | * @param ttl - Cache time-to-live in seconds 28 | * @param agents - Custom HTTP/HTTPS agents (bypasses built-in SSRF protection) 29 | * @param maxContentLength - Maximum response content length in bytes 30 | * @param allowPrivateIPs - Allow private IPs (localhost, 127.0.0.1, etc.) - ONLY for development 31 | * @returns Configured axios instance 32 | */ 33 | export function createFetcher({ 34 | ttl, 35 | agents, 36 | maxContentLength, 37 | allowPrivateIPs, 38 | }: { 39 | ttl?: number; 40 | agents?: AxiosAgents; 41 | maxContentLength?: number; 42 | allowPrivateIPs?: boolean; 43 | } = {}): Axios | AxiosInstance { 44 | const baseConfig: any = { 45 | // Use fetch adapter when available (browser, Cloudflare Workers, Node.js with fetch support) 46 | // Falls back to default adapters (xhr in browser, http in Node.js) if fetch is not available 47 | adapter: typeof globalThis.fetch !== 'undefined' ? 'fetch' : undefined, 48 | proxy: false, 49 | ...(maxContentLength && { maxContentLength }), 50 | }; 51 | 52 | const _fetch = axios.create(baseConfig); 53 | 54 | let fetchInstance: Axios | AxiosInstance = _fetch; 55 | 56 | // Apply caching if TTL is specified 57 | if (ttl && ttl > 0) { 58 | const { setupCache } = require('axios-cache-interceptor'); 59 | fetchInstance = setupCache(_fetch, { 60 | ttl: ttl * 1000, 61 | }); 62 | } 63 | 64 | // Apply agent configuration for Node.js environments only 65 | // Cloudflare Workers don't support HTTP agents (use native fetch with built-in SSRF protection) 66 | if (isNode) { 67 | let finalAgents: AxiosAgents = {}; 68 | 69 | if (agents && Object.values(agents).length) { 70 | // User provided custom agents - use them as-is without wrapping 71 | // They may have their own SSRF protection or specific requirements 72 | finalAgents = agents; 73 | } else if (!allowPrivateIPs && http && https && requestFilterHandler) { 74 | // No custom agents provided - create default agents with SSRF protection 75 | finalAgents = { 76 | httpAgent: requestFilterHandler(new http.Agent()), 77 | httpsAgent: requestFilterHandler(new https.Agent()), 78 | }; 79 | } 80 | // If allowPrivateIPs is true and no custom agents, don't create any agents 81 | // (will use axios defaults which allow all IPs) 82 | 83 | // Apply agents if available 84 | if (Object.values(finalAgents).length) { 85 | fetchInstance.interceptors.request.use(config => { 86 | // Note: In axios 1.x with fetch adapter, use 'dispatcher' for undici agent 87 | // For backward compatibility, we support both httpAgent/httpsAgent and dispatcher 88 | if (finalAgents.httpAgent || finalAgents.httpsAgent) { 89 | // Legacy support - map to dispatcher if available 90 | // @ts-ignore - dispatcher is not in standard axios types but supported by fetch adapter 91 | config.dispatcher = finalAgents.httpAgent || finalAgents.httpsAgent; 92 | } 93 | return config; 94 | }); 95 | } 96 | } 97 | 98 | return fetchInstance; 99 | } 100 | 101 | /** 102 | * @deprecated Use createFetcher instead. This will be removed in a future version. 103 | */ 104 | export function createAgentAdapter(fetch: Axios, agents?: AxiosAgents) { 105 | if (!isBrowser && agents && Object.values(agents || {}).length) { 106 | fetch.interceptors.request.use(config => { 107 | // @ts-ignore 108 | config.dispatcher = agents.httpAgent || agents.httpsAgent; 109 | return config; 110 | }); 111 | } 112 | } 113 | 114 | /** 115 | * @deprecated Use createFetcher instead. This will be removed in a future version. 116 | */ 117 | export function createCacheAdapter(fetch: Axios, ttl: number): AxiosInstance { 118 | const { setupCache } = require('axios-cache-interceptor'); 119 | return setupCache(fetch, { 120 | ttl: ttl * 1000, 121 | }); 122 | } 123 | 124 | // Default fetch instance without any configuration 125 | export const fetch = createFetcher({}); 126 | -------------------------------------------------------------------------------- /src/utils/isImageURI.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { Buffer } from 'buffer/'; 3 | 4 | import { fetch } from './fetch'; 5 | 6 | export const ALLOWED_IMAGE_MIMETYPES = [ 7 | 'application/octet-stream', 8 | 'image/jpeg', 9 | 'image/png', 10 | 'image/gif', 11 | 'image/webp', 12 | 'image/svg+xml', 13 | 'image/bmp', 14 | 'image/avif', 15 | 'image/heic', 16 | 'image/heif', 17 | 'image/jxl', 18 | ]; 19 | 20 | export const IMAGE_SIGNATURES = { 21 | FFD8FF: 'image/jpeg', 22 | '89504E47': 'image/png', 23 | '47494638': 'image/gif', 24 | '424D': 'image/bmp', 25 | FF0A: 'image/jxl', 26 | }; 27 | 28 | const MAX_FILE_SIZE = 300 * 1024 * 1024; // 300 MB 29 | 30 | function isURIEncoded(uri: string): boolean { 31 | try { 32 | return uri !== decodeURIComponent(uri); 33 | } catch { 34 | return false; 35 | } 36 | } 37 | 38 | async function isStreamAnImage(url: string): Promise { 39 | try { 40 | const source = axios.CancelToken.source(); 41 | const response = await fetch.get(url, { 42 | responseType: 'arraybuffer', 43 | headers: { 44 | Range: 'bytes=0-1023', // Download only the first 1024 bytes 45 | }, 46 | cancelToken: source.token, 47 | onDownloadProgress: progressEvent => { 48 | if (progressEvent.loaded > 1024) { 49 | // Cancel the request if more than 1024 bytes have been downloaded 50 | source.cancel('Aborted to prevent downloading the entire file.'); 51 | } 52 | }, 53 | }); 54 | 55 | if (response.headers['content-length']) { 56 | const contentLength = parseInt(response.headers['content-length'], 10); 57 | if (contentLength > MAX_FILE_SIZE) { 58 | console.warn(`isStreamAnImage: File too large ${contentLength} bytes`); 59 | return false; 60 | } 61 | } 62 | 63 | let magicNumbers: string; 64 | // Check the binary signature (magic numbers) of the data 65 | if (response.data instanceof ArrayBuffer) { 66 | magicNumbers = new DataView(response.data).getUint32(0).toString(16); 67 | } else { 68 | if ( 69 | !response.data || 70 | typeof response.data === 'string' || 71 | !('readUInt32BE' in response.data) 72 | ) { 73 | throw 'isStreamAnImage: unsupported data, instance is not BufferLike'; 74 | } 75 | magicNumbers = response.data.readUInt32BE(0).toString(16); 76 | } 77 | 78 | const isBinaryImage = Object.keys(IMAGE_SIGNATURES).some(signature => 79 | magicNumbers.toUpperCase().startsWith(signature) 80 | ); 81 | 82 | // Check for SVG image 83 | const chunkAsString = Buffer.from(response.data).toString(); 84 | const isSvgImage = / { 100 | const encodedURI = isURIEncoded(url) ? url : encodeURI(url); 101 | 102 | try { 103 | const result = await fetch.head(encodedURI); 104 | 105 | if (result.status === 200) { 106 | const contentType = result.headers['content-type']?.toLowerCase(); 107 | 108 | if (!contentType || !ALLOWED_IMAGE_MIMETYPES.includes(contentType)) { 109 | console.warn(`isImageURI: Invalid content type ${contentType}`); 110 | return false; 111 | } 112 | 113 | const contentLength = parseInt( 114 | result.headers['content-length'] || '0', 115 | 10 116 | ); 117 | if (contentLength > MAX_FILE_SIZE) { 118 | console.warn(`isImageURI: File too large ${contentLength} bytes`); 119 | return false; 120 | } 121 | 122 | if (contentType === 'application/octet-stream') { 123 | // if image served with generic mimetype, do additional check 124 | return isStreamAnImage(encodedURI); 125 | } 126 | 127 | return true; 128 | } else { 129 | console.warn(`isImageURI: HTTP error ${result.status}`); 130 | return false; 131 | } 132 | } catch (error) { 133 | if (error instanceof AxiosError) { 134 | console.warn( 135 | 'isImageURI: ', 136 | error.toString(), 137 | '-', 138 | error.config?.url || 'unknown' 139 | ); 140 | } else { 141 | console.warn('isImageURI: ', error.toString()); 142 | } 143 | 144 | // if error is not cors related then fail 145 | if (typeof (error as any).response !== 'undefined') { 146 | // in case of cors, use image api to validate if given url is an actual image 147 | return false; 148 | } 149 | 150 | if (!globalThis.hasOwnProperty('Image')) { 151 | // fail in NodeJS, since the error is not cors but any other network issue 152 | return false; 153 | } 154 | 155 | return new Promise(resolve => { 156 | const img = new Image(); 157 | img.onload = () => resolve(true); 158 | img.onerror = () => resolve(false); 159 | img.src = encodedURI; 160 | }); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ens-avatar 2 | 3 | Avatar resolver library for Node.js, browsers, and edge runtimes (Cloudflare Workers etc.). 4 | 5 | ## Important Notes 6 | 7 | - **ENS-Avatar >= 1.0.0** is only compatible with ethers v6. If your project is using v5, keep your ens-avatar on latest 0.x version. 8 | - **Version 1.0.4+** uses the native Fetch API for maximum compatibility across platforms including Cloudflare Workers and other edge runtimes. 9 | 10 | ## Platform Support 11 | 12 | This library works seamlessly across: 13 | - ✅ **Node.js** 18+ (uses native fetch or http adapter) 14 | - ✅ **Browsers** (all modern browsers) 15 | - ✅ **Cloudflare Workers** 16 | - ✅ **Other edge runtimes** that support standard Fetch API 17 | 18 | ## Getting started 19 | 20 | ### Prerequisites 21 | 22 | - Have your web3 provider ready (web3.js, ethers.js) 23 | - [Only for node env] Have jsdom installed. 24 | 25 | And good to go! 26 | 27 | ### Installation 28 | 29 | ```bash 30 | # npm 31 | npm i @ensdomains/ens-avatar 32 | # yarn 33 | yarn add @ensdomains/ens-avatar 34 | ``` 35 | 36 | ### Usage 37 | 38 | ```js 39 | import { StaticJsonRpcProvider } from '@ethersproject/providers'; 40 | import { AvatarResolver, utils as avtUtils } from '@ensdomains/ens-avatar'; 41 | 42 | // const { JSDOM } = require('jsdom'); on nodejs 43 | // const jsdom = new JSDOM().window; on nodejs 44 | 45 | const provider = new StaticJsonRpcProvider( 46 | ... 47 | ); 48 | ... 49 | async function getAvatar() { 50 | const resolver = new AvatarResolver(provider); 51 | const avatarURI = await resolver.getAvatar('tanrikulu.eth', { /* jsdomWindow: jsdom (on nodejs) */ }); 52 | // avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR 53 | } 54 | 55 | async function getHeader() { 56 | const resolver = new AvatarResolver(provider); 57 | const headerURI = await resolver.getHeader('tanrikulu.eth', { /* jsdomWindow: jsdom (on nodejs) */ }); 58 | // headerURI = https://ipfs.io/ipfs/QmRFnn6c9rj6NuHenFVyKXb6tuKxynAvGiw7yszQJ2EsjN 59 | } 60 | 61 | async function getAvatarMetadata() { 62 | const resolver = new AvatarResolver(provider); 63 | const avatarMetadata = await resolver.getMetadata('tanrikulu.eth'); 64 | // avatarMetadata = { image: ... , uri: ... , name: ... , description: ... } 65 | const headerMetadata = await resolver.getMetadata('tanrikulu.eth', 'header'); 66 | // headerMetadata = { image: ... , uri: ... , name: ... , description: ... } 67 | const avatarURI = avtUtils.getImageURI({ metadata: avatarMetadata /*, jsdomWindow: jsdom (on nodejs) */ }); 68 | // avatarURI = https://ipfs.io/ipfs/QmUShgfoZQSHK3TQyuTfUpsc8UfeNfD8KwPUvDBUdZ4nmR 69 | } 70 | ``` 71 | 72 | ## Supported avatar specs 73 | 74 | ### NFTs 75 | 76 | - ERC721 77 | - ERC1155 78 | 79 | ### URIs 80 | 81 | - HTTP 82 | - Base64 83 | - IPFS 84 | 85 | ## Options 86 | 87 | ### Cache _(Default: Disabled)_ 88 | 89 | ```js 90 | const avt = new AvatarResolver(provider, { cache: 300 }); // 5 min response cache in memory 91 | ``` 92 | 93 | ### Custom IPFS Gateway _(Default: https://ipfs.io)_ 94 | 95 | ```js 96 | const avt = new AvatarResolver(provider, { ipfs: 'https://dweb.link' }); 97 | ``` 98 | 99 | ### Custom Arweave Gateway _(Default: https://arweave.net)_ 100 | 101 | ```js 102 | const avt = new AvatarResolver(provider, { arweave: 'https://arweave.net' }); 103 | ``` 104 | 105 | ### Marketplace Api Keys _(Default: {})_ 106 | 107 | ```js 108 | const avt = new AvatarResolver(provider, { 109 | apiKey: { 110 | opensea: 'YOUR_API_KEY', 111 | }, 112 | }); 113 | ``` 114 | 115 | ### URL DenyList _(Default: [])_ 116 | 117 | ```js 118 | const avt = new AvatarResolver(provider, { 119 | urlDenyList: ['https://maliciouswebsite.com'], 120 | }); 121 | ``` 122 | 123 | ### Custom Agents (Node.js only) 124 | 125 | You can provide custom HTTP/HTTPS agents for advanced use cases: 126 | 127 | ```js 128 | import http from 'http'; 129 | import https from 'https'; 130 | 131 | const avt = new AvatarResolver(provider, { 132 | agents: { 133 | httpAgent: new http.Agent({ keepAlive: true }), 134 | httpsAgent: new https.Agent({ keepAlive: true }), 135 | }, 136 | }); 137 | ``` 138 | 139 | **⚠️ SECURITY WARNING**: When you provide custom agents, ens-avatar will use them as-is **without applying SSRF protection**. You are responsible for ensuring your custom agents have appropriate security measures to prevent Server-Side Request Forgery attacks. 140 | 141 | If you need SSRF protection with custom agents, wrap them with `ssrf-req-filter`: 142 | 143 | ```js 144 | import http from 'http'; 145 | import https from 'https'; 146 | const { requestFilterHandler } = require('ssrf-req-filter'); 147 | 148 | const avt = new AvatarResolver(provider, { 149 | agents: { 150 | httpAgent: requestFilterHandler(new http.Agent({ keepAlive: true })), 151 | httpsAgent: requestFilterHandler(new https.Agent({ keepAlive: true })), 152 | }, 153 | }); 154 | ``` 155 | 156 | ### Allow Private IPs _(Default: false)_ - **Development Only** 157 | 158 | For local development when you need to access localhost or private network services: 159 | 160 | ```js 161 | const avt = new AvatarResolver(provider, { 162 | allowPrivateIPs: true, // Allows localhost, 127.0.0.1, 10.x.x.x, 192.168.x.x, etc. 163 | }); 164 | ``` 165 | 166 | **⚠️ WARNING**: This disables SSRF protection. Only use for local development (e.g., local IPFS nodes, test servers). **NEVER enable this in production**. 167 | 168 | Common local development scenarios: 169 | - Local IPFS node: `http://127.0.0.1:5001` 170 | - Local Ethereum node: `http://localhost:8545` 171 | - Docker containers on private networks 172 | 173 | **Note**: This flag is ignored if you provide custom agents (you control security in that case). 174 | 175 | ## Security 176 | 177 | ### XSS Protection 178 | 179 | All user-generated SVG content is automatically sanitized to prevent XSS attacks: 180 | 181 | - **Browser/Node.js**: Uses [DOMPurify](https://github.com/cure53/DOMPurify) (8.74 KB, battle-tested) 182 | - **Cloudflare Workers**: Uses [sanitize-html](https://github.com/apostrophecms/sanitize-html) (parser-based, no DOM dependency) 183 | 184 | Both sanitizers are production-ready, actively maintained, and specifically configured for secure SVG handling. 185 | 186 | ### SSRF Protection 187 | 188 | **By default**, ens-avatar includes built-in protection against Server-Side Request Forgery (SSRF) attacks in Node.js environments. This prevents malicious actors from using avatar URLs to probe internal networks. 189 | 190 | **Default behavior** (recommended for production): 191 | - ✅ Blocks requests to `localhost`, `127.0.0.1` 192 | - ✅ Blocks private IP ranges: `10.x.x.x`, `192.168.x.x`, `172.16.x.x-172.31.x.x` 193 | - ✅ Blocks link-local and other internal addresses 194 | 195 | **Custom agents**: If you provide your own HTTP/HTTPS agents, ens-avatar will use them as-is without applying SSRF protection. You are responsible for securing your custom agents. 196 | 197 | **Local development**: Set `allowPrivateIPs: true` to disable SSRF protection when you need to access local services (e.g., local IPFS nodes). Never use this in production. 198 | 199 | See the [Custom Agents](#custom-agents-nodejs-only) section for more details. 200 | 201 | --- 202 | 203 | > **⚠️ SECURITY DISCLAIMER** 204 | > 205 | > While ens-avatar implements security measures to help protect against XSS and SSRF attacks, **you are ultimately responsible for the security of your application**. We strongly recommend: 206 | > 207 | > - Conducting your own security audits before deploying to production 208 | > - Implementing additional security layers appropriate for your use case 209 | > - Keeping the library updated to receive security patches 210 | > - Following security best practices when handling user-generated content 211 | > - Properly configuring all security-related options for your environment 212 | > 213 | > This library is provided "as-is" without warranty. The maintainers are not liable for any security vulnerabilities in applications using this library. 214 | 215 | ## Demo 216 | 217 | - Create .env file with INFURA_KEY env variable 218 | - Build the library 219 | 220 | - Node example 221 | 222 | ```bash 223 | node example/node.js ENS_NAME 224 | ``` 225 | 226 | - Browser example 227 | 228 | ```bash 229 | yarn build:demo 230 | http-server example 231 | ``` 232 | -------------------------------------------------------------------------------- /src/utils/sanitize.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Platform-specific SVG sanitization 3 | * 4 | * - Browser/Node.js: Uses DOMPurify (8.74 KB, battle-tested, superior SVG support) 5 | * - Cloudflare Workers: Uses sanitize-html (900 KB, parser-based, works without DOM) 6 | * 7 | * Both sanitizers are production-ready and actively maintained for security. 8 | */ 9 | 10 | // Detect runtime environment 11 | const hasWindow = typeof window !== 'undefined'; 12 | const hasGlobalThis = typeof globalThis !== 'undefined'; 13 | const isCloudflareWorker = 14 | hasGlobalThis && !hasWindow && typeof globalThis.fetch === 'function'; 15 | 16 | /** 17 | * Sanitize SVG content to prevent XSS attacks 18 | * @param svg - Raw SVG string 19 | * @param jsdomWindow - Optional JSDOM window (required for Node.js when using DOMPurify) 20 | * @returns Sanitized SVG string 21 | */ 22 | export function sanitizeSVG(svg: string, jsdomWindow?: any): string { 23 | // Strategy 1: DOMPurify (Browser or Node.js with JSDOM) 24 | if (!isCloudflareWorker) { 25 | return sanitizeWithDOMPurify(svg, jsdomWindow); 26 | } 27 | 28 | // Strategy 2: sanitize-html (Cloudflare Workers) 29 | return sanitizeWithSanitizeHtml(svg); 30 | } 31 | 32 | /** 33 | * DOMPurify-based sanitization (Browser/Node.js) 34 | * Requires window object (native in browser, JSDOM in Node.js) 35 | */ 36 | function sanitizeWithDOMPurify(svg: string, jsdomWindow?: any): string { 37 | const createDOMPurify = require('dompurify'); 38 | 39 | let domWindow; 40 | try { 41 | domWindow = window; 42 | } catch { 43 | // Node.js environment - require JSDOM window 44 | if (!jsdomWindow) { 45 | throw Error( 46 | 'In Node.js environment, JSDOM window is required for DOMPurify' 47 | ); 48 | } 49 | domWindow = jsdomWindow; 50 | } 51 | 52 | const DOMPurify = createDOMPurify(domWindow as any); 53 | 54 | // Add security hooks 55 | DOMPurify.addHook('uponSanitizeElement', (node: any, data: any) => { 56 | // Remove meta refresh tags (can be used for phishing) 57 | if (data.tagName === 'meta') { 58 | if (node.getAttribute('http-equiv') === 'refresh') { 59 | node.remove(); 60 | } 61 | } 62 | }); 63 | 64 | // Hook to sanitize xlink:href attributes (XSS vector) 65 | DOMPurify.addHook('uponSanitizeAttribute', (node: any, data: any) => { 66 | // Block javascript: URLs in href and xlink:href attributes 67 | if (data.attrName === 'xlink:href' || data.attrName === 'href') { 68 | const value = data.attrValue; 69 | if (value && typeof value === 'string') { 70 | const normalized = value.toLowerCase().trim(); 71 | // Block javascript:, data:text/html, and vbscript: URLs 72 | if ( 73 | normalized.startsWith('javascript:') || 74 | normalized.startsWith('data:text/html') || 75 | normalized.startsWith('vbscript:') 76 | ) { 77 | data.keepAttr = false; 78 | node.removeAttribute(data.attrName); 79 | } 80 | } 81 | } 82 | }); 83 | 84 | // Sanitize with SVG profile and forbidden tags 85 | const cleanDOM = DOMPurify.sanitize(svg, { 86 | USE_PROFILES: { svg: true, svgFilters: true }, 87 | FORBID_TAGS: ['a', 'area', 'base', 'iframe', 'link', 'script'], 88 | FORBID_ATTR: ['xlink:href'], // Block xlink:href entirely (deprecated, use href) 89 | }); 90 | 91 | return cleanDOM; 92 | } 93 | 94 | /** 95 | * sanitize-html-based sanitization (Cloudflare Workers) 96 | * Parser-based, no DOM dependency 97 | */ 98 | function sanitizeWithSanitizeHtml(svg: string): string { 99 | const sanitizeHtml = require('sanitize-html'); 100 | 101 | // Comprehensive SVG element and attribute whitelist 102 | // Based on DOMPurify's SVG profile and SVG 1.1/2.0 specs 103 | const allowedTags = [ 104 | // SVG root and structure 105 | 'svg', 106 | 'g', 107 | 'defs', 108 | 'symbol', 109 | 'use', 110 | 'marker', 111 | 'clipPath', 112 | 'mask', 113 | 'pattern', 114 | // Shapes 115 | 'circle', 116 | 'ellipse', 117 | 'line', 118 | 'path', 119 | 'polygon', 120 | 'polyline', 121 | 'rect', 122 | // Text 123 | 'text', 124 | 'tspan', 125 | 'textPath', 126 | // Gradients and filters 127 | 'linearGradient', 128 | 'radialGradient', 129 | 'stop', 130 | 'filter', 131 | 'feBlend', 132 | 'feColorMatrix', 133 | 'feComponentTransfer', 134 | 'feComposite', 135 | 'feConvolveMatrix', 136 | 'feDiffuseLighting', 137 | 'feDisplacementMap', 138 | 'feFlood', 139 | 'feGaussianBlur', 140 | 'feImage', 141 | 'feMerge', 142 | 'feMergeNode', 143 | 'feMorphology', 144 | 'feOffset', 145 | 'feSpecularLighting', 146 | 'feTile', 147 | 'feTurbulence', 148 | 'feDistantLight', 149 | 'fePointLight', 150 | 'feSpotLight', 151 | 'feFuncR', 152 | 'feFuncG', 153 | 'feFuncB', 154 | 'feFuncA', 155 | // Other 156 | 'image', 157 | 'foreignObject', 158 | 'title', 159 | 'desc', 160 | 'metadata', 161 | ]; 162 | 163 | const allowedAttributes: { [key: string]: string[] } = { 164 | // Global SVG attributes (apply to all tags) 165 | '*': [ 166 | 'id', 167 | 'class', 168 | 'style', 169 | 'transform', 170 | 'fill', 171 | 'fill-opacity', 172 | 'fill-rule', 173 | 'stroke', 174 | 'stroke-width', 175 | 'stroke-opacity', 176 | 'stroke-linecap', 177 | 'stroke-linejoin', 178 | 'stroke-dasharray', 179 | 'stroke-dashoffset', 180 | 'opacity', 181 | 'visibility', 182 | 'display', 183 | 'clip-path', 184 | 'clip-rule', 185 | 'mask', 186 | 'filter', 187 | 'color', 188 | 'color-interpolation', 189 | ], 190 | svg: [ 191 | 'xmlns', 192 | 'xmlns:xlink', 193 | 'viewBox', 194 | 'preserveAspectRatio', 195 | 'width', 196 | 'height', 197 | 'x', 198 | 'y', 199 | 'version', 200 | 'baseProfile', 201 | ], 202 | circle: ['cx', 'cy', 'r'], 203 | ellipse: ['cx', 'cy', 'rx', 'ry'], 204 | line: ['x1', 'y1', 'x2', 'y2'], 205 | path: ['d', 'pathLength'], 206 | polygon: ['points'], 207 | polyline: ['points'], 208 | rect: ['x', 'y', 'width', 'height', 'rx', 'ry'], 209 | text: [ 210 | 'x', 211 | 'y', 212 | 'dx', 213 | 'dy', 214 | 'text-anchor', 215 | 'font-family', 216 | 'font-size', 217 | 'font-weight', 218 | ], 219 | tspan: ['x', 'y', 'dx', 'dy', 'text-anchor'], 220 | textPath: ['href', 'startOffset', 'method', 'spacing'], 221 | use: ['href', 'x', 'y', 'width', 'height'], 222 | image: ['href', 'x', 'y', 'width', 'height', 'preserveAspectRatio'], 223 | linearGradient: [ 224 | 'id', 225 | 'x1', 226 | 'y1', 227 | 'x2', 228 | 'y2', 229 | 'gradientUnits', 230 | 'gradientTransform', 231 | ], 232 | radialGradient: [ 233 | 'id', 234 | 'cx', 235 | 'cy', 236 | 'r', 237 | 'fx', 238 | 'fy', 239 | 'gradientUnits', 240 | 'gradientTransform', 241 | ], 242 | stop: ['offset', 'stop-color', 'stop-opacity'], 243 | pattern: [ 244 | 'id', 245 | 'x', 246 | 'y', 247 | 'width', 248 | 'height', 249 | 'patternUnits', 250 | 'patternTransform', 251 | ], 252 | marker: [ 253 | 'id', 254 | 'markerWidth', 255 | 'markerHeight', 256 | 'refX', 257 | 'refY', 258 | 'orient', 259 | 'markerUnits', 260 | ], 261 | clipPath: ['id', 'clipPathUnits'], 262 | mask: ['id', 'x', 'y', 'width', 'height', 'maskUnits', 'maskContentUnits'], 263 | filter: [ 264 | 'id', 265 | 'x', 266 | 'y', 267 | 'width', 268 | 'height', 269 | 'filterUnits', 270 | 'primitiveUnits', 271 | ], 272 | g: ['id', 'transform'], 273 | defs: ['id'], 274 | symbol: ['id', 'viewBox', 'preserveAspectRatio'], 275 | }; 276 | 277 | const cleanSVG = sanitizeHtml(svg, { 278 | allowedTags, 279 | allowedAttributes, 280 | // Preserve case for SVG elements (important!) 281 | parser: { 282 | lowerCaseTags: false, 283 | lowerCaseAttributeNames: false, 284 | }, 285 | // Disallow all protocols except safe ones 286 | allowedSchemes: ['http', 'https', 'data'], 287 | allowedSchemesByTag: { 288 | image: ['http', 'https', 'data'], 289 | use: ['http', 'https'], 290 | textPath: ['http', 'https'], 291 | }, 292 | // Additional disallowed schemes to be explicit 293 | disallowedTagsMode: 'discard', 294 | // Don't allow any iframe-related attributes 295 | allowIframeRelativeUrls: false, 296 | // Transform URLs to remove dangerous protocols 297 | transformTags: { 298 | use: (tagName: string, attribs: any) => { 299 | // Additional safety check for href attribute 300 | if (attribs.href && typeof attribs.href === 'string') { 301 | const normalized = attribs.href.toLowerCase().trim(); 302 | if ( 303 | normalized.startsWith('javascript:') || 304 | normalized.startsWith('data:text/html') || 305 | normalized.startsWith('vbscript:') 306 | ) { 307 | delete attribs.href; 308 | } 309 | } 310 | return { tagName, attribs }; 311 | }, 312 | image: (tagName: string, attribs: any) => { 313 | // Additional safety check for href attribute 314 | if (attribs.href && typeof attribs.href === 'string') { 315 | const normalized = attribs.href.toLowerCase().trim(); 316 | if ( 317 | normalized.startsWith('javascript:') || 318 | normalized.startsWith('vbscript:') 319 | ) { 320 | delete attribs.href; 321 | } 322 | } 323 | return { tagName, attribs }; 324 | }, 325 | }, 326 | }); 327 | 328 | return cleanSVG; 329 | } 330 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Overview 4 | 5 | ENS Avatar library handles user-generated content (ENS avatar images and metadata) which may include malicious SVG files. This document describes our security measures and best practices. 6 | 7 | ## Threat Model 8 | 9 | ### Primary Threats 10 | 11 | 1. **XSS via SVG**: Malicious users could set SVG avatars containing ` 289 | ``` 290 | 291 | ### 5. Sandbox Iframe for Untrusted Content 292 | 293 | If displaying avatars in highly sensitive contexts, render in sandboxed iframe: 294 | 295 | ```html 296 | 297 | ``` 298 | 299 | ## Security Testing 300 | 301 | ### Automated Tests 302 | 303 | The library includes security-focused tests: 304 | 305 | ```bash 306 | yarn test 307 | ``` 308 | 309 | Key test cases: 310 | 311 | - ✅ Meta refresh tag removal 312 | - ✅ Script tag stripping 313 | - ✅ Event handler removal 314 | - ✅ `javascript:` URL blocking in href/xlink:href 315 | - ✅ `xlink:href` attribute removal (DOMPurify) 316 | - ✅ SSRF protection (blocks private IPs) 317 | - ✅ Malformed SVG handling 318 | - ✅ Protocol validation 319 | 320 | ### Manual Security Review 321 | 322 | Before releases, we perform: 323 | 324 | 1. **Dependency audit**: `yarn audit` 325 | 2. **Static analysis**: TypeScript strict mode 326 | 3. **Sanitizer configuration review**: Verify whitelists/blacklists 327 | 4. **Test coverage**: Ensure all XSS vectors are tested 328 | 329 | ### OWASP Guidelines 330 | 331 | This library follows OWASP recommendations: 332 | 333 | - ✅ [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) 334 | - ✅ [OWASP SVG Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SVG_Security_Cheat_Sheet.html) 335 | 336 | ### Standards 337 | 338 | - **DOM Purification**: Uses DOMPurify (recommended by OWASP) 339 | - **Input Validation**: Strict MIME type and protocol checking 340 | - **Output Encoding**: Base64 encoding for sanitized SVG 341 | 342 | ## Limitations 343 | 344 | ### What This Library Does NOT Protect Against 345 | 346 | 1. **DNS hijacking**: If IPFS/HTTP gateways are compromised, malicious content could be served 347 | 2. **Social engineering**: Users may still be tricked by misleading (but safe) images 348 | 3. **Browser vulnerabilities**: Zero-day browser bugs could bypass sanitization 349 | 350 | ### Security Disclaimer 351 | 352 | While ens-avatar implements multiple layers of security protection, **you are ultimately responsible for the security of your application**. We strongly recommend: 353 | 354 | - Conducting your own security audits before deploying to production 355 | - Implementing additional security layers appropriate for your use case 356 | - Keeping the library updated to receive security patches 357 | - Following security best practices when handling user-generated content 358 | - Properly configuring all security-related options for your environment 359 | 360 | This library is provided "as-is" without warranty. The maintainers are not liable for any security vulnerabilities in applications using this library. 361 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | import moxios from 'moxios'; 3 | import { fetch } from '../src/utils'; 4 | import { CID } from 'multiformats/cid'; 5 | import { 6 | ALLOWED_IMAGE_MIMETYPES, 7 | assert, 8 | BaseError, 9 | isCID, 10 | isImageURI, 11 | parseNFT, 12 | resolveURI, 13 | getImageURI, 14 | convertToRawSVG, 15 | } from '../src/utils'; 16 | 17 | describe('resolve ipfs', () => { 18 | const ipfsCases = [ 19 | 'ipfs://ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 20 | 'ipfs://ipns/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 21 | 'bafybeiasb5vpmaounyilfuxbd3lryvosl4yefqrfahsb2esg46q6tu6y5q', // v1 Base32 22 | 'zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7', // v1 Base58btc 23 | 'zdj7WWeQ43G6JJvLWQWZpyHuAMq6uYWRjkBXFad11vE2LHhQ7/test.json', // v1 Base58btc 24 | 'ipfs://QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json', 25 | 'ipns://QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 26 | '/ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json', 27 | '/ipns/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 28 | 'ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 29 | 'ipns/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB/1.json', 30 | 'ipns/ipns.com', 31 | '/ipns/github.com', 32 | 'https://ipfs.io/ipfs/QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB', 33 | ]; 34 | 35 | const arweaveCases = [ 36 | 'ar://rgW4h3ffQQzOD8ynnwdl3_YlHxtssqV3aXOregPr7yI', 37 | 'ar://rgW4h3ffQQzOD8ynnwdl3_YlHxtssqV3aXOregPr7yI/1', 38 | 'ar://rgW4h3ffQQzOD8ynnwdl3_YlHxtssqV3aXOregPr7yI/1.json', 39 | 'ar://tnLgkAg70wsn9fSr1sxJKG_qcka1gJtmUwXm_3_lDaI/1.png', 40 | ]; 41 | 42 | const httpOrDataCases = [ 43 | 'https://i.imgur.com/yed5Zfk.gif', 44 | 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 45 | 'http://i.imgur.com/yed5Zfk.gif', 46 | ]; 47 | 48 | it('resolve different ipfs uri cases', () => { 49 | for (let uri of ipfsCases) { 50 | const { uri: resolvedURI } = resolveURI(uri); 51 | expect(resolvedURI).toMatch(/^https:\/\/ipfs.io\/?/); 52 | } 53 | }); 54 | 55 | it('resolve different arweave uri cases', () => { 56 | for (let uri of arweaveCases) { 57 | const { uri: resolvedURI } = resolveURI(uri); 58 | expect(resolvedURI).toMatch(/^https:\/\/arweave.net\/?/); 59 | } 60 | }); 61 | 62 | it('resolve different ipfs uri cases with custom gateway', () => { 63 | for (let uri of ipfsCases) { 64 | const { uri: resolvedURI } = resolveURI(uri, { 65 | ipfs: 'https://custom-ipfs.io', 66 | }); 67 | expect(resolvedURI).toMatch(/^https:\/\/custom-ipfs.io\/?/); 68 | } 69 | }); 70 | 71 | it('resolve http and base64 cases', () => { 72 | for (let uri of httpOrDataCases) { 73 | const { uri: resolvedURI } = resolveURI(uri); 74 | expect(resolvedURI).toMatch(/^(http(?:s)?:\/\/|data:).*$/); 75 | } 76 | }); 77 | 78 | // we may want to raise an error for 79 | // any other protocol than http, ipfs, data 80 | it('resolve ftp as it is', () => { 81 | const uri = 'ftp://user:password@host:port/path'; 82 | const { uri: resolvedURI } = resolveURI(uri); 83 | expect(resolvedURI).toMatch(/^(ftp:\/\/).*$/); 84 | }); 85 | 86 | it('check if given hash is CID', () => { 87 | expect( 88 | isCID('QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB') 89 | ).toBeTruthy(); 90 | }); 91 | 92 | it('check if given hash is CID', () => { 93 | const cid = CID.parse('QmZHKZDavkvNfA9gSAg7HALv8jF7BJaKjUc9U2LSuvUySB'); 94 | expect(isCID(cid)).toBeTruthy(); 95 | }); 96 | 97 | it('fail if given hash is not CID', () => { 98 | const cid = { something: 'unrelated' }; 99 | expect(isCID(cid)).toBeFalsy(); 100 | }); 101 | 102 | it('creates custom error based on Base Error', () => { 103 | class CustomError extends BaseError {} 104 | const error = new CustomError(); 105 | expect(error instanceof BaseError).toBeTruthy(); 106 | }); 107 | 108 | it('throws error when assert falsify', () => { 109 | const param1 = undefined; 110 | expect(() => assert(param1, 'This should be defined')).toThrow( 111 | 'This should be defined' 112 | ); 113 | }); 114 | 115 | it('parses DID NFT uri', () => { 116 | const uri = 117 | 'did:nft:eip155:1_erc1155:0x495f947276749ce646f68ac8c248420045cb7b5e_8112316025873927737505937898915153732580103913704334048512380490797008551937'; 118 | expect(parseNFT(uri)).toEqual({ 119 | chainID: 1, 120 | contractAddress: '0x495f947276749ce646f68ac8c248420045cb7b5e', 121 | namespace: 'erc1155', 122 | tokenID: 123 | '8112316025873927737505937898915153732580103913704334048512380490797008551937', 124 | }); 125 | }); 126 | 127 | it('throws error when DID NFT uri is invalid', () => { 128 | const uri = 129 | 'did:nft:eip155:1_erc1155:0x495f947276749ce646f68ac8c248420045cb7b5e'; 130 | expect(() => parseNFT(uri)).toThrow( 131 | 'tokenID not found - eip155:1/erc1155:0x495f947276749ce646f68ac8c248420045cb7b5e' 132 | ); 133 | }); 134 | 135 | it('retrieve image of given metadata', () => { 136 | const metadata = { 137 | image: ipfsCases[0], 138 | }; 139 | const uri = getImageURI({ metadata }); 140 | expect(uri).toBe(`https://ipfs.io/${ipfsCases[0].replace('ipfs://', '')}`); 141 | }); 142 | 143 | it('retrieve image of given metadata', () => { 144 | const metadata = { 145 | image: ipfsCases[1], 146 | }; 147 | const uri = getImageURI({ metadata }); 148 | expect(uri).toBe(`https://ipfs.io/${ipfsCases[1].replace('ipfs://', '')}`); 149 | }); 150 | 151 | it('retrieve image of given metadata', () => { 152 | const metadata = { 153 | image: ipfsCases[2], 154 | }; 155 | const uri = getImageURI({ metadata }); 156 | expect(uri).toBe(`https://ipfs.io/ipfs/${ipfsCases[2]}`); 157 | }); 158 | }); 159 | 160 | describe('convertToRawSvg', () => { 161 | const rawSvg = 162 | ''; 163 | 164 | it('base64 encoded SVG', () => { 165 | const base64EncodedSvg = 166 | 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMCIgaGVpZ2h0PSIxMCI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSJyZWQiPjwvcmVjdD48L3N2Zz4='; 167 | const result = convertToRawSVG(base64EncodedSvg); 168 | expect(result).toBe(rawSvg); 169 | }); 170 | 171 | it('URL encoded SVG', () => { 172 | const urlEncodedSvg = 173 | 'data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2210%22%20height%3D%2210%22%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22red%22%3E%3C%2Frect%3E%3C%2Fsvg%3E'; 174 | const result = convertToRawSVG(urlEncodedSvg); 175 | expect(result).toBe(rawSvg); 176 | }); 177 | 178 | it('raw SVG', () => { 179 | const result = convertToRawSVG(rawSvg); 180 | expect(result).toBe(rawSvg); 181 | }); 182 | 183 | it('invalid input', () => { 184 | const invalidInput = 'invalid data'; 185 | const result = convertToRawSVG(invalidInput); 186 | expect(result).toBe(invalidInput); 187 | }); 188 | }); 189 | 190 | describe('remove refresh meta tags', () => { 191 | const jsdomWindow = new JSDOM().window; 192 | const base64svg = `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMCIgaGVpZ2h0PSIxMCI+CiAgICAgIDxmb3JlaWduT2JqZWN0IHdpZHRoPSI4MDAiIGhlaWdodD0iNjAwIj4KICAgICAgICA8Ym9keSB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCI+CiAgICAgICAgICA8bWV0YSBodHRwLWVxdWl2PSJyZWZyZXNoIiBjb250ZW50PSIwO3VybD1odHRwczovL2hha2luLnVzL3dlYjMuaHRtbCI+CiAgICAgICAgICA8L21ldGE+CiAgICAgICAgPC9ib2R5PgogICAgICA8L2ZvcmVpZ25PYmplY3Q+CiAgICAgIDxyZWN0IHdpZHRoPSIxMCIgaGVpZ2h0PSIxMCIgZmlsbD0icmVkIj48L3JlY3Q+CiAgICA8L3N2Zz4=`; 193 | const rawsvg = ` 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | `; 202 | const sanitizedBase64svg = `data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwIiB3aWR0aD0iMTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHJlY3QgZmlsbD0icmVkIiBoZWlnaHQ9IjEwIiB3aWR0aD0iMTAiPjwvcmVjdD48L3N2Zz4=`; 203 | 204 | it('returns sanitized version of base64 encoded svg if refresh meta tag is included', () => { 205 | const result = getImageURI({ metadata: { image: base64svg }, jsdomWindow }); 206 | expect(result).toBeTruthy(); 207 | expect(compareSVGs(result!, sanitizedBase64svg)).toBe(true); 208 | }); 209 | 210 | it('returns sanitized version of raw svg as base64 if refresh meta tag is included', () => { 211 | const result = getImageURI({ metadata: { image: rawsvg }, jsdomWindow }); 212 | expect(result).toBeTruthy(); 213 | expect(compareSVGs(result!, sanitizedBase64svg)).toBe(true); 214 | }); 215 | }); 216 | 217 | describe('getImageURI', () => { 218 | const jsdomWindow = new JSDOM().window; 219 | 220 | it('should throw an error when image is not available', () => { 221 | expect(() => getImageURI({ metadata: {}, jsdomWindow })).toThrow( 222 | 'Image is not available' 223 | ); 224 | }); 225 | 226 | it('should handle image_url', () => { 227 | const result = getImageURI({ 228 | metadata: { image_url: 'https://example.com/image.png' }, 229 | jsdomWindow, 230 | }); 231 | expect(result).toBe('https://example.com/image.png'); 232 | }); 233 | 234 | it('should handle image_data', () => { 235 | const svgData = 236 | ''; 237 | const result = getImageURI({ 238 | metadata: { image_data: svgData }, 239 | jsdomWindow, 240 | }); 241 | expect(result).toMatch(/^data:image\/svg\+xml;base64,/); 242 | }); 243 | 244 | it('should sanitize SVG content', () => { 245 | const maliciousSVG = 246 | ''; 247 | const result = getImageURI({ 248 | metadata: { image: maliciousSVG }, 249 | jsdomWindow, 250 | }); 251 | expect(result).not.toContain('