security: round 3 hardening (CSRF double-submit, TRX MITM, container hardening)

This commit is contained in:
ZOMBIIIIIII
2026-05-12 01:47:58 +03:00
parent c8bc40af97
commit 8dc0855827
37 changed files with 1852 additions and 318 deletions

View File

@@ -1,15 +1,21 @@
import { Request, Response } from 'express';
import { db } from '../config/database';
import { WalletModel } from '../models/wallet.model';
import { UserModel } from '../models/user.model';
import { getBalance, getTransactions, buildSend } from '../services/wallet-ops.service';
import { getBalance, getTransactions } from '../services/wallet-ops.service';
import { isValidAddress, isValidAmount, ChainCode } from '../lib/address-validators';
import { generateMnemonic, deriveAllAddresses, ALL_CHAINS } from '../services/wallet-generator.service';
import { encryptMnemonic, decryptMnemonic, isCryptoReady } from '../services/crypto.service';
import { signAndBroadcast } from '../services/wallet-signer.service';
import { auditLog, auditLogStrict } from '../lib/audit-log';
import { logger } from '../lib/logger';
const ALLOWED_CHAINS = new Set<ChainCode>(['ETH', 'BTC', 'SOL', 'TRX', 'BSC']);
const MAX_WALLETS_PER_REQUEST = 20;
const MAX_DERIVATION_PATH = 64;
const ALLOWED_CHAINS = new Set<ChainCode>(ALL_CHAINS);
const MAX_TX_LIMIT = 100;
const BIP32_PATH_RE = /^m(\/[0-9]+'?)*$/;
class ConflictError extends Error {
constructor() { super('Wallet already exists'); }
}
function isChain(value: unknown): value is ChainCode {
return typeof value === 'string' && ALLOWED_CHAINS.has(value as ChainCode);
@@ -37,69 +43,153 @@ export const WalletController = {
},
/**
* POST /api/wallets/create — non-custodial upsert.
* Клиент сам деривит mnemonic и шлёт массив { chain, address, derivationPath }.
* Сервер валидирует и сохраняет (upsert по user_id+chain).
* POST /api/wallets/create — custodial bootstrap.
* Сервер: генерит mnemonic → деривит адреса 5 chains → шифрует AES-GCM (master из Vault)
* → атомарно (UPDATE WHERE NULL + INSERT в одной транзакции) сохраняет
* Возвращает: ТОЛЬКО адреса. Mnemonic клиенту не отдаём.
*/
async createWallets(req: Request, res: Response) {
async createWallet(req: Request, res: Response) {
const userId = req.auth!.userId;
const { wallets } = req.body ?? {};
if (!Array.isArray(wallets) || wallets.length === 0 || wallets.length > MAX_WALLETS_PER_REQUEST) {
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
let mnemonic: string | null = null;
try {
await UserModel.ensureExists(userId);
if (await UserModel.hasMnemonic(userId)) {
res.status(409).json({ success: false, error: 'Wallet already exists' });
return;
}
mnemonic = generateMnemonic();
const derived = await deriveAllAddresses(mnemonic);
const blob = encryptMnemonic(mnemonic);
const created = await db.transaction(async (trx) => {
const claimed = await UserModel.setEncryptedMnemonicIfAbsent(userId, blob, trx);
if (!claimed) {
throw new ConflictError();
}
await WalletModel.createMany(
derived.map((w) => ({
user_id: userId,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
})),
trx,
);
return derived;
});
await auditLog({
event: 'wallet.create',
userId,
ip: req.ip || null,
result: 'success',
meta: { chains: created.map((w) => w.chain) },
});
res.status(201).json({
success: true,
data: created.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivationPath,
})),
});
} catch (err: any) {
if (err instanceof ConflictError) {
// Audit conflict events — surveillance attacker spamming the endpoint
await auditLog({
event: 'wallet.create',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'CONFLICT',
});
res.status(409).json({ success: false, error: 'Wallet already exists' });
return;
}
logger.error(`createWallet failed for user ${userId}: ${err.stack || err.message}`);
await auditLog({
event: 'wallet.create',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'INTERNAL',
});
res.status(500).json({ success: false, error: 'Failed to create wallet' });
} finally {
mnemonic = null;
}
},
/**
* POST /api/wallets/mnemonic/reveal — settings: показать seed.
* Защита: POST + CSRF + body confirm token + rate-limit 5/час + audit-log.
*/
async revealMnemonic(req: Request, res: Response) {
const userId = req.auth!.userId;
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
if (req.body?.confirm !== 'I_UNDERSTAND_SEED_IS_SECRET') {
res.status(400).json({
success: false,
error: `wallets must be a non-empty array (max ${MAX_WALLETS_PER_REQUEST})`,
error: 'Missing or invalid confirm token. Send { "confirm": "I_UNDERSTAND_SEED_IS_SECRET" } in body.',
});
return;
}
for (const w of wallets) {
if (!w || typeof w !== 'object') {
res.status(400).json({ success: false, error: 'Invalid wallet entry' });
return;
}
if (!isChain(w.chain)) {
res.status(400).json({ success: false, error: 'Invalid chain' });
return;
}
if (!isValidAddress(w.chain, w.address)) {
res.status(400).json({ success: false, error: 'Invalid address format for chain' });
return;
}
if (
typeof w.derivationPath !== 'string' ||
w.derivationPath.length === 0 ||
w.derivationPath.length > MAX_DERIVATION_PATH ||
!BIP32_PATH_RE.test(w.derivationPath)
) {
res.status(400).json({ success: false, error: 'Invalid derivationPath (BIP32 m/.. format expected)' });
return;
}
}
try {
await UserModel.ensureExists(userId);
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
await auditLog({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'NOT_FOUND',
});
res.status(404).json({ success: false, error: 'Wallet not created yet' });
return;
}
const rows = await WalletModel.upsertMany(
wallets.map((w: { chain: ChainCode; address: string; derivationPath: string }) => ({
user_id: userId,
chain: w.chain,
address: w.address,
derivation_path: w.derivationPath,
})),
);
const mnemonic = decryptMnemonic(blob);
res.status(201).json({
success: true,
data: rows.map((w) => ({
chain: w.chain,
address: w.address,
derivationPath: w.derivation_path,
})),
});
// CRITICAL operation — fail-secure audit (если запись fail'ит — не отдаём mnemonic).
try {
await auditLogStrict({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'success',
});
} catch (auditErr: any) {
logger.error(`Audit log MUST succeed for mnemonic.reveal: ${auditErr.message}`);
res.status(503).json({ success: false, error: 'Audit service unavailable' });
return;
}
res.json({ success: true, data: { mnemonic } });
} catch (err: any) {
logger.error(`createWallets failed for user ${userId}: ${err.stack || err.message}`);
res.status(500).json({ success: false, error: 'Internal error' });
logger.error(`revealMnemonic failed for user ${userId}: ${err.stack || err.message}`);
await auditLog({
event: 'mnemonic.reveal',
userId,
ip: req.ip || null,
result: 'failure',
errorCode: 'INTERNAL',
});
res.status(500).json({ success: false, error: 'Failed to reveal mnemonic' });
}
},
@@ -160,8 +250,9 @@ export const WalletController = {
},
/**
* POST /api/wallets/:chain/send — build unsigned tx (non-custodial).
* Возвращает unsigned tx; клиент подписывает приватом и broadcast'ит сам.
* POST /api/wallets/:chain/send — custodial sign + broadcast.
* Юзер жмёт "подтвердить" → клиент шлёт {to, amount, token?} → сервер расшифровывает
* мнемонику, деривит privkey, подписывает, broadcast'ит → возвращает txid.
*/
async sendFromChain(req: Request, res: Response) {
const userId = req.auth!.userId;
@@ -172,6 +263,11 @@ export const WalletController = {
return;
}
if (!isCryptoReady()) {
res.status(503).json({ success: false, error: 'Crypto service not ready' });
return;
}
const { to, amount, token } = req.body ?? {};
if (!isValidAddress(chain, String(to))) {
@@ -192,6 +288,7 @@ export const WalletController = {
normalizedToken = token.toUpperCase();
}
let mnemonic: string | null = null;
try {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
@@ -199,21 +296,62 @@ export const WalletController = {
return;
}
const tx = await buildSend({
const blob = await UserModel.getEncryptedMnemonic(userId);
if (!blob) {
res.status(404).json({ success: false, error: 'Encrypted mnemonic not found' });
return;
}
mnemonic = decryptMnemonic(blob);
const result = await signAndBroadcast({
chain,
from: wallet.address,
mnemonic,
to: String(to),
amount: String(amount),
token: normalizedToken,
expectedFromAddress: wallet.address,
});
res.json({ success: true, data: tx });
// CRITICAL operation — fail-secure audit
try {
await auditLogStrict({
event: 'wallet.send',
userId,
ip: req.ip || null,
result: 'success',
meta: { chain, hasToken: !!normalizedToken, txid: result.txid },
});
} catch (auditErr: any) {
logger.error(`Audit log MUST succeed for wallet.send (txid=${result.txid}): ${auditErr.message}`);
// Tx уже broadcast'нут — нельзя отменить. Возвращаем txid но с warning о audit.
res.status(200).json({
success: true,
data: { txid: result.txid, chain },
warning: 'Transaction broadcast succeeded but audit log write failed',
});
return;
}
res.json({ success: true, data: { txid: result.txid, chain } });
} catch (err: any) {
logger.error(`buildSend ${chain} failed for user ${userId}: ${err.stack || err.message}`);
const msg = err?.message?.toLowerCase?.().includes('not supported')
? 'Token/chain combination not supported'
: 'Failed to build transaction';
logger.error(`send failed for user ${userId} chain ${chain}: ${err.stack || err.message}`);
await auditLog({
event: 'wallet.send',
userId,
ip: req.ip || null,
result: 'failure',
meta: { chain },
errorCode: 'BROADCAST_FAILED',
});
const msg = err?.message?.toLowerCase?.().includes('insufficient')
? 'Insufficient balance'
: err?.message?.toLowerCase?.().includes('not supported')
? 'Token/chain combination not supported'
: 'Failed to broadcast transaction';
res.status(502).json({ success: false, error: msg });
} finally {
mnemonic = null;
}
},
};