From 89c3b7ac9c0c2509954daccfff17d20b9b45e7b0 Mon Sep 17 00:00:00 2001 From: doresa0 Date: Thu, 13 Aug 2026 08:40:10 +0300 Subject: [PATCH] fix(api): validate preview CORS origins by hostname Faucet and Index Supply CORS used origin.includes('vercel.app'), which can reflect attacker-controlled hosts that merely contain that substring. Match feedback.ts: parse the Origin URL and allow only https hosts that are tempo.xyz, *.tempo.xyz, or *.vercel.app. Closes #681 --- src/pages/_api/api/faucet.ts | 30 ++++++++++++++++++++++++------ src/pages/_api/api/index-supply.ts | 29 +++++++++++++++++++++++------ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/pages/_api/api/faucet.ts b/src/pages/_api/api/faucet.ts index 670567814..18540b601 100644 --- a/src/pages/_api/api/faucet.ts +++ b/src/pages/_api/api/faucet.ts @@ -53,18 +53,36 @@ async function fund(address: `0x${string}`, headers: Record): Pr } } -function cors(origin: string | null): Record { - const allowedOrigins = ['https://tempo.xyz', 'https://docs.tempo.xyz'] - - if (origin?.includes('vercel.app')) allowedOrigins.push(origin) - if (process.env.NODE_ENV === 'development') allowedOrigins.push('http://localhost:5173') +function isAllowedPreviewOrigin(origin: string): boolean { + try { + const { hostname, protocol } = new URL(origin) + if (protocol !== 'https:') return false + // Hostname suffix — not substring — so attacker.example containing + // "vercel.app" in the path/host cannot spoof preview CORS. + return ( + hostname === 'tempo.xyz' || + hostname.endsWith('.tempo.xyz') || + hostname.endsWith('.vercel.app') + ) + } catch { + return false + } +} +function cors(origin: string | null): Record { const headers: Record = { 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, x-api-token', } - if (origin && allowedOrigins.includes(origin)) headers['Access-Control-Allow-Origin'] = origin + if (process.env.NODE_ENV === 'development' && origin === 'http://localhost:5173') { + headers['Access-Control-Allow-Origin'] = origin + return headers + } + + if (origin && isAllowedPreviewOrigin(origin)) { + headers['Access-Control-Allow-Origin'] = origin + } return headers } diff --git a/src/pages/_api/api/index-supply.ts b/src/pages/_api/api/index-supply.ts index 2035baf99..1f85464f5 100644 --- a/src/pages/_api/api/index-supply.ts +++ b/src/pages/_api/api/index-supply.ts @@ -119,19 +119,36 @@ export async function OPTIONS(request: Request): Promise { return new Response(null, { status: 200, headers: cors(origin) }) } -function cors(origin: string | null): Record { - const allowedOrigins = ['https://tempo.xyz', 'https://mainnet.docs.tempo.xyz'] - - if (origin?.includes('vercel.app')) allowedOrigins.push(origin) - if (process.env.NODE_ENV === 'development') allowedOrigins.push('http://localhost:5173') +function isAllowedPreviewOrigin(origin: string): boolean { + try { + const { hostname, protocol } = new URL(origin) + if (protocol !== 'https:') return false + // Hostname suffix — not substring — so attacker.example containing + // "vercel.app" in the path/host cannot spoof preview CORS. + return ( + hostname === 'tempo.xyz' || + hostname.endsWith('.tempo.xyz') || + hostname.endsWith('.vercel.app') + ) + } catch { + return false + } +} +function cors(origin: string | null): Record { const headers: Record = { 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, x-api-token', } - if (origin && allowedOrigins.some((allowed) => origin.startsWith(allowed))) + if (process.env.NODE_ENV === 'development' && origin === 'http://localhost:5173') { + headers['Access-Control-Allow-Origin'] = origin + return headers + } + + if (origin && isAllowedPreviewOrigin(origin)) { headers['Access-Control-Allow-Origin'] = origin + } return headers }