Files
cryptowallet/apps/api/src/routes/relay-proxy.routes.ts
2026-05-14 02:23:45 +03:00

180 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, ChainCode> = {
1: 'ETH',
56: 'BSC',
792703809: 'SOL',
};
// Whitelist: GET-paths + POST-paths + allowed `/execute/<action>` 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' });
}
}