180 lines
7.8 KiB
TypeScript
180 lines
7.8 KiB
TypeScript
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' });
|
||
}
|
||
}
|