feat: security audit fixes

This commit is contained in:
ZOMBIIIIIII
2026-05-13 00:17:32 +03:00
parent e87d178d71
commit 1498ed3431
31 changed files with 2198 additions and 339 deletions

View File

@@ -1,5 +1,7 @@
import { Request, Response, Router } from 'express';
import { ethers } from 'ethers';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router();
@@ -85,7 +87,7 @@ async function getSwapQuote(req: Request, res: Response) {
toDecimals: TOKEN_DECIMALS[to],
});
} catch (error) {
console.error(`BSC quote failed: ${(error as any)?.stack || (error as any)?.message}`);
logger.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 +115,16 @@ async function buildSwapTx(req: Request, res: Response) {
return;
}
// C17 — bind userAddress to JWT. Иначе attacker может set userAddress=victim_addr
// и output swap'а пойдёт victim'у/контракту-attacker'у (reentrancy vector).
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'BSC', userAddress);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
// Validate amount + amountOutMin: positive integers > 0 (string-encoded BigInt).
// "0" truthy bypass — без этого attacker может выставить amountOutMin=0 = 100% slippage
// → sandwich attack осушает swap.
@@ -189,7 +201,7 @@ async function buildSwapTx(req: Request, res: Response) {
res.json({ success: true, transactions });
} catch (error) {
console.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
logger.error(`BSC build failed: ${(error as any)?.stack || (error as any)?.message}`);
res.status(502).json({ success: false, error: 'Failed to build BSC swap' });
}
}

View File

@@ -1,14 +1,25 @@
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';
const router = Router();
const RELAY_API_URL = 'https://api.relay.link';
const RELAY_TIMEOUT_MS = 20_000;
// Whitelist: GET-paths + allowed `/execute/<action>` actions.
// 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.
const ALLOWED_GET_PATHS = new Set(['/quote/v2', '/intents/status/v3']);
// 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',
@@ -24,10 +35,13 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
const relayPath = req.path;
// Whitelist matching — никакого freeform после `/execute/`.
// Каждый path matches только своему method'у, чтобы клиент не мог GET-ом дёрнуть POST endpoint.
let allowed = false;
if (ALLOWED_GET_PATHS.has(relayPath)) {
if (req.method === 'GET' && ALLOWED_GET_PATHS.has(relayPath)) {
allowed = true;
} else if (relayPath.startsWith('/execute/')) {
} 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)) {
@@ -39,6 +53,65 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
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 = (req as any).auth?.userId;
if (!userId) {
res.status(401).json({ success: false, error: 'auth required' });
return;
}
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]) => {
@@ -79,7 +152,16 @@ async function proxyRelayRequest(req: Request, res: Response, next: NextFunction
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' });
// Пробрасываем 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;
}

View File

@@ -1,5 +1,8 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
import { PublicKey } from '@solana/web3.js';
const router = Router();
const JUPITER_BASE = 'https://api.jup.ag/swap/v1';
@@ -70,7 +73,8 @@ async function getQuote(req: Request, res: Response) {
url.searchParams.set('inputMint', String(inputMint));
url.searchParams.set('outputMint', String(outputMint));
url.searchParams.set('amount', String(parsedAmount));
url.searchParams.set('slippageBps', String(slippageBps));
// H38 — forward PARSED value, not raw query string ("300abc" → "300" но raw forwarded as "300abc")
url.searchParams.set('slippageBps', String(parsedSlippage));
// Platform fee (0.7%) — Jupiter deducts this natively
if (env.jupiterFeeBps > 0) {
@@ -87,7 +91,7 @@ async function getQuote(req: Request, res: Response) {
if (!response.ok) {
const text = await response.text().catch(() => '');
// НЕ reflect'им upstream body — может содержать internal config (feeAccount, refs)
console.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
logger.error(`Jupiter quote ${response.status}: ${text.slice(0, 200)}`);
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
return;
}
@@ -122,6 +126,37 @@ async function buildSwap(req: Request, res: Response) {
return;
}
// Validate userPublicKey syntactically
try {
new PublicKey(userPublicKey);
} catch {
res.status(400).json({ success: false, error: 'Invalid userPublicKey (not a valid base58 32-byte pubkey)' });
return;
}
// C9 — bind userPublicKey to JWT (без bind: feeAccount/referral hijacking, ATA pre-staging)
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'SOL', userPublicKey);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
// C8 — re-validate input/output mints в quoteResponse против ALLOWED_MINTS.
// Иначе attacker может вызвать /quote с whitelisted mints (passes), затем POST /build
// с hand-crafted quoteResponse где outputMint = scam-mint, и Jupiter trust'нет shape.
const qInputMint = (quoteResponse as any)?.inputMint;
const qOutputMint = (quoteResponse as any)?.outputMint;
if (typeof qInputMint !== 'string' || !ALLOWED_MINTS.has(qInputMint)) {
res.status(400).json({ success: false, error: 'quoteResponse.inputMint not in allowlist' });
return;
}
if (typeof qOutputMint !== 'string' || !ALLOWED_MINTS.has(qOutputMint)) {
res.status(400).json({ success: false, error: 'quoteResponse.outputMint not in allowlist' });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JUPITER_TIMEOUT_MS);
@@ -156,7 +191,7 @@ async function buildSwap(req: Request, res: Response) {
if (!response.ok) {
const text = await response.text().catch(() => '');
console.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
logger.error(`Jupiter swap build ${response.status}: ${text.slice(0, 200)}`);
res.status(502).json({ success: false, error: 'Jupiter upstream error' });
return;
}

View File

@@ -1,5 +1,7 @@
import { Request, Response, Router } from 'express';
import { env } from '../config/env';
import { logger } from '../lib/logger';
import { assertUserOwnsAddress } from '../lib/wallet-binding';
const router = Router();
const TRONGRID_BASE = 'https://api.trongrid.io';
@@ -199,6 +201,15 @@ async function buildSwapTx(req: Request, res: Response) {
return;
}
// H47 — bind userAddress to JWT (output идёт на userAddress; без bind output к attacker'у)
const userId = req.auth!.userId;
try {
await assertUserOwnsAddress(userId, 'TRX', userAddress);
} catch (err: any) {
res.status(403).json({ success: false, error: err.message });
return;
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TRON_TIMEOUT_MS);
@@ -308,7 +319,7 @@ async function buildSwapTx(req: Request, res: Response) {
res.status(504).json({ success: false, error: 'Build request timed out' });
return;
}
console.error(`TRON swap build failed: ${(error as any)?.stack || (error as any)?.message}`);
logger.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);
@@ -320,8 +331,25 @@ async function buildSwapTx(req: Request, res: Response) {
async function broadcastTx(req: Request, res: Response) {
const { signedTransaction } = req.body;
if (!signedTransaction) {
res.status(400).json({ success: false, error: 'Missing signedTransaction' });
if (!signedTransaction || typeof signedTransaction !== 'object') {
res.status(400).json({ success: false, error: 'Missing or invalid signedTransaction' });
return;
}
// C6 — verify owner_address внутри signed tx равен JWT-bound TRX-адресу юзера.
// Иначе endpoint = open relay для arbitrary tx (replay leaked txs, evade IP bans).
const userId = req.auth!.userId;
const contract0 = signedTransaction?.raw_data?.contract?.[0];
const ownerAddr = contract0?.parameter?.value?.owner_address;
if (typeof ownerAddr !== 'string' || !ownerAddr) {
res.status(400).json({ success: false, error: 'Cannot extract owner_address from signedTransaction.raw_data.contract[0]' });
return;
}
try {
await assertUserOwnsAddress(userId, 'TRX', ownerAddr);
} catch (err: any) {
logger.warn(`broadcast rejected: ${err.message} userId=${userId}`);
res.status(403).json({ success: false, error: err.message });
return;
}

View File

@@ -9,6 +9,8 @@ router.post('/mnemonic/reveal', WalletController.revealMnemonic);
router.get('/:chain/balance', WalletController.getChainBalance);
router.get('/:chain/transactions', WalletController.getChainTransactions);
router.get('/:chain/gas-suggestions', WalletController.getGasSuggestions);
router.post('/:chain/send', WalletController.sendFromChain);
router.post('/:chain/sign-raw-evm-tx', WalletController.signRawEvmTx);
export default router;