Refactor token media-type API to be token-scoped and safer (#3190)

* refactor media-type next.js API resource to mitigate request forgery attack

* fix typescript
This commit is contained in:
tom goriunov
2025-12-18 18:05:36 +01:00
committed by GitHub
parent dc9f400fa8
commit 5571346d76
16 changed files with 127 additions and 56 deletions
+1 -1
View File
@@ -90,7 +90,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
'/api/metrics': 'Regular page',
'/api/monitoring/invalid-api-schema': 'Regular page',
'/api/log': 'Regular page',
'/api/media-type': 'Regular page',
'/api/tokens/[hash]/instances/[id]/media-type': 'Regular page',
'/api/proxy': 'Regular page',
'/api/csrf': 'Regular page',
'/api/healthz': 'Regular page',
+1 -1
View File
@@ -93,7 +93,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/metrics': DEFAULT_TEMPLATE,
'/api/monitoring/invalid-api-schema': DEFAULT_TEMPLATE,
'/api/log': DEFAULT_TEMPLATE,
'/api/media-type': DEFAULT_TEMPLATE,
'/api/tokens/[hash]/instances/[id]/media-type': DEFAULT_TEMPLATE,
'/api/proxy': DEFAULT_TEMPLATE,
'/api/csrf': DEFAULT_TEMPLATE,
'/api/healthz': DEFAULT_TEMPLATE,
+1 -1
View File
@@ -94,7 +94,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
'/api/metrics': '%network_name% node API prometheus metrics',
'/api/monitoring/invalid-api-schema': '%network_name% node API prometheus metrics',
'/api/log': '%network_name% node API request log',
'/api/media-type': '%network_name% node API media type',
'/api/tokens/[hash]/instances/[id]/media-type': '%network_name% node API token instance media type',
'/api/proxy': '%network_name% node API proxy',
'/api/csrf': '%network_name% node API CSRF token',
'/api/healthz': '%network_name% node API health check',
+1 -1
View File
@@ -88,7 +88,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
'/api/metrics': 'Node API: Prometheus metrics',
'/api/monitoring/invalid-api-schema': 'Node API: Prometheus metrics',
'/api/log': 'Node API: Request log',
'/api/media-type': 'Node API: Media type',
'/api/tokens/[hash]/instances/[id]/media-type': 'Node API: Token instance media type',
'/api/proxy': 'Node API: Proxy',
'/api/csrf': 'Node API: CSRF token',
'/api/healthz': 'Node API: Health check',
+2
View File
@@ -2,8 +2,10 @@
import type { TokenInstance } from 'types/api/token';
import * as addressMock from '../address/address';
import * as tokenInfoMock from './tokenInfo';
export const base: TokenInstance = {
token: tokenInfoMock.tokenInfoERC721a,
animation_url: null,
external_app_url: 'https://duck.nft/get-your-duck-today',
id: '32925298983216553915666621415831103694597106215670571463977478984525997408266',
+1 -1
View File
@@ -22,10 +22,10 @@ declare module "nextjs-routes" {
| StaticRoute<"/api/csrf">
| StaticRoute<"/api/healthz">
| StaticRoute<"/api/log">
| StaticRoute<"/api/media-type">
| StaticRoute<"/api/metrics">
| StaticRoute<"/api/monitoring/invalid-api-schema">
| StaticRoute<"/api/proxy">
| DynamicRoute<"/api/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
| StaticRoute<"/api-docs">
| DynamicRoute<"/apps/[id]", { "id": string }>
| StaticRoute<"/apps">
+7
View File
@@ -4,7 +4,14 @@ import buildUrl from 'nextjs/utils/buildUrl';
import fetchFactory from 'nextjs/utils/fetchProxy';
import { httpLogger } from 'nextjs/utils/logger';
import isNeedProxy from 'lib/api/isNeedProxy';
export default async function csrfHandler(_req: NextApiRequest, res: NextApiResponse) {
if (!isNeedProxy()) {
res.status(404).json({ error: 'Not found' });
return;
}
httpLogger(_req, res);
const url = buildUrl('general:csrf');
-43
View File
@@ -1,43 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import nodeFetch from 'node-fetch';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
import getQueryParamString from 'lib/router/getQueryParamString';
export default async function mediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
try {
const url = getQueryParamString(req.query.url);
const end = metrics?.apiRequestDuration.startTimer();
const response = await nodeFetch(url, { method: 'HEAD' });
const duration = end?.({ route: '/media-type', code: response.status });
if (response.status !== 200) {
httpLogger.logger.error({ message: 'API fetch', url, code: response.status, duration });
throw new Error();
}
const contentType = response.headers.get('content-type');
const mediaType = (() => {
if (contentType?.startsWith('video')) {
return 'video';
}
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) {
return 'html';
}
})();
httpLogger.logger.info({ message: 'API fetch', url, code: response.status, duration });
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
}
}
+5
View File
@@ -6,6 +6,11 @@ const isEnabled = process.env.PROMETHEUS_METRICS_ENABLED === 'true';
isEnabled && promClient.collectDefaultMetrics({ prefix: 'frontend_' });
export default async function metricsHandler(req: NextApiRequest, res: NextApiResponse) {
if (!isEnabled) {
res.status(404).json({ error: 'Not found' });
return;
}
const metrics = await promClient.register.metrics();
res.setHeader('Content-type', promClient.register.contentType);
res.send(metrics);
@@ -0,0 +1,69 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import nodeFetch from 'node-fetch';
import fetchApi from 'nextjs/utils/fetchApi';
import { httpLogger } from 'nextjs/utils/logger';
import metrics from 'lib/monitoring/metrics';
import getQueryParamString from 'lib/router/getQueryParamString';
import { SECOND } from 'toolkit/utils/consts';
export default async function tokenInstanceMediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort('Request to media asset timed out');
}, 10 * SECOND);
try {
const field = getQueryParamString(req.query.field) as 'animation_url' | 'image_url';
if (![ 'animation_url', 'image_url' ].includes(field)) {
throw new Error('Invalid field parameter');
}
const apiData = await fetchApi({
resource: 'general:token_instance',
pathParams: { hash: getQueryParamString(req.query.hash), id: getQueryParamString(req.query.id) },
timeout: SECOND,
});
if (!apiData) {
throw new Error('Failed to fetch token instance');
}
const mediaUrl = apiData[field];
if (!mediaUrl) {
throw new Error('No media URL found');
}
const mediaRequestEndTime = metrics?.apiRequestDuration.startTimer();
const mediaResponse = await nodeFetch(mediaUrl, { method: 'HEAD', signal: controller.signal });
const mediaRequestDuration = mediaRequestEndTime?.({ route: '/media-type', code: mediaResponse.status });
if (mediaResponse.status === 200) {
httpLogger.logger.info({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
} else {
httpLogger.logger.error({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
throw new Error();
}
const contentType = mediaResponse.headers.get('content-type');
const mediaType = (() => {
if (contentType?.startsWith('video')) {
return 'video';
}
if (contentType?.startsWith('image')) {
return 'image';
}
if (contentType?.startsWith('text/html')) {
return 'html';
}
})();
res.status(200).json({ type: mediaType });
} catch (error) {
res.status(200).json({ type: undefined });
} finally {
clearTimeout(timeout);
}
}
+3 -3
View File
@@ -89,23 +89,23 @@ export const ADDRESS_TOKEN_BALANCE_ERC_20: AddressTokenBalance = {
export const ADDRESS_NFT_721: AddressNFT = {
token_type: 'ERC-721',
token: TOKEN_INFO_ERC_721,
value: '1',
...TOKEN_INSTANCE,
token: TOKEN_INFO_ERC_721,
};
export const ADDRESS_NFT_1155: AddressNFT = {
token_type: 'ERC-1155',
token: TOKEN_INFO_ERC_1155,
value: '10',
...TOKEN_INSTANCE,
token: TOKEN_INFO_ERC_1155,
};
export const ADDRESS_NFT_404: AddressNFT = {
token_type: 'ERC-404',
token: TOKEN_INFO_ERC_404,
value: '10',
...TOKEN_INSTANCE,
token: TOKEN_INFO_ERC_404,
};
export const ADDRESS_COLLECTION: AddressCollection = {
+1
View File
@@ -167,6 +167,7 @@ export const getTokenInstanceTransfersStub = (type?: TokenType, pagination: Toke
};
export const TOKEN_INSTANCE: TokenInstance = {
token: TOKEN_INFO_ERC_721,
animation_url: null,
external_app_url: 'https://vipsland.com/nft/collections/genesis/188882',
id: '188882',
+1
View File
@@ -68,6 +68,7 @@ export interface TokenInstance {
metadata: Record<string, unknown> | null;
owner: AddressParam | null;
thumbnails: ({ original: string } & Partial<Record<Exclude<ThumbnailSize, 'original'>, string>>) | null;
token: TokenInfo;
}
export interface TokenInstanceMetadataSocketMessage {
+2 -1
View File
@@ -19,7 +19,8 @@ import NFTItemContainer from './NFTItemContainer';
type Props = AddressNFT & { isLoading: boolean; withTokenLink?: boolean; chain?: ClusterChainConfig };
const NFTItem = ({ token, value, isLoading, withTokenLink, chain, ...tokenInstance }: Props) => {
const NFTItem = ({ value, isLoading, withTokenLink, chain, ...tokenInstance }: Props) => {
const { token } = tokenInstance;
const valueResult = token.decimals && value ? calculateUsdValue({ amount: value, decimals: token.decimals, accuracy: 2 }).valueStr : value;
const tokenInstanceLink = tokenInstance.id ?
route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address_hash, id: tokenInstance.id } }, chain ? { chain } : undefined) :
+22 -2
View File
@@ -3,16 +3,22 @@ import React from 'react';
import type { TokenInstance } from 'types/api/token';
import * as tokenInfoMock from 'mocks/tokens/tokenInfo';
import { test, expect } from 'playwright/lib';
import NftMedia from './NftMedia';
const TOKEN_ID = '123';
const TOKEN_HASH = tokenInfoMock.tokenInfoERC721a.address_hash;
test.describe('no url', () => {
test.use({ viewport: { width: 250, height: 250 } });
test('preview +@dark-mode', async({ render }) => {
const data = {
id: TOKEN_ID,
image_url: null,
animation_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
const component = await render(<NftMedia data={ data }/>);
await expect(component).toHaveScreenshot();
@@ -21,8 +27,10 @@ test.describe('no url', () => {
test('with fallback', async({ render, mockAssetResponse }) => {
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
const data = {
id: TOKEN_ID,
image_url: IMAGE_URL,
animation_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
await mockAssetResponse(IMAGE_URL, './playwright/mocks/image_long.jpg');
@@ -32,11 +40,13 @@ test.describe('no url', () => {
test('non-media url and fallback', async({ render, page, mockAssetResponse }) => {
const ANIMATION_URL = 'https://localhost:3000/my-animation.m3u8';
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(ANIMATION_URL) }`;
const ANIMATION_MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
const IMAGE_URL = 'https://localhost:3000/my-image.jpg';
const data = {
id: TOKEN_ID,
animation_url: ANIMATION_URL,
image_url: IMAGE_URL,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
await page.route(ANIMATION_MEDIA_TYPE_API_URL, (route) => {
@@ -61,8 +71,10 @@ test.describe('image', () => {
test('preview +@dark-mode', async({ render, page }) => {
const data = {
id: TOKEN_ID,
animation_url: MEDIA_URL,
image_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
await render(
<Box boxSize="250px">
@@ -75,11 +87,13 @@ test.describe('image', () => {
test('preview with thumbnails', async({ render, page, mockAssetResponse }) => {
const THUMBNAIL_URL = 'https://localhost:3000/my-image-250.jpg';
const data = {
id: TOKEN_ID,
animation_url: MEDIA_URL,
image_url: null,
thumbnails: {
'500x500': THUMBNAIL_URL,
},
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
await mockAssetResponse(THUMBNAIL_URL, './playwright/mocks/image_md.jpg');
await render(
@@ -92,8 +106,10 @@ test.describe('image', () => {
test('preview hover', async({ render, page }) => {
const data = {
id: TOKEN_ID,
animation_url: MEDIA_URL,
image_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
const component = await render(<NftMedia data={ data } w="250px" size="md"/>);
await component.getByRole('img', { name: 'Token instance image' }).hover();
@@ -102,8 +118,10 @@ test.describe('image', () => {
test('fullscreen +@dark-mode +@mobile', async({ render, page }) => {
const data = {
id: TOKEN_ID,
animation_url: MEDIA_URL,
image_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
const component = await render(<NftMedia data={ data } withFullscreen w="250px"/>);
await component.getByRole('img', { name: 'Token instance image' }).click();
@@ -115,7 +133,7 @@ test.describe('page', () => {
test.use({ viewport: { width: 250, height: 250 } });
const MEDIA_URL = 'https://localhost:3000/page.html';
const MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(MEDIA_URL) }`;
const MEDIA_TYPE_API_URL = `/node-api/tokens/${ TOKEN_HASH }/instances/${ TOKEN_ID }/media-type?field=animation_url`;
test.beforeEach(async({ page, mockAssetResponse }) => {
await mockAssetResponse(MEDIA_URL, './playwright/mocks/page.html');
@@ -127,8 +145,10 @@ test.describe('page', () => {
test('preview +@dark-mode', async({ render }) => {
const data = {
id: TOKEN_ID,
animation_url: MEDIA_URL,
image_url: null,
token: tokenInfoMock.tokenInfoERC721a,
} as TokenInstance;
const component = await render(<NftMedia data={ data }/>);
+10 -2
View File
@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import type { TokenInstance } from 'types/api/token';
import type { StaticRoute } from 'nextjs-routes';
import type { DynamicRoute } from 'nextjs-routes';
import { route } from 'nextjs-routes';
import config from 'configs/app';
@@ -81,7 +81,15 @@ async function getMediaType(data: TokenInstance, field: Params['field']): Promis
}
try {
const mediaTypeResourceUrl = route({ pathname: '/node-api/media-type' as StaticRoute<'/api/media-type'>['pathname'], query: { url } });
const mediaTypeResourceUrl = route({
// eslint-disable-next-line max-len
pathname: '/node-api/tokens/[hash]/instances/[id]/media-type' as DynamicRoute<'/api/tokens/[hash]/instances/[id]/media-type', { hash: string; id: string }>['pathname'],
query: {
hash: data.token.address_hash,
id: data.id,
field,
},
});
const response = await fetch(mediaTypeResourceUrl);
const payload = await response.json() as { type: MediaType | undefined };