import { NextFunction, Request, Response, Router } from 'express'; import { env } from '../config/env'; import { logger } from '../lib/logger'; import { WalletModel } from '../models/wallet.model'; import type { ChainCode } from '../lib/address-validators'; import { FIXED_API_USER_ID } from '../middleware/fixed-user'; const router = Router(); const RELAY_API_URL = 'https://api.relay.link'; const RELAY_TIMEOUT_MS = 20_000; // chainId → ChainCode. Relay использует EVM chainIds + custom большие для не-EVM. const RELAY_CHAINID_TO_CHAIN: Record = { 1: 'ETH', 56: 'BSC', 792703809: 'SOL', }; // Whitelist: GET-paths + POST-paths + allowed `/execute/` actions. // Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check. // Relay API: `POST /quote` (раньше был `GET /quote/v2` — upstream сменили в 2025). const ALLOWED_GET_PATHS = new Set(['/intents/status/v3']); const ALLOWED_POST_PATHS = new Set(['/quote']); const ALLOWED_EXECUTE_ACTIONS = new Set([ 'swap', 'bridge', // добавлять по мере необходимости ]); router.use(proxyRelayRequest); export default router; async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) { try { const relayPath = req.path; // Whitelist matching — никакого freeform после `/execute/`. // Каждый path matches только своему method'у, чтобы клиент не мог GET-ом дёрнуть POST endpoint. let allowed = false; if (req.method === 'GET' && ALLOWED_GET_PATHS.has(relayPath)) { allowed = true; } else if (req.method === 'POST' && ALLOWED_POST_PATHS.has(relayPath)) { allowed = true; } else if (req.method === 'POST' && relayPath.startsWith('/execute/')) { const action = relayPath.slice('/execute/'.length); // action: только alphanumeric, никаких слешей/дотов if (/^[a-z][a-z0-9-]{0,32}$/i.test(action) && ALLOWED_EXECUTE_ACTIONS.has(action)) { allowed = true; } } if (!allowed) { res.status(404).json({ success: false, error: 'Relay endpoint not allowed' }); return; } // C16 — bind body.user / body.recipient to JWT user's wallet. // Без этого authenticated user может set recipient=attacker → Relay строит quote → // victim signs → bridge funds к attacker'у. if (req.method === 'POST' && (relayPath === '/quote' || relayPath.startsWith('/execute/'))) { const userId = FIXED_API_USER_ID; const bodyUser = req.body?.user; const bodyRecipient = req.body?.recipient; const originChainId = Number(req.body?.originChainId); const destinationChainId = Number(req.body?.destinationChainId); if (typeof bodyUser !== 'string' || !bodyUser) { res.status(400).json({ success: false, error: 'Missing body.user' }); return; } const originChain = RELAY_CHAINID_TO_CHAIN[originChainId]; if (!originChain) { res.status(400).json({ success: false, error: `originChainId ${originChainId} not in allowlist (1=ETH, 56=BSC, 792703809=SOL)` }); return; } // Bind: body.user must equal user's wallet for originChain try { const wallet = await WalletModel.findByUserAndChain(userId, originChain); if (!wallet) throw new Error(`No ${originChain} wallet for user`); const isEvm = originChain === 'ETH' || originChain === 'BSC'; const match = isEvm ? bodyUser.toLowerCase() === wallet.address.toLowerCase() : bodyUser === wallet.address; if (!match) throw new Error(`body.user ${bodyUser} ≠ user's ${originChain} wallet ${wallet.address}`); } catch (err: any) { res.status(403).json({ success: false, error: err.message }); return; } // Bind recipient (if provided) — must equal user's wallet for destinationChain. // Если destinationChainId не в whitelist — recipient мы проверить не можем; reject. if (bodyRecipient !== undefined && bodyRecipient !== null) { const destChain = RELAY_CHAINID_TO_CHAIN[destinationChainId]; if (!destChain) { res.status(400).json({ success: false, error: `destinationChainId ${destinationChainId} not in allowlist` }); return; } try { const dstWallet = await WalletModel.findByUserAndChain(userId, destChain); if (!dstWallet) throw new Error(`No ${destChain} wallet for user (cannot validate recipient)`); const isEvm = destChain === 'ETH' || destChain === 'BSC'; const match = isEvm ? String(bodyRecipient).toLowerCase() === dstWallet.address.toLowerCase() : String(bodyRecipient) === dstWallet.address; if (!match) throw new Error(`body.recipient ${bodyRecipient} ≠ user's ${destChain} wallet ${dstWallet.address}`); } catch (err: any) { res.status(403).json({ success: false, error: err.message }); return; } } } const relayUrl = new URL(`${RELAY_API_URL}${relayPath}`); Object.entries(req.query).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach((item) => relayUrl.searchParams.append(key, String(item))); return; } if (typeof value !== 'undefined') { relayUrl.searchParams.set(key, String(value)); } }); // Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely. const controller = new AbortController(); const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS); let upstream: globalThis.Response; try { upstream = await fetch(relayUrl.toString(), { method: req.method, headers: { Accept: 'application/json', ...(req.method !== 'GET' ? { 'Content-Type': 'application/json' } : {}), ...(env.relayApiKey ? { Authorization: `Bearer ${env.relayApiKey}` } : {}), }, body: req.method === 'GET' ? undefined : JSON.stringify(req.body ?? {}), signal: controller.signal, }); } finally { clearTimeout(t); } // Force JSON content-type — иначе compromised upstream может вернуть text/html // → reflected XSS если frontend рендерит ответ напрямую. res.status(upstream.status); res.type('application/json'); const text = await upstream.text(); if (!upstream.ok) { logger.warn(`Relay upstream ${upstream.status}: ${text.slice(0, 200)}`); // Пробрасываем Relay error JSON клиенту — он сам пишет structured payload // {message, errorCode, requestId, ...}. Content-Type уже forced на JSON выше, // так что HTML-injection невозможен. Parsable наружу — клиент видит реальную причину. let parsed: unknown = null; try { parsed = JSON.parse(text); } catch { /* not JSON — wrap in safe envelope */ } if (parsed && typeof parsed === 'object') { res.json({ success: false, error: 'Relay upstream error', upstream: parsed }); } else { res.json({ success: false, error: 'Relay upstream error' }); } return; } // Send raw text если это валидный JSON, иначе обернём try { res.send(text); } catch { res.json({ success: false, error: 'Relay returned non-JSON' }); } } catch (error: any) { if (error?.name === 'AbortError') { res.status(504).json({ success: false, error: 'Relay request timeout' }); return; } logger.error(`Relay proxy failed: ${error?.stack || error?.message}`); res.status(502).json({ success: false, error: 'Relay proxy error' }); } }