Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add xml-parser to detect any svg content, with or without mimetype #175

Merged
merged 9 commits into from
Jul 5, 2024
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@


### Request
- __network:__ Name of the chain to query for. (mainnet | goerli ...)
- __network:__ Name of the chain to query for. (mainnet | sepolia ...)
- __contactAddress:__ accepts contractAddress of the NFT which represented by the tokenId
- __NFT v1 - tokenId:__ accepts ENS name or labelhash of ENS name in both hex and int format
- __NFT v2 - tokenId:__ accepts ENS name or namehash of ENS name in both hex and int format
Expand Down
2 changes: 1 addition & 1 deletion docgen.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const doc = {
tokenId: '4221908525551133525058944220830153...',
networkName: {
description: 'Name of the chain to query for.',
'@enum': ['mainnet', 'rinkeby', 'ropsten', 'goerli', 'sepolia'],
'@enum': ['mainnet', 'sepolia'],
},
},
components: {
Expand Down
6 changes: 3 additions & 3 deletions mock/entry.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
WrappedDomainResponse,
} from './interface';

const { SUBGRAPH_URL: subgraph_url } = getNetwork('goerli');
const { SUBGRAPH_URL: subgraph_url } = getNetwork('sepolia');
const SUBGRAPH_URL = new URL(subgraph_url);
const SUBGRAPH_PATH = SUBGRAPH_URL.pathname + SUBGRAPH_URL.search;

Expand Down Expand Up @@ -111,10 +111,10 @@ export class MockEntry {
});

(_metadata as Metadata).setImage(
`https://metadata.ens.domains/goerli/${ADDRESS_NAME_WRAPPER}/${this.namehash}/image`
`https://metadata.ens.domains/sepolia/${ADDRESS_NAME_WRAPPER}/${this.namehash}/image`
);
(_metadata as Metadata).setBackground(
`https://metadata.ens.domains/goerli/avatar/${name}`
`https://metadata.ens.domains/sepolia/avatar/${name}`
);

this.domainResponse = {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"dependencies": {
"@adraffy/ens-normalize": "^1.10.0",
"@ensdomains/ens-avatar": "^1.0.0-alpha.1.ethers.6",
"@ensdomains/ens-avatar": "^1.0.0-alpha.2.ethers.6",
"@ensdomains/ensjs": "^3.7.0",
"@types/lodash": "^4.14.170",
"btoa": "^1.2.1",
Expand All @@ -21,12 +21,12 @@
"emoji-regex": "^10.1.0",
"ethers": "6.12.0",
"express": "^4.18.1",
"fast-xml-parser": "^4.3.6",
"google-auth-library": "^8.1.0",
"graphql": "^16.5.0",
"graphql-request": "^4.3.0",
"helmet": "^6.1.5",
"ioredis": "^5.3.2",
"is-svg": "^4.3.2",
"jsdom": "^19.0.0",
"lodash": "^4.17.21",
"multiformats": "^9.4.8",
Expand Down
11 changes: 6 additions & 5 deletions src/assets/doc_output.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
"info": {
"version": "0.0.1-alpha.1",
"title": "ENS Metadata Service",
"description": "Set of endpoints to query ENS metadata and more",
"description": "Set of endpoints to query ENS metadata and more.<br/><br/><b>Important:</b> While we have taken extensive measures to sanitize the user provided content, developers are advised to exercise caution when interacting with this API endpoint. In case of any advise or question, please reach us.",
"contact": "[email protected]",
"license": "MIT License",
"license": {
"name": "MIT",
"url": "https://opensource.org/license/mit"
},
"x-logo": {
"url": "/assets/logo.svg",
"backgroundColor": "#FFFFFF",
Expand Down Expand Up @@ -528,9 +531,7 @@
"type": "string",
"enum": [
"mainnet",
"rinkeby",
"ropsten",
"goerli"
"sepolia"
],
"xml": {
"name": "networkName"
Expand Down
6 changes: 6 additions & 0 deletions src/controller/queryNFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export async function queryNFTep(req: Request, res: Response) {
} */
res.status(200).json(metadata);
} catch (error) {
if (error.code === 'ERR_FR_REDIRECTION_FAILURE') {
res.status(500).json({
message: "Redirection is not allowed",
});
return;
}
/* #swagger.responses[500] = {
description: 'Internal Server Error'
} */
Expand Down
6 changes: 3 additions & 3 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { GET_DOMAINS } from './service/subgraph';
import { nockProvider, requireUncached } from '../mock/helper';
import { Metadata } from './service/metadata';

const TEST_NETWORK = 'goerli';
const TEST_NETWORK = 'sepolia';

const { WEB3_URL: web3_url, SUBGRAPH_URL: subgraph_url } =
getNetwork(TEST_NETWORK);
Expand Down Expand Up @@ -93,7 +93,7 @@ test.before(async (t: ExecutionContext<TestContext>) => {
nockProvider(WEB3_URL, 'eth_chainId', [], {
id: 1,
jsonrpc: '2.0',
result: '0x05', // goerli
result: '0xAA36A7', // sepolia
});
nockProvider(WEB3_URL, 'net_version', [], {
jsonrpc: '2.0',
Expand Down Expand Up @@ -452,7 +452,7 @@ test('raise ContractMismatchError', async (t: ExecutionContext<TestContext>) =>
response: { body },
}: HTTPError = (await t.throwsAsync(
() =>
got(`goerli/${NON_CONTRACT_ADDRESS}/${sub1Wrappertest.namehash}`, {
got(`sepolia/${NON_CONTRACT_ADDRESS}/${sub1Wrappertest.namehash}`, {
...options,
retry: 0,
}),
Expand Down
60 changes: 38 additions & 22 deletions src/service/avatar.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { AvatarResolver } from '@ensdomains/ens-avatar';
import { strict as assert } from 'assert';
import { ethers, JsonRpcProvider } from 'ethers';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import http from 'http';
import https from 'https';

import { AvatarResolver } from '@ensdomains/ens-avatar';
import { strict as assert } from 'assert';
import { JsonRpcProvider } from 'ethers';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
import {
ResolverNotFound,
RetrieveURIFailed,
TextRecordNotFound,
} from '../base';
import {
IPFS_GATEWAY,
OPENSEA_API_KEY
} from '../config';
import { abortableFetch } from '../utils/abortableFetch';
} from '../base';
import { IPFS_GATEWAY, OPENSEA_API_KEY } from '../config';
import { abortableFetch } from '../utils/abortableFetch';
import isSvg from '../utils/isSvg';

const { requestFilterHandler } = require('ssrf-req-filter');

const window = new JSDOM('').window;

Expand Down Expand Up @@ -49,13 +52,15 @@ export class AvatarMetadata {
avtResolver: AvatarResolver;
constructor(provider: JsonRpcProvider, uri: string) {
this.defaultProvider = provider;
this.avtResolver = new AvatarResolver(provider,
{
ipfs: IPFS_GATEWAY,
apiKey: { opensea: OPENSEA_API_KEY },
urlDenyList: [ 'metadata.ens.domains' ]
}
);
this.avtResolver = new AvatarResolver(provider, {
ipfs: IPFS_GATEWAY,
apiKey: { opensea: OPENSEA_API_KEY },
urlDenyList: ['metadata.ens.domains'],
agents: {
httpAgent: requestFilterHandler(new http.Agent()),
httpsAgent: requestFilterHandler(new https.Agent()),
},
});
this.uri = uri;
}

Expand All @@ -72,7 +77,10 @@ export class AvatarMetadata {
if (typeof error === 'string') {
console.log(`${this.uri} - error:`, error);
}
throw new RetrieveURIFailed(`Error fetching avatar: Provided url or NFT source is broken.`, 404);
throw new RetrieveURIFailed(
`Error fetching avatar: Provided url or NFT source is broken.`,
404
);
}

if (!avatarURI) {
Expand All @@ -84,16 +92,24 @@ export class AvatarMetadata {

if (avatarURI?.startsWith('http')) {
// abort fetching image after 5sec
const response = await abortableFetch(avatarURI, { timeout: 7000 });
const response = await abortableFetch(avatarURI, {
timeout: 7000,
headers: {
'user-agent': 'ENS-ImageFetcher/1.0.0',
},
})


assert(!!response, 'Response is empty');

const mimeType = response?.headers.get('Content-Type');
const data = await response?.buffer();

if (mimeType?.includes('svg')) {
if (mimeType?.includes('svg') || isSvg(data.toString())) {
const DOMPurify = createDOMPurify(window);
const cleanData = DOMPurify.sanitize(data.toString());
const cleanData = DOMPurify.sanitize(data.toString(), {
FORBID_TAGS: ['a', 'area', 'base', 'iframe', 'link'],
});
return [Buffer.from(cleanData), mimeType];
}

Expand Down
4 changes: 3 additions & 1 deletion src/service/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ function getWeb3URL(
case NODE_PROVIDERS.INFURA:
return `${api.replace('https://', `https://${network}.`)}`;
case NODE_PROVIDERS.CLOUDFLARE:
return `${api}/${network}`;
if (network === NETWORK.MAINNET) return `${api}/${network}`;
if (network === NETWORK.GOERLI) return NODE_PROVIDER_URL_GOERLI;
if (network === NETWORK.SEPOLIA) return NODE_PROVIDER_URL_SEPOLIA;
case NODE_PROVIDERS.GOOGLE:
if (network === NETWORK.MAINNET) return api;
if (network === NETWORK.GOERLI) return NODE_PROVIDER_URL_GOERLI;
Expand Down
13 changes: 12 additions & 1 deletion src/service/queryNFT.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import http from 'http';
import https from 'https';

import { utils, specs, UnsupportedNamespace } from '@ensdomains/ens-avatar';
import getNetwork, { NetworkName } from '../service/network';
import { UnsupportedNetwork } from '../base';

const { requestFilterHandler } = require('ssrf-req-filter');

const networks: { [key: string]: string } = {
'1': 'mainnet',
'3': 'ropsten',
Expand Down Expand Up @@ -38,7 +43,13 @@ export async function queryNFT(uri: string) {
provider,
undefined,
contractAddress,
tokenID
tokenID,
{
agents: {
httpAgent: requestFilterHandler(new http.Agent()),
httpsAgent: requestFilterHandler(new https.Agent())
}
}
);
return { host_meta, ...metadata };
}
2 changes: 1 addition & 1 deletion src/utils/abortableFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import timeoutSignal from 'timeout-signal';
const ssrfFilter = require('ssrf-req-filter');

interface AbortableFetchOpts {
timeout?: number;
[key: string]: any;
}

export async function abortableFetch(
Expand Down
39 changes: 39 additions & 0 deletions src/utils/isSvg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// @ref: https://github.com/sindresorhus/is-svg
// @ref: https://github.com/sindresorhus/is-svg/pull/38
import { XMLParser, XMLValidator } from 'fast-xml-parser';

export default function isSvg(data: string) {
if (typeof data !== 'string') {
throw new TypeError(`Expected a \`string\`, got \`${typeof data}\``);
}

data = data.toLowerCase().trim();

if (data.length === 0) {
return false;
}

// Has to be `!==` as it can also return an object with error info.
if (XMLValidator.validate(data) !== true) {
return false;
}

let jsonObject;
const parser = new XMLParser();

try {
jsonObject = parser.parse(data);
} catch {
return false;
}

if (!jsonObject) {
return false;
}

if (!('svg' in jsonObject)) {
return false;
}

return true;
}
2 changes: 1 addition & 1 deletion src/utils/rateLimiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (REDIS_URL) {

const opts = {
storeClient: redisClient,
points: 20, // Number of total points
points: 40, // Number of total points
duration: 2, // Per second(s)
execEvenly: false, // Do not delay actions evenly
blockDuration: 0, // Do not block the caller if consumed more than points
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@
/* Advanced Options */
"resolveJsonModule": true,
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"useUnknownInCatchVariables": false
},
"exclude": ["./src/**/*.test.ts", "./mock/**/*"]
}
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,10 @@
dns-packet "^5.6.1"
typescript-logging "^1.0.1"

"@ensdomains/ens-avatar@^1.0.0-alpha.1.ethers.6":
version "1.0.0-alpha.1.ethers.6"
resolved "https://registry.yarnpkg.com/@ensdomains/ens-avatar/-/ens-avatar-1.0.0-alpha.1.ethers.6.tgz#01829c09803b26f838cf1a78714e9f8100c710ce"
integrity sha512-CwkGDegpEZ13WBxK3LNfJUL5cczQHF9Ar8L9faEXLXIh4OkvEZWwpYNEB9Fn/UhYkS8/R4sGtx4ImqfppNujCA==
"@ensdomains/ens-avatar@^1.0.0-alpha.2.ethers.6":
version "1.0.0-alpha.2.ethers.6"
resolved "https://registry.yarnpkg.com/@ensdomains/ens-avatar/-/ens-avatar-1.0.0-alpha.2.ethers.6.tgz#b406c20fec6fa98e3427e92970686c8cdafbddeb"
integrity sha512-lx8Ggmp6uLPQHzgunMvVUG3MfMLhyhSoY03Fb5HyjAK39OjYl7pMB+nmWnh+l4foKztMqXBfXLJ9WOYls5AyAw==
dependencies:
"@ethersproject/contracts" "^5.7.0"
"@ethersproject/providers" "^5.7.0"
Expand Down Expand Up @@ -2511,7 +2511,7 @@ fast-text-encoding@^1.0.0:
resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867"
integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==

fast-xml-parser@^4.1.3:
fast-xml-parser@^4.1.3, fast-xml-parser@^4.3.6:
version "4.3.6"
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.3.6.tgz#190f9d99097f0c8f2d3a0e681a10404afca052ff"
integrity sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==
Expand Down
Loading