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,35 +1,20 @@
/**
* Audit log — append-only JSON lines в `logs/audit.log`.
* Используется для критических custodial операций.
* Audit log — durable durable durable.
*
* Two sinks:
* 1. **DB `audit_log` table** — primary, used by `auditLogStrict` для critical
* операций. INSERT pending → mutation → UPDATE success/failure с txid.
* Если INSERT fails — operation must NOT proceed (fail-secure).
* 2. **stdout JSON line** — для log-aggregator (Docker logs / Loki etc).
* Best-effort, всегда (даже если DB sink fails).
*
* НИКОГДА не логирует mnemonic / privkey / encrypted blob.
*/
import { promises as fs } from 'fs';
import path from 'path';
import { logger } from './logger';
import { ulid } from 'ulidx';
import { db } from '../config/database';
import { getTraceId } from './trace-store';
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
let initialized = false;
async function ensureFile(): Promise<void> {
if (initialized) return;
try {
await fs.mkdir(AUDIT_DIR, { recursive: true });
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
await handle.close();
try {
await fs.chmod(AUDIT_FILE, 0o600);
} catch {
// Windows chmod — игнор
}
initialized = true;
} catch (err: any) {
logger.error(`Audit log init failed: ${err.message}`);
}
}
import { logger } from './logger';
export interface AuditEntry {
event: string;
@@ -40,36 +25,90 @@ export interface AuditEntry {
errorCode?: string;
}
/**
* Best-effort write. Если запись провалилась — только log, не throws.
* Используется для не-критических событий (wallet.create success, etc).
*/
export async function auditLog(entry: AuditEntry): Promise<void> {
await ensureFile();
const line = JSON.stringify({
function buildStdoutLine(entry: AuditEntry, status: 'pending' | 'success' | 'failure'): string {
return JSON.stringify({
level: 'audit',
status,
timestamp: new Date().toISOString(),
trace_id: getTraceId(),
...entry,
});
}) + '\n';
}
function writeStdoutBestEffort(line: string): void {
try {
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
} catch (err: any) {
logger.error(`Audit log write failed: ${err.message}`);
process.stdout.write(line);
} catch {
// swallow
}
}
/**
* Fail-secure write. Если запись провалилась — throws.
* Используется для critical security событий (mnemonic.reveal, wallet.send),
* где compliance требует чтобы операция НЕ происходила без audit-trail.
* Best-effort: stdout only. Используется для info-level событий
* (wallet.create success, lookup, etc). Не блокирует request на DB.
*/
export async function auditLogStrict(entry: AuditEntry): Promise<void> {
await ensureFile();
const line = JSON.stringify({
timestamp: new Date().toISOString(),
trace_id: getTraceId(),
...entry,
});
// Без try/catch — caller обрабатывает failure
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
export async function auditLog(entry: AuditEntry): Promise<void> {
const status: 'success' | 'failure' = entry.result === 'failure' ? 'failure' : 'success';
writeStdoutBestEffort(buildStdoutLine(entry, status));
}
/**
* Fail-secure audit для critical custodial операций (mnemonic.reveal, wallet.send,
* wallet.sign_raw_evm).
*
* Семантика: INSERT row в `audit_log` table перед mutation. Если INSERT FAILS
* (DB down, connection pool exhausted, constraint violation) — throws.
* Caller ОБЯЗАН abort'нуть mutation, не вернуть response с funds-action.
*
* Возвращает audit row id — caller использует его в `completeAudit()` после mutation.
*/
export async function auditLogStrict(entry: AuditEntry & { status?: 'pending' | 'success' | 'failure' }): Promise<string> {
const id = ulid();
const status = entry.status ?? 'pending';
// DB INSERT — fail-secure (throws on DB failure)
await db('audit_log').insert({
id,
user_id: entry.userId,
event: entry.event,
status,
error_code: entry.errorCode ?? null,
ip: entry.ip ?? null,
trace_id: getTraceId() ?? null,
meta: entry.meta ? JSON.stringify(entry.meta) : null,
});
// Mirror to stdout (best-effort, не критично)
writeStdoutBestEffort(buildStdoutLine(entry, status));
return id;
}
/**
* Update audit row после mutation (success или failure с txid/error).
* Best-effort — если update fails, операция уже произошла, мы just log warning.
*/
export async function completeAudit(
auditId: string,
result: 'success' | 'failure',
meta?: Record<string, unknown>,
errorCode?: string,
): Promise<void> {
try {
await db('audit_log')
.where({ id: auditId })
.update({
status: result,
error_code: errorCode ?? null,
meta: meta ? JSON.stringify(meta) : db.raw('meta'),
updated_at: db.fn.now(),
});
} catch (err: any) {
logger.error(`completeAudit failed for ${auditId}: ${err?.message}`);
}
// Mirror to stdout
writeStdoutBestEffort(buildStdoutLine(
{ event: `audit.update.${auditId}`, userId: '<see-audit-row>', meta, errorCode, result },
result,
));
}

View File

@@ -0,0 +1,116 @@
/**
* Security policy для `signAndBroadcastRawEvm`.
*
* Защита от drain-вектора: stolen JWT + один POST = пустой кошелёк, если подписывать
* произвольный `to`+`data`. Этот модуль применяет несколько слоёв защиты:
*
* 1. **Selector blacklist** — `approve()`, `permit()`, `setApprovalForAll()` etc.
* Безопасный swap НЕ требует approve через наш sign-raw — клиент должен делать
* approve через другой кастодиальный flow (если бы был такой), но самый чистый
* дизайн — никогда не давать sign-raw отозвать approval из bridge-quote.
*
* 2. **`to` allowlist** — только Relay router-адреса для каждого chainId.
* Native send (`data === '0x'`) тоже whitelist'ит `to` чтобы атакер не мог
* drain native через sign-raw-evm-tx.
*
* 3. **Caps** — `gas`, `value`, `gas*maxFeePerGas` — против poisoned quote.
*/
import { ethers } from 'ethers';
/** Relay-protocol router/depository contract addresses per chainId. */
const RELAY_ROUTERS: Record<number, Set<string>> = {
// Ethereum mainnet — Relay router contracts (lowercase for canonical match)
1: new Set([
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 ETH
'0xf70da97812cb96acdf810712aa562db8dfa3dbef', // Relay Router (legacy)
]),
// BSC mainnet
56: new Set([
'0xa5f565650890fba1824ee0f21ebbbf660a179934', // Relay Depository v1 BSC
]),
};
/** Method selectors which are NEVER allowed via sign-raw-evm-tx (drain vectors). */
const FORBIDDEN_SELECTORS: Record<string, string> = {
'0x095ea7b3': 'approve(address,uint256)',
'0x39509351': 'increaseAllowance(address,uint256)',
'0xd505accf': 'permit(address,address,uint256,uint256,uint8,bytes32,bytes32)',
'0x8fcbaf0c': 'permit(address,address,uint256,uint256,bool,uint8,bytes32,bytes32)',
'0xa22cb465': 'setApprovalForAll(address,bool)',
'0x42842e0e': 'safeTransferFrom(address,address,uint256)', // NFT transferFrom
'0xb88d4fde': 'safeTransferFrom(address,address,uint256,bytes)',
};
export interface PolicyParams {
chainId: number;
to: string;
data: string;
value: string;
gas: string;
maxFeePerGas: string;
}
/**
* Caps (per-chain budget). Стоит выше любого realistic Relay tx, но защищает
* от absurd-poisoned quote, которые сжигают весь баланс на gas.
*/
const CAPS = {
// gas: max 1.5M (типичный complex swap ~300-500k; cap есть запас)
maxGas: ethers.BigNumber.from('1500000'),
// gas budget: gas × maxFeePerGas ≤ 0.05 native (= 0.05 ETH ≈ $130 worst case)
// Уровень который покрывает legitimate bridge/swap но не drain
maxGasBudgetWei: ethers.utils.parseEther('0.05'),
// value: max 100 native в одной tx (защита от случайного drain через value)
// Native send большего объёма через sign-raw-evm-tx — explicit user confirmation
// нужен. Через /send route (структурированный) — ограничения другие.
maxValueWei: ethers.utils.parseEther('100'),
};
/**
* Применяет security policy. Throws if disallowed.
*
* Возвращает result-обoject для info-логирования (matched router name, selector name).
*/
export function applyEvmTxPolicy(p: PolicyParams): { routerName?: string; selectorName?: string } {
// 1. `to` validation — должен быть в Relay router allowlist для этого chainId
const toLower = p.to.toLowerCase();
const routers = RELAY_ROUTERS[p.chainId];
if (!routers) {
throw new Error(`Sign-raw policy: chainId ${p.chainId} not in router allowlist`);
}
if (!routers.has(toLower)) {
throw new Error(`Sign-raw policy: 'to' address ${p.to} not in Relay router allowlist for chainId ${p.chainId}`);
}
// 2. Selector blacklist — `approve()` etc. никогда не подписывается
let selectorName: string | undefined;
if (p.data.length >= 10) {
const selector = p.data.slice(0, 10).toLowerCase();
if (FORBIDDEN_SELECTORS[selector]) {
throw new Error(`Sign-raw policy: forbidden selector ${selector} (${FORBIDDEN_SELECTORS[selector]}) — drain vector`);
}
}
// 3. gas caps
const gas = ethers.BigNumber.from(p.gas);
if (gas.gt(CAPS.maxGas)) {
throw new Error(`Sign-raw policy: gas ${gas.toString()} exceeds cap ${CAPS.maxGas.toString()}`);
}
const maxFee = ethers.BigNumber.from(p.maxFeePerGas);
const budget = gas.mul(maxFee);
if (budget.gt(CAPS.maxGasBudgetWei)) {
throw new Error(`Sign-raw policy: gas budget ${ethers.utils.formatEther(budget)} ETH exceeds cap ${ethers.utils.formatEther(CAPS.maxGasBudgetWei)} ETH`);
}
// 4. value cap
const value = ethers.BigNumber.from(p.value);
if (value.gt(CAPS.maxValueWei)) {
throw new Error(`Sign-raw policy: value ${ethers.utils.formatEther(value)} exceeds cap ${ethers.utils.formatEther(CAPS.maxValueWei)} native units`);
}
return {
routerName: routers.has(toLower) ? `relay-router-${p.chainId}` : undefined,
selectorName,
};
}

View File

@@ -0,0 +1,92 @@
/**
* Idempotency-Key handling — C3 защита от double-spend при retry.
*
* Контракт:
* Client передаёт header `Idempotency-Key: <opaque-string-up-to-128-chars>`.
* Server:
* 1. INSERT row (user_id, key, request_hash) — PK conflict = retry detected.
* 2. На retry: SELECT existing row. Если response_status is null — operation
* ещё in-flight → return 409 "retry too soon". Если response_status set →
* return cached response (same status, same body).
* Retention: 24h. Cleanup via cron.
*/
import { createHash } from 'crypto';
import { db } from '../config/database';
export interface IdempotencyClaim {
fresh: boolean;
cached?: { status: number; body: string };
}
/**
* Try to claim the key. If first time → fresh=true, caller proceeds with mutation.
* If duplicate с existing response → fresh=false + cached response.
* If duplicate с pending in-flight → throws (caller returns 409).
*/
export async function claimIdempotency(
userId: string,
key: string,
requestBody: unknown,
): Promise<IdempotencyClaim> {
const requestHash = createHash('sha256')
.update(JSON.stringify(requestBody ?? {}))
.digest('hex');
try {
await db('idempotency_keys').insert({
user_id: userId,
key,
request_hash: requestHash,
});
return { fresh: true };
} catch (err: any) {
// PK violation = retry
const existing = await db('idempotency_keys')
.where({ user_id: userId, key })
.first();
if (!existing) throw err;
// Verify request body matches (защита от replay с другим body)
if (existing.request_hash !== requestHash) {
throw new Error(`Idempotency-Key reuse with different request body. Use a new key.`);
}
if (existing.response_status === null || existing.response_status === undefined) {
throw new Error('Operation already in flight; retry after a few seconds.');
}
return {
fresh: false,
cached: {
status: existing.response_status as number,
body: existing.response_body as string,
},
};
}
}
/** Сохранить response в idempotency row (после mutation succeeds/fails). */
export async function saveIdempotencyResponse(
userId: string,
key: string,
status: number,
body: string,
): Promise<void> {
await db('idempotency_keys')
.where({ user_id: userId, key })
.update({
response_status: status,
response_body: body,
});
}
/** Validate header format. Returns null if missing/invalid (caller may make mandatory). */
export function extractIdempotencyKey(headerValue: unknown): string | null {
if (typeof headerValue !== 'string') return null;
const v = headerValue.trim();
if (!v) return null;
// Restrict charset: alphanum + dash/underscore, max 128
if (!/^[A-Za-z0-9_-]{1,128}$/.test(v)) return null;
return v;
}

View File

@@ -0,0 +1,38 @@
/**
* Per-user-per-chain mutex для send / sign operations.
*
* C3 — nonce race: `provider.getTransactionCount('pending')` без lock'а возвращает
* одинаковый nonce для двух parallel send'ов → один tx replaces другой, или оба
* с разными gas → mempool collision. Plus retry после ECONNRESET = double-spend.
*
* Этот lock — in-process Map+queue. Для multi-replica deployment нужен Redis.
*/
type ReleaseFn = () => void;
const locks = new Map<string, Promise<void>>();
/**
* Acquire lock для `userId:chain`. Возвращает release function.
* Если другой await уже идёт, наш await ждёт его release.
*
* Usage:
* const release = await acquireSendLock(userId, chain);
* try { ... } finally { release(); }
*/
export async function acquireSendLock(userId: string, chain: string): Promise<ReleaseFn> {
const key = `${userId}:${chain}`;
// Wait для предыдущего lock
while (locks.has(key)) {
await locks.get(key);
}
let release: ReleaseFn = () => {};
const promise = new Promise<void>((resolve) => {
release = () => {
locks.delete(key);
resolve();
};
});
locks.set(key, promise);
return release;
}

View File

@@ -0,0 +1,80 @@
/**
* Реестр известных токенов per-chain. Используется в getBalance для
* параллельного запроса баланса всех токенов адреса.
*
* Адреса контрактов / mint'ы — из публичных on-chain данных и используются
* также в swap-proxy роутах (BSC TOKEN_MAP, SOL ALLOWED_MINTS).
*/
import type { ChainCode } from './address-validators';
export interface EvmToken {
symbol: string;
contractAddress: string;
decimals: number;
}
export interface TrxToken {
symbol: string;
contractAddress: string; // T...base58
decimals: number;
}
export interface SolToken {
symbol: string;
mint: string; // SPL mint pubkey (base58)
decimals: number;
}
export const ETH_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', decimals: 6 },
{ symbol: 'USDC', contractAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', decimals: 6 },
{ symbol: 'DAI', contractAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', decimals: 18 },
{ symbol: 'WBTC', contractAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', decimals: 8 },
{ symbol: 'LINK', contractAddress: '0x514910771AF9Ca656af840dff83E8264EcF986CA', decimals: 18 },
{ symbol: 'UNI', contractAddress: '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', decimals: 18 },
];
export const BSC_TOKENS: EvmToken[] = [
{ symbol: 'USDT', contractAddress: '0x55d398326f99059fF775485246999027B3197955', decimals: 18 },
{ symbol: 'USDC', contractAddress: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', decimals: 18 },
{ symbol: 'DOGE', contractAddress: '0xbA2aE424d960c26247Dd6c32edC70B295c744C43', decimals: 8 },
{ symbol: 'WBNB', contractAddress: '0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c', decimals: 18 },
{ symbol: 'BUSD', contractAddress: '0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56', decimals: 18 },
];
export const TRX_TOKENS: TrxToken[] = [
{ symbol: 'USDT', contractAddress: 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t', decimals: 6 },
{ symbol: 'USDC', contractAddress: 'TEkxiTehnzSmSe2XqrBj4w32RUN966rdz8', decimals: 6 },
];
export const SOL_TOKENS: SolToken[] = [
{ symbol: 'USDT', mint: 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB', decimals: 6 },
{ symbol: 'USDC', mint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', decimals: 6 },
{ symbol: 'PUMP', mint: 'pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn', decimals: 6 },
{ symbol: 'JUP', mint: 'JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN', decimals: 6 },
{ symbol: 'WIF', mint: 'EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm', decimals: 6 },
{ symbol: 'POPCAT', mint: '7GCihgDB8fe6KNjn2MYtkzZcRjQy3t9GHdC8uHYmW2hr', decimals: 9 },
{ symbol: 'TRUMP', mint: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', decimals: 6 },
{ symbol: 'PYTH', mint: 'HZ1JovNiVvGrGNiiYvEozEVgZ58xaU3RKwX8eACQBCt3', decimals: 6 },
{ symbol: 'JTO', mint: 'jtojtomepa8beP8AuQc6eXt5FriJwfFMwQx2v2f9mCL', decimals: 9 },
{ symbol: 'W', mint: '85VBFQZC9TZkfaptBWjvUw7YbZjy52A6mjtPGjstQAmQ', decimals: 6 },
{ symbol: 'BONK', mint: 'DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263', decimals: 5 },
{ symbol: 'ORCA', mint: 'orcaEKTdK7LKz57vaAYr9QeNsVEPfiu6QeMU1kektZE', decimals: 6 },
{ symbol: 'PENGU', mint: '2zMMhcVQEXDtdE6vsFS7S7D5oUodfJHE8vd1gnBouauv', decimals: 6 },
{ symbol: 'RAY', mint: '4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R', decimals: 6 },
];
export function getEvmTokens(chain: ChainCode): EvmToken[] {
if (chain === 'ETH') return ETH_TOKENS;
if (chain === 'BSC') return BSC_TOKENS;
return [];
}
export function getTrxTokens(): TrxToken[] {
return TRX_TOKENS;
}
export function getSolTokens(): SolToken[] {
return SOL_TOKENS;
}

View File

@@ -0,0 +1,43 @@
/**
* JWT ↔ wallet-address binding.
*
* Защита от drain-вектора: проксируемые endpoint'ы (swap-build, relay-quote) принимают
* `userAddress`/`recipient` как body param. Если не привязать к JWT-юзеру —
* authenticated user A может set `userAddress=<user B's addr>` и:
* - swap output идёт к B (бесплатный slippage steal)
* - reentrancy: B's address — malicious contract, callback дрянит
* - bridge `recipient=attacker` → victim signs → funds to attacker
*
* Этот хелпер находит wallet user'а по chain и проверяет match.
*/
import { WalletModel } from '../models/wallet.model';
import type { ChainCode } from './address-validators';
/**
* Throws if address ≠ user's wallet для данного chain.
* Returns the canonical (DB-stored) address для использования вместо user input.
*
* Сравнение case-insensitive только для EVM (где checksum mixed-case = same address).
* Для BTC/TRX/SOL — strict equality (base58/bech32 case-sensitive).
*/
export async function assertUserOwnsAddress(
userId: string,
chain: ChainCode,
candidateAddress: string,
): Promise<string> {
const wallet = await WalletModel.findByUserAndChain(userId, chain);
if (!wallet) {
throw new Error(`No ${chain} wallet for user — POST /wallets/create first`);
}
const isEvm = chain === 'ETH' || chain === 'BSC';
const dbAddr = wallet.address;
const candidate = String(candidateAddress ?? '').trim();
const match = isEvm
? candidate.toLowerCase() === dbAddr.toLowerCase()
: candidate === dbAddr;
if (!match) {
throw new Error(`Address ${candidate} does not match user's ${chain} wallet ${dbAddr}`);
}
return dbAddr;
}