Skip to content

Commit 76b7603

Browse files
authored
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
1 parent 10c4c52 commit 76b7603

File tree

16 files changed

+127
-56
lines changed

16 files changed

+127
-56
lines changed

lib/metadata/getPageOgType.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ const OG_TYPE_DICT: Record<Route['pathname'], OGPageType> = {
9090
'/api/metrics': 'Regular page',
9191
'/api/monitoring/invalid-api-schema': 'Regular page',
9292
'/api/log': 'Regular page',
93-
'/api/media-type': 'Regular page',
93+
'/api/tokens/[hash]/instances/[id]/media-type': 'Regular page',
9494
'/api/proxy': 'Regular page',
9595
'/api/csrf': 'Regular page',
9696
'/api/healthz': 'Regular page',

lib/metadata/templates/description.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
9393
'/api/metrics': DEFAULT_TEMPLATE,
9494
'/api/monitoring/invalid-api-schema': DEFAULT_TEMPLATE,
9595
'/api/log': DEFAULT_TEMPLATE,
96-
'/api/media-type': DEFAULT_TEMPLATE,
96+
'/api/tokens/[hash]/instances/[id]/media-type': DEFAULT_TEMPLATE,
9797
'/api/proxy': DEFAULT_TEMPLATE,
9898
'/api/csrf': DEFAULT_TEMPLATE,
9999
'/api/healthz': DEFAULT_TEMPLATE,

lib/metadata/templates/title.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const TEMPLATE_MAP: Record<Route['pathname'], string> = {
9494
'/api/metrics': '%network_name% node API prometheus metrics',
9595
'/api/monitoring/invalid-api-schema': '%network_name% node API prometheus metrics',
9696
'/api/log': '%network_name% node API request log',
97-
'/api/media-type': '%network_name% node API media type',
97+
'/api/tokens/[hash]/instances/[id]/media-type': '%network_name% node API token instance media type',
9898
'/api/proxy': '%network_name% node API proxy',
9999
'/api/csrf': '%network_name% node API CSRF token',
100100
'/api/healthz': '%network_name% node API health check',

lib/mixpanel/getPageType.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const PAGE_TYPE_DICT: Record<Route['pathname'], string> = {
8888
'/api/metrics': 'Node API: Prometheus metrics',
8989
'/api/monitoring/invalid-api-schema': 'Node API: Prometheus metrics',
9090
'/api/log': 'Node API: Request log',
91-
'/api/media-type': 'Node API: Media type',
91+
'/api/tokens/[hash]/instances/[id]/media-type': 'Node API: Token instance media type',
9292
'/api/proxy': 'Node API: Proxy',
9393
'/api/csrf': 'Node API: CSRF token',
9494
'/api/healthz': 'Node API: Health check',

mocks/tokens/tokenInstance.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
import type { TokenInstance } from 'types/api/token';
33

44
import * as addressMock from '../address/address';
5+
import * as tokenInfoMock from './tokenInfo';
56

67
export const base: TokenInstance = {
8+
token: tokenInfoMock.tokenInfoERC721a,
79
animation_url: null,
810
external_app_url: 'https://duck.nft/get-your-duck-today',
911
id: '32925298983216553915666621415831103694597106215670571463977478984525997408266',

nextjs/nextjs-routes.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ declare module "nextjs-routes" {
2222
| StaticRoute<"/api/csrf">
2323
| StaticRoute<"/api/healthz">
2424
| StaticRoute<"/api/log">
25-
| StaticRoute<"/api/media-type">
2625
| StaticRoute<"/api/metrics">
2726
| StaticRoute<"/api/monitoring/invalid-api-schema">
2827
| StaticRoute<"/api/proxy">
28+
| DynamicRoute<"/api/tokens/[hash]/instances/[id]/media-type", { "hash": string; "id": string }>
2929
| StaticRoute<"/api-docs">
3030
| DynamicRoute<"/apps/[id]", { "id": string }>
3131
| StaticRoute<"/apps">

pages/api/csrf.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import buildUrl from 'nextjs/utils/buildUrl';
44
import fetchFactory from 'nextjs/utils/fetchProxy';
55
import { httpLogger } from 'nextjs/utils/logger';
66

7+
import isNeedProxy from 'lib/api/isNeedProxy';
8+
79
export default async function csrfHandler(_req: NextApiRequest, res: NextApiResponse) {
10+
if (!isNeedProxy()) {
11+
res.status(404).json({ error: 'Not found' });
12+
return;
13+
}
14+
815
httpLogger(_req, res);
916

1017
const url = buildUrl('general:csrf');

pages/api/media-type.ts

Lines changed: 0 additions & 43 deletions
This file was deleted.

pages/api/metrics.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ const isEnabled = process.env.PROMETHEUS_METRICS_ENABLED === 'true';
66
isEnabled && promClient.collectDefaultMetrics({ prefix: 'frontend_' });
77

88
export default async function metricsHandler(req: NextApiRequest, res: NextApiResponse) {
9+
if (!isEnabled) {
10+
res.status(404).json({ error: 'Not found' });
11+
return;
12+
}
13+
914
const metrics = await promClient.register.metrics();
1015
res.setHeader('Content-type', promClient.register.contentType);
1116
res.send(metrics);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { NextApiRequest, NextApiResponse } from 'next';
2+
import nodeFetch from 'node-fetch';
3+
4+
import fetchApi from 'nextjs/utils/fetchApi';
5+
import { httpLogger } from 'nextjs/utils/logger';
6+
7+
import metrics from 'lib/monitoring/metrics';
8+
import getQueryParamString from 'lib/router/getQueryParamString';
9+
import { SECOND } from 'toolkit/utils/consts';
10+
11+
export default async function tokenInstanceMediaTypeHandler(req: NextApiRequest, res: NextApiResponse) {
12+
const controller = new AbortController();
13+
const timeout = setTimeout(() => {
14+
controller.abort('Request to media asset timed out');
15+
}, 10 * SECOND);
16+
17+
try {
18+
const field = getQueryParamString(req.query.field) as 'animation_url' | 'image_url';
19+
if (![ 'animation_url', 'image_url' ].includes(field)) {
20+
throw new Error('Invalid field parameter');
21+
}
22+
23+
const apiData = await fetchApi({
24+
resource: 'general:token_instance',
25+
pathParams: { hash: getQueryParamString(req.query.hash), id: getQueryParamString(req.query.id) },
26+
timeout: SECOND,
27+
});
28+
if (!apiData) {
29+
throw new Error('Failed to fetch token instance');
30+
}
31+
32+
const mediaUrl = apiData[field];
33+
if (!mediaUrl) {
34+
throw new Error('No media URL found');
35+
}
36+
37+
const mediaRequestEndTime = metrics?.apiRequestDuration.startTimer();
38+
const mediaResponse = await nodeFetch(mediaUrl, { method: 'HEAD', signal: controller.signal });
39+
const mediaRequestDuration = mediaRequestEndTime?.({ route: '/media-type', code: mediaResponse.status });
40+
41+
if (mediaResponse.status === 200) {
42+
httpLogger.logger.info({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
43+
} else {
44+
httpLogger.logger.error({ message: 'API fetch', url: mediaUrl, code: mediaResponse.status, duration: mediaRequestDuration });
45+
throw new Error();
46+
}
47+
48+
const contentType = mediaResponse.headers.get('content-type');
49+
const mediaType = (() => {
50+
if (contentType?.startsWith('video')) {
51+
return 'video';
52+
}
53+
54+
if (contentType?.startsWith('image')) {
55+
return 'image';
56+
}
57+
58+
if (contentType?.startsWith('text/html')) {
59+
return 'html';
60+
}
61+
})();
62+
63+
res.status(200).json({ type: mediaType });
64+
} catch (error) {
65+
res.status(200).json({ type: undefined });
66+
} finally {
67+
clearTimeout(timeout);
68+
}
69+
}

0 commit comments

Comments
 (0)