From ee662f54d02f1fa0f908deee2d603fd38032e764 Mon Sep 17 00:00:00 2001 From: dan13ram Date: Sat, 26 Sep 2026 17:20:09 +0530 Subject: [PATCH] fix(web): harden token image rendering --- .../api/dao/[daoId]/token/[tokenId]/route.ts | 10 +- .../app/api/render/[daoId]/[tokenId]/route.ts | 122 +++++++++++------- .../app/dao/[daoId]/token/[tokenId]/page.tsx | 17 +-- apps/web/src/lib/token-id.test.ts | 15 +++ apps/web/src/lib/token-id.ts | 8 ++ 5 files changed, 104 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/lib/token-id.test.ts create mode 100644 apps/web/src/lib/token-id.ts diff --git a/apps/web/src/app/api/dao/[daoId]/token/[tokenId]/route.ts b/apps/web/src/app/api/dao/[daoId]/token/[tokenId]/route.ts index 171018c..46bc638 100644 --- a/apps/web/src/app/api/dao/[daoId]/token/[tokenId]/route.ts +++ b/apps/web/src/app/api/dao/[daoId]/token/[tokenId]/route.ts @@ -2,18 +2,10 @@ import { NextResponse } from 'next/server'; import { getDaoNetworkConfigById } from '@/lib/dao-config'; import { resolveOnchainTokenMetadata } from '@/lib/onchain-token-metadata'; +import { parseTokenId } from '@/lib/token-id'; export const dynamic = 'force-dynamic'; -function parseTokenId(value: string) { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error('Invalid token id'); - } - - return parsed; -} - export async function GET(request: Request, { params }: { params: Promise<{ daoId: string; tokenId: string }> }) { try { const { daoId, tokenId } = await params; diff --git a/apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts b/apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts index f185cbf..f116662 100644 --- a/apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts +++ b/apps/web/src/app/api/render/[daoId]/[tokenId]/route.ts @@ -6,26 +6,35 @@ import { Agent, fetch } from 'undici'; import { getDaoNetworkConfigById } from '@/lib/dao-config'; import { assertSafeRemoteUrl, getFetchableUrls, IPFS_GATEWAYS } from '@/lib/ipfs-gateway'; import { resolveOnchainTokenMetadata } from '@/lib/onchain-token-metadata'; +import { parseTokenId } from '@/lib/token-id'; export const dynamic = 'force-dynamic'; export const runtime = 'nodejs'; const REQUEST_TIMEOUT_MS = 20_000; const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const MAX_TOTAL_IMAGE_BYTES = 32 * 1024 * 1024; +const MAX_LAYERS = 16; const MAX_REDIRECTS = 3; const MAX_INPUT_PIXELS = 16_777_216; const SIZE = 1080; -// MVP supports static raster layers only. SVG and animated formats are rejected -// so untrusted markup and time-varying output never enter the compositor. -const ALLOWED_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); - -function parseTokenId(value: string) { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) throw new Error('Invalid token id'); - return parsed; +const ALLOWED_IMAGE_FORMATS = new Set(['png', 'jpeg', 'webp']); + +function abortError() { + return new DOMException('Artwork request timed out', 'AbortError'); +} + +async function withSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) throw abortError(); + + return new Promise((resolve, reject) => { + const abort = () => reject(abortError()); + signal.addEventListener('abort', abort, { once: true }); + promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + }); } -async function fetchImage(uri: string) { +async function fetchImage(uri: string, signal: AbortSignal, maxBytes: number) { const urls = getFetchableUrls(uri); if (!urls?.length) throw new Error(`Unsupported artwork URL: ${uri}`); @@ -35,7 +44,7 @@ async function fetchImage(uri: string) { try { for (let redirect = 0; redirect <= MAX_REDIRECTS; redirect += 1) { const isIpfsGateway = IPFS_GATEWAYS.some((gateway) => new URL(gateway).hostname === new URL(url).hostname); - const addresses = await assertSafeRemoteUrl(url, isIpfsGateway); + const addresses = await withSignal(assertSafeRemoteUrl(url, isIpfsGateway), signal); const pinnedAddress = addresses[0]; const dispatcher = new Agent({ connect: { @@ -45,12 +54,10 @@ async function fetchImage(uri: string) { } }); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await fetch(url, { redirect: 'manual', - signal: controller.signal, + signal, dispatcher }); @@ -63,13 +70,8 @@ async function fetchImage(uri: string) { } if (!response.ok) throw new Error(`HTTP ${response.status}`); - const contentType = response.headers.get('content-type')?.split(';')[0].toLowerCase(); - if (!contentType || !ALLOWED_MIME_TYPES.has(contentType)) { - throw new Error(`Unsupported artwork MIME type: ${contentType || 'unknown'}`); - } - const contentLength = Number(response.headers.get('content-length')); - if (Number.isFinite(contentLength) && contentLength > MAX_IMAGE_BYTES) { + if (Number.isFinite(contentLength) && contentLength > maxBytes) { throw new Error('Artwork exceeds size limit'); } @@ -81,12 +83,16 @@ async function fetchImage(uri: string) { const { done, value } = await reader.read(); if (done) break; totalBytes += value.byteLength; - if (totalBytes > MAX_IMAGE_BYTES) throw new Error('Artwork exceeds size limit'); + if (totalBytes > maxBytes) throw new Error('Artwork exceeds size limit'); chunks.push(Buffer.from(value)); } - return Buffer.concat(chunks, totalBytes); + const image = Buffer.concat(chunks, totalBytes); + const metadata = await withSignal(sharp(image, { limitInputPixels: MAX_INPUT_PIXELS }).metadata(), signal); + if (!metadata.format || !ALLOWED_IMAGE_FORMATS.has(metadata.format)) { + throw new Error('Unsupported artwork format'); + } + return image; } finally { - clearTimeout(timeout); await dispatcher.close(); } } @@ -98,42 +104,57 @@ async function fetchImage(uri: string) { throw lastError || new Error('Artwork fetch failed'); } -function fallbackSvg(message: string) { - const safe = message.replace( - /[&<>"']/g, - (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character] || character - ); - return `Artwork unavailable${safe}`; +function fallbackSvg() { + return 'Artwork unavailable'; } export async function GET(request: Request, { params }: { params: Promise<{ daoId: string; tokenId: string }> }) { const { daoId, tokenId } = await params; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const resolvedTokenId = parseTokenId(tokenId); - const config = await getDaoNetworkConfigById(daoId); + const config = await withSignal(getDaoNetworkConfigById(daoId), controller.signal); const origin = new URL(request.url).origin; - const metadata = await resolveOnchainTokenMetadata( - config, - resolvedTokenId, - `${origin}/api/render/${daoId}/${resolvedTokenId}` + const metadata = await withSignal( + resolveOnchainTokenMetadata(config, resolvedTokenId, `${origin}/api/render/${daoId}/${resolvedTokenId}`), + controller.signal ); if (metadata.artwork.length === 0) throw new Error(`No artwork found for token ${resolvedTokenId}`); - const layers = await Promise.all(metadata.artwork.map(({ url }) => fetchImage(url))); - const base = await sharp(layers[0], { limitInputPixels: MAX_INPUT_PIXELS }) - .resize(SIZE, SIZE, { fit: 'contain' }) - .png() - .toBuffer(); - const overlays = await Promise.all( - layers - .slice(1) - .map((layer) => - sharp(layer, { limitInputPixels: MAX_INPUT_PIXELS }).resize(SIZE, SIZE, { fit: 'contain' }).png().toBuffer() + if (metadata.artwork.length > MAX_LAYERS) throw new Error('Artwork has too many layers'); + + const layers: Buffer[] = []; + let totalBytes = 0; + for (const { url } of metadata.artwork) { + const layer = await fetchImage( + url, + controller.signal, + Math.min(MAX_IMAGE_BYTES, MAX_TOTAL_IMAGE_BYTES - totalBytes) + ); + totalBytes += layer.byteLength; + layers.push(layer); + } + + const base = await withSignal( + sharp(layers[0], { limitInputPixels: MAX_INPUT_PIXELS }).resize(SIZE, SIZE, { fit: 'contain' }).png().toBuffer(), + controller.signal + ); + const overlays: Buffer[] = []; + for (const layer of layers.slice(1)) { + overlays.push( + await withSignal( + sharp(layer, { limitInputPixels: MAX_INPUT_PIXELS }).resize(SIZE, SIZE, { fit: 'contain' }).png().toBuffer(), + controller.signal ) + ); + } + const image = await withSignal( + sharp(base) + .composite(overlays.map((input) => ({ input }))) + .webp({ quality: 85 }) + .toBuffer(), + controller.signal ); - const image = await sharp(base) - .composite(overlays.map((input) => ({ input }))) - .webp({ quality: 85 }) - .toBuffer(); return new Response(image, { headers: { @@ -142,13 +163,16 @@ export async function GET(request: Request, { params }: { params: Promise<{ daoI } }); } catch (error) { - return new Response(fallbackSvg(error instanceof Error ? error.message : 'Unable to render token'), { + console.error('Token artwork render failed', error); + return new Response(fallbackSvg(), { status: 200, headers: { - 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + 'Cache-Control': 'no-store', 'Content-Type': 'image/svg+xml; charset=utf-8', 'X-Renderer-Fallback': 'true' } }); + } finally { + clearTimeout(timeout); } } diff --git a/apps/web/src/app/dao/[daoId]/token/[tokenId]/page.tsx b/apps/web/src/app/dao/[daoId]/token/[tokenId]/page.tsx index 383c9e1..e9c8f06 100644 --- a/apps/web/src/app/dao/[daoId]/token/[tokenId]/page.tsx +++ b/apps/web/src/app/dao/[daoId]/token/[tokenId]/page.tsx @@ -7,19 +7,16 @@ import { Grid, Stack } from 'styled-system/jsx'; import { PageSection } from '@/components/page-section'; import { Badge, Card, ShortId, Text } from '@/components/ui'; import { TOKEN_DESCRIPTION, TOKEN_NAME } from '@/lib/token-config'; - -function parseTokenId(value: string) { - if (!/^\d+$/.test(value)) notFound(); - - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) notFound(); - - return parsed; -} +import { parseTokenId } from '@/lib/token-id'; export default async function TokenPage({ params }: { params: Promise<{ daoId: string; tokenId: string }> }) { const { daoId, tokenId } = await params; - const displayTokenId = parseTokenId(tokenId); + let displayTokenId: number; + try { + displayTokenId = parseTokenId(tokenId); + } catch { + notFound(); + } const tokenName = `${TOKEN_NAME} #${displayTokenId}`; return ( diff --git a/apps/web/src/lib/token-id.test.ts b/apps/web/src/lib/token-id.test.ts new file mode 100644 index 0000000..105b4ba --- /dev/null +++ b/apps/web/src/lib/token-id.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTokenId } from './token-id'; + +describe('parseTokenId', () => { + it('accepts non-negative safe integers', () => { + expect(parseTokenId('0')).toBe(0); + expect(parseTokenId('123')).toBe(123); + expect(parseTokenId('9007199254740991')).toBe(Number.MAX_SAFE_INTEGER); + }); + + it.each(['', ' 1', '+1', '-1', '1.5', '1e3', '1junk', '9007199254740992'])('rejects %j', (value) => { + expect(() => parseTokenId(value)).toThrow('Invalid token id'); + }); +}); diff --git a/apps/web/src/lib/token-id.ts b/apps/web/src/lib/token-id.ts new file mode 100644 index 0000000..ef98696 --- /dev/null +++ b/apps/web/src/lib/token-id.ts @@ -0,0 +1,8 @@ +export function parseTokenId(value: string) { + if (!/^\d+$/.test(value)) throw new Error('Invalid token id'); + + const tokenId = Number(value); + if (!Number.isSafeInteger(tokenId)) throw new Error('Invalid token id'); + + return tokenId; +}