security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)
This commit is contained in:
@@ -85,8 +85,8 @@ async function getSwapQuote(req: Request, res: Response) {
|
||||
toDecimals: TOKEN_DECIMALS[to],
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to get BSC swap quote';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to get BSC swap quote' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,18 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
|
||||
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
|
||||
// → sandwich attack осушает swap.
|
||||
if (!/^\d+$/.test(String(amount)) || BigInt(amount) <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'Invalid amount (must be positive integer string)' });
|
||||
return;
|
||||
}
|
||||
if (!/^\d+$/.test(String(amountOutMin)) || BigInt(amountOutMin) <= 0n) {
|
||||
res.status(400).json({ success: false, error: 'Invalid amountOutMin (must be positive; 0 = unlimited slippage)' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = new ethers.providers.StaticJsonRpcProvider(BSC_RPC, BSC_CHAIN_ID);
|
||||
const routerContract = new ethers.Contract(PANCAKE_ROUTER, ROUTER_ABI, provider);
|
||||
@@ -145,10 +157,12 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
);
|
||||
|
||||
if (currentAllowance.lt(ethers.BigNumber.from(amount))) {
|
||||
// Build approve tx
|
||||
// Exact-amount approve (НЕ MaxUint256!). Infinite approval = post-tx attack vector:
|
||||
// если router compromised или attacker узнаёт private key позже, attacker дренит
|
||||
// всё что approved. Approve только то что нужно сейчас.
|
||||
const approveData = tokenContract.interface.encodeFunctionData(
|
||||
'approve',
|
||||
[PANCAKE_ROUTER, ethers.constants.MaxUint256]
|
||||
[PANCAKE_ROUTER, amount]
|
||||
);
|
||||
|
||||
transactions.push({
|
||||
@@ -175,8 +189,8 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
|
||||
res.json({ success: true, transactions });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build BSC swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,8 @@ async function getFeeEstimates(_req: Request, res: Response) {
|
||||
async function broadcastTx(req: Request, res: Response) {
|
||||
const { hex } = req.body;
|
||||
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) {
|
||||
// BTC max tx serialized ~100KB = 200_000 hex chars. Cap чтобы не abuse'или bandwidth.
|
||||
if (!hex || typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex) || hex.length > 200_000) {
|
||||
res.status(400).json({ success: false, error: 'Invalid transaction hex' });
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +131,8 @@ async function broadcastTx(req: Request, res: Response) {
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
res.status(response.status).json({ success: false, error: text || 'Broadcast failed' });
|
||||
// Don't leak Blockstream error body (could contain UTXO state oracle).
|
||||
res.status(502).json({ success: false, error: 'BTC broadcast failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { NextFunction, Request, Response, Router } from 'express';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
const router = Router();
|
||||
const RELAY_API_URL = 'https://api.relay.link';
|
||||
const ALLOWED_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||
const RELAY_TIMEOUT_MS = 20_000;
|
||||
|
||||
// Whitelist: GET-paths + allowed `/execute/<action>` actions.
|
||||
// Без него `req.path` после `/execute/` свободен → `/execute/../admin` обходит startsWith-check.
|
||||
const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
|
||||
const ALLOWED_EXECUTE_ACTIONS = new Set([
|
||||
'swap',
|
||||
'bridge',
|
||||
// добавлять по мере необходимости
|
||||
]);
|
||||
|
||||
router.use(proxyRelayRequest);
|
||||
|
||||
@@ -12,7 +22,19 @@ export default router;
|
||||
async function proxyRelayRequest(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const relayPath = req.path;
|
||||
if (!relayPath.startsWith('/execute/') && !ALLOWED_PATHS.has(relayPath)) {
|
||||
|
||||
// Whitelist matching — никакого freeform после `/execute/`.
|
||||
let allowed = false;
|
||||
if (ALLOWED_GET_PATHS.has(relayPath)) {
|
||||
allowed = true;
|
||||
} else if (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;
|
||||
}
|
||||
@@ -24,29 +46,55 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
|
||||
value.forEach((item) => relayUrl.searchParams.append(key, String(item)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value !== 'undefined') {
|
||||
relayUrl.searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const response = 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 ?? {}),
|
||||
});
|
||||
// Explicit timeout via AbortController — иначе hang upstream'а держит request indefinitely.
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), RELAY_TIMEOUT_MS);
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? 'application/json';
|
||||
const payload = await response.text();
|
||||
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);
|
||||
}
|
||||
|
||||
res.status(response.status);
|
||||
res.type(contentType);
|
||||
res.send(payload);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
// 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)}`);
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,8 +85,10 @@ async function getQuote(req: Request, res: Response) {
|
||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter API error: ${text}` });
|
||||
const text = await response.text().catch(() => '');
|
||||
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
|
||||
console.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
|
||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -153,8 +155,9 @@ async function buildSwap(req: Request, res: Response) {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => 'Unknown error');
|
||||
res.status(response.status).json({ success: false, error: `Jupiter swap build error: ${text}` });
|
||||
const text = await response.text().catch(() => '');
|
||||
console.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
|
||||
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,10 @@ const ALLOWED_TRC_FUNCTIONS = new Set<string>([
|
||||
* Proxies to TronGrid /wallet/triggersmartcontract — builds unsigned transaction.
|
||||
* Whitelisted contracts + function selectors only.
|
||||
*/
|
||||
// Максимальный fee_limit для TriggerSmartContract: 1000 TRX = 1_000_000_000 sun.
|
||||
// Без этого attacker с whitelist-проходящим контрактом мог бы выкачать ресурсы аккаунта.
|
||||
const MAX_FEE_LIMIT_SUN = 1_000_000_000;
|
||||
|
||||
async function triggerSmartContract(req: Request, res: Response) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
|
||||
@@ -228,6 +232,9 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
const contractAddress = String(body.contract_address || '');
|
||||
const functionSelector = String(body.function_selector || '');
|
||||
const ownerAddress = String(body.owner_address || '');
|
||||
const parameter = String(body.parameter || '');
|
||||
const callValueRaw = body.call_value;
|
||||
const feeLimitRaw = body.fee_limit;
|
||||
|
||||
if (!ALLOWED_TRC_CONTRACTS.has(contractAddress)) {
|
||||
res.status(403).json({ success: false, error: 'Contract address not allowed' });
|
||||
@@ -242,6 +249,29 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate parameter — hex (0-9a-f), без 0x prefix, length определена selector'ом.
|
||||
if (!/^[0-9a-fA-F]*$/.test(parameter)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid parameter (must be hex)' });
|
||||
return;
|
||||
}
|
||||
// Лимит длины — самый длинный whitelist'нутый ABI принимает ~3-4 параметра = 256-512 hex chars
|
||||
if (parameter.length > 1024) {
|
||||
res.status(400).json({ success: false, error: 'parameter too long' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Bound fee_limit + call_value
|
||||
const feeLimit = Number(feeLimitRaw ?? 0);
|
||||
if (!Number.isFinite(feeLimit) || feeLimit < 0 || feeLimit > MAX_FEE_LIMIT_SUN) {
|
||||
res.status(400).json({ success: false, error: `fee_limit out of bounds (max ${MAX_FEE_LIMIT_SUN})` });
|
||||
return;
|
||||
}
|
||||
const callValue = Number(callValueRaw ?? 0);
|
||||
if (!Number.isFinite(callValue) || callValue < 0) {
|
||||
res.status(400).json({ success: false, error: 'Invalid call_value' });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
@@ -250,11 +280,22 @@ async function triggerSmartContract(req: Request, res: Response) {
|
||||
headers['TRON-PRO-API-KEY'] = env.tronApiKey;
|
||||
}
|
||||
|
||||
// ВАЖНО: НЕ forward'им req.body целиком — только validated fields.
|
||||
const forwardBody = {
|
||||
owner_address: ownerAddress,
|
||||
contract_address: contractAddress,
|
||||
function_selector: functionSelector,
|
||||
parameter,
|
||||
fee_limit: feeLimit,
|
||||
call_value: callValue,
|
||||
visible: true,
|
||||
};
|
||||
|
||||
const response = await fetch(`${TRONGRID_BASE}/wallet/triggersmartcontract`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify(req.body),
|
||||
body: JSON.stringify(forwardBody),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -308,8 +308,8 @@ async function buildSwapTx(req: Request, res: Response) {
|
||||
res.status(504).json({ success: false, error: 'Build request timed out' });
|
||||
return;
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : 'Failed to build swap';
|
||||
res.status(502).json({ success: false, error: msg });
|
||||
console.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
|
||||
res.status(502).json({ success: false, error: 'Failed to build swap' });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { WalletController } from '../controllers/wallet.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/create', WalletController.createWallet);
|
||||
router.get('/', WalletController.getWallets);
|
||||
router.post('/create', WalletController.createWallets);
|
||||
router.post('/mnemonic/reveal', WalletController.revealMnemonic);
|
||||
|
||||
router.get('/:chain/balance', WalletController.getChainBalance);
|
||||
router.get('/:chain/transactions', WalletController.getChainTransactions);
|
||||
|
||||
Reference in New Issue
Block a user