feat(security): Phase 1 — critical security fixes and hardening

- #42: Remove hardcoded ENCRYPTION_KEY fallback from config.js,
  add startup validation for BOT_TOKEN and ENCRYPTION_KEY length
- #43: Fix SQL injection vulnerabilities — add ALLOWED_TABLES
  whitelist in database.js, ALLOWED_USER_FIELDS in userService.js,
  validate table names before PRAGMA
- #44: Fix race condition in purchaseService.js — wrap createPurchase
  in BEGIN IMMEDIATE TRANSACTION, add atomic balance/stock checks
- #41: Move all secrets from docker-compose.yml to .env file,
  use env_file directive
- #45: Replace MD5 tx_hash with crypto.randomUUID()
- #46: Upgrade KDF from SHA-256 to HKDF for mnemonic encryption,
  add backward compatibility for legacy format
- #47: Add input validation across all handlers — walletType
  whitelist, string length limits, numeric ID checks, price bounds

New files:
- src/utils/encryption.js (HKDF key derivation)
- src/__tests__/security.test.js (SQL injection prevention tests)

Closes: #41, #42, #43, #44, #45, #46, #47
This commit is contained in:
NW
2026-06-17 21:52:49 +01:00
parent d1503a0180
commit de415633be
17 changed files with 360 additions and 154 deletions

View File

@@ -1,9 +1,13 @@
// productService.js
import db from "../config/database.js";
import Validators from "../utils/validators.js";
class ProductService {
static async getProductById(productId) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
try {
return await db.getAsync(`SELECT * FROM products WHERE id = ?`, [productId]);
} catch (error) {
@@ -13,6 +17,9 @@ class ProductService {
}
static async getDetailedProductById(productId) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
return await db.getAsync(
`SELECT p.*, c.name as category_name
FROM products p
@@ -23,6 +30,9 @@ class ProductService {
}
static async getProductsByLocationAndCategory(locationId, categoryId) {
if (!Validators.isValidNumericId(Number(locationId)) || !Validators.isValidNumericId(Number(categoryId))) {
throw new Error('Invalid location or category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
FROM products
@@ -34,6 +44,9 @@ class ProductService {
}
static async getProductsByCategoryId(categoryId) {
if (!Validators.isValidNumericId(Number(categoryId))) {
throw new Error('Invalid category ID');
}
return await db.allAsync(
`SELECT id, name, price, description, quantity_in_stock, photo_url
FROM products
@@ -45,6 +58,12 @@ class ProductService {
}
static async decreaseProductQuantity(productId, quantity) {
if (!Validators.isValidNumericId(Number(productId))) {
throw new Error('Invalid product ID');
}
if (!Number.isFinite(quantity) || quantity <= 0) {
throw new Error('Invalid quantity');
}
try {
await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ?',

View File

@@ -1,8 +1,7 @@
// purchaseService.js
import db from "../config/database.js";
import CryptoJS from "crypto";
import UserService from "../services/userService.js";
import crypto from "crypto";
class PurchaseService {
static async getPurchasesByUserId(userId, limit, offset) {
@@ -44,65 +43,76 @@ class PurchaseService {
static async createPurchase(userId, productId, walletType, quantity, totalPrice) {
try {
const user = await UserService.getUserByUserId(userId);
await db.runAsync('BEGIN IMMEDIATE TRANSACTION');
const user = await db.getAsync(
'SELECT id, telegram_id, bonus_balance, total_balance FROM users WHERE id = ?',
[userId]
);
if (!user) {
throw new Error('User not found');
}
// Обновляем крипто-баланс пользователя перед началом покупки
await UserService.recalculateUserBalanceByTelegramId(user.telegram_id);
const product = await db.getAsync(
'SELECT id, quantity_in_stock FROM products WHERE id = ? AND quantity_in_stock >= ?',
[productId, quantity]
);
if (!product) {
throw new Error('Product not available or insufficient stock');
}
let remainingAmount = totalPrice;
let usedBonus = 0;
let usedCrypto = 0;
let sourceWalletType = '';
// Сначала списываем с бонусного баланса
if (user.bonus_balance > 0) {
usedBonus = Math.min(user.bonus_balance, remainingAmount);
remainingAmount -= usedBonus;
// Обновляем бонусный баланс пользователя
await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance - ? WHERE id = ?',
[usedBonus, userId]
const bonusResult = await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance - ? WHERE id = ? AND bonus_balance >= ?',
[usedBonus, userId, usedBonus]
);
// Добавляем информацию о списании с бонусного баланса
if (bonusResult.changes === 0) {
throw new Error('Insufficient bonus balance');
}
sourceWalletType += `bonus_${usedBonus}`;
}
// Если осталась сумма, списываем с крипто-баланса
if (remainingAmount > 0) {
usedCrypto = remainingAmount;
// Обновляем крипто-баланс пользователя
await db.runAsync(
'UPDATE users SET total_balance = total_balance - ? WHERE id = ?',
[usedCrypto, userId]
const cryptoResult = await db.runAsync(
'UPDATE users SET total_balance = total_balance - ? WHERE id = ? AND total_balance >= ?',
[usedCrypto, userId, usedCrypto]
);
// Добавляем информацию о списании с крипто-баланса
if (sourceWalletType) {
sourceWalletType += `, crypto_${usedCrypto}`;
} else {
sourceWalletType = `crypto_${usedCrypto}`;
if (cryptoResult.changes === 0) {
throw new Error('Insufficient crypto balance');
}
sourceWalletType += sourceWalletType ? `, crypto_${usedCrypto}` : `crypto_${usedCrypto}`;
}
// Генерируем MD5-хеш для tx_hash
const txHash = CryptoJS.MD5(Date.now().toString()).toString();
// Вставляем новую покупку в базу данных
const stockResult = await db.runAsync(
'UPDATE products SET quantity_in_stock = quantity_in_stock - ? WHERE id = ? AND quantity_in_stock >= ?',
[quantity, productId, quantity]
);
if (stockResult.changes === 0) {
throw new Error('Insufficient stock');
}
const txHash = crypto.randomUUID();
const result = await db.runAsync(
`INSERT INTO purchases (user_id, product_id, wallet_type, quantity, total_price, purchase_date, tx_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO purchases (user_id, product_id, wallet_type, quantity, total_price, purchase_date, tx_hash, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'completed')`,
[userId, productId, sourceWalletType, quantity, totalPrice, new Date().toISOString(), txHash]
);
// Возвращаем ID новой покупки
await db.runAsync('COMMIT');
return result.lastID;
} catch (error) {
try { await db.runAsync('ROLLBACK'); } catch (_) {}
console.error('Error creating purchase:', error);
throw error;
}
@@ -110,34 +120,38 @@ class PurchaseService {
static async updatePurchaseStatus(purchaseId, status) {
try {
const purchase = await this.getPurchaseById(purchaseId);
await db.runAsync('BEGIN IMMEDIATE TRANSACTION');
const purchase = await db.getAsync(
'SELECT * FROM purchases WHERE id = ?',
[purchaseId]
);
if (!purchase) {
throw new Error('Purchase not found');
}
if (status === 'canceled') {
const user = await UserService.getUserByUserId(purchase.user_id);
// Возвращаем средства на бонусный или крипто-баланс
if (purchase.wallet_type === 'bonus') {
if (purchase.wallet_type.startsWith('bonus')) {
await db.runAsync(
'UPDATE users SET bonus_balance = bonus_balance + ? WHERE id = ?',
[purchase.total_price, user.id]
[purchase.total_price, purchase.user_id]
);
} else {
await db.runAsync(
'UPDATE users SET total_balance = total_balance + ? WHERE id = ?',
[purchase.total_price, user.id]
[purchase.total_price, purchase.user_id]
);
}
}
// Обновляем статус покупки
await db.runAsync(
'UPDATE purchases SET status = ? WHERE id = ?',
[status, purchaseId]
);
await db.runAsync('COMMIT');
} catch (error) {
try { await db.runAsync('ROLLBACK'); } catch (_) {}
console.error('Error updating purchase status:', error);
throw new Error('Failed to update purchase status');
}

View File

@@ -4,6 +4,11 @@ import db from "../config/database.js";
import Wallet from "../models/Wallet.js";
import WalletUtils from "../utils/walletUtils.js";
const ALLOWED_USER_FIELDS = new Set([
'telegram_id', 'username', 'country', 'city',
'district', 'status', 'total_balance', 'bonus_balance'
]);
class UserService {
// Функция для нормализации telegram_id
@@ -44,10 +49,14 @@ class UserService {
}
// Подготавливаем данные для вставки в базу данных
const fields = Object.keys(userData);
const values = Object.values(userData);
const fields = Object.keys(userData).filter(key => ALLOWED_USER_FIELDS.has(key));
const values = fields.map(key => userData[key]);
const marks = Array(fields.length).fill('?');
if (fields.length === 0) {
throw new Error('No valid fields provided for user creation');
}
const query = `
INSERT INTO users (${fields.join(', ')})
VALUES (${marks.join(', ')})

View File

@@ -4,7 +4,7 @@ import db from "../config/database.js";
import config from "../config/config.js";
import WalletUtils from "../utils/walletUtils.js";
import WalletGenerator from "../utils/walletGenerator.js";
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
class WalletService {
static async getArchivedWalletsCount(user) {
@@ -148,28 +148,8 @@ class WalletService {
if (typeof userId !== 'number' && typeof userId !== 'string') {
throw new Error('Invalid user ID');
}
const userIdStr = userId.toString();
// Создаем ключ шифрования с использованием хэша
const baseKey = config.ENCRYPTION_KEY;
const combinedKey = baseKey + userIdStr;
// Создаем ключ и IV с использованием SHA-256
const key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userIdStr)
.digest();
const iv = crypto.randomBytes(16); // Генерируем случайный IV
// Создаем шифр
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
// Шифруем мнемонику
let encrypted = cipher.update(mnemonic, 'utf8', 'hex');
encrypted += cipher.final('hex');
// Сохраняем зашифрованные данные вместе с IV
const encryptedMnemonic = iv.toString('hex') + ':' + encrypted;
const encryptedMnemonic = encrypt(mnemonic, userId);
// Определяем путь деривации
let derivationPath;
@@ -213,35 +193,9 @@ class WalletService {
}
// Проверяем целостность записанной мнемоники
// Разделяем IV и зашифрованные данные для проверки
const [verify_ivHex, verify_encryptedData] = insertedWallet.mnemonic.split(':');
const verify_iv = Buffer.from(verify_ivHex, 'hex');
// Создаем ключ для проверки
const verify_key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userId)
.digest();
// Создаем дешифратор для проверки
const decipher = crypto.createDecipheriv('aes-256-cbc', verify_key, verify_iv);
// Дешифруем данные
let decryptedMnemonic;
try {
decryptedMnemonic = decipher.update(verify_encryptedData, 'hex', 'utf8');
decryptedMnemonic += decipher.final('utf8');
if (!decryptedMnemonic) {
throw new Error('Failed to decrypt mnemonic');
}
} catch (error) {
console.error('Decryption error:', error);
throw new Error('Failed to decrypt mnemonic: ' + error.message);
}
const decryptedMnemonic = decrypt(insertedWallet.mnemonic, userId);
if (decryptedMnemonic !== mnemonic) {
console.error('Mnemonic verification failed for wallet:', walletType);
// Удаляем кошелек в случае ошибки
await db.runAsync(
`DELETE FROM crypto_wallets
WHERE user_id = ? AND wallet_type = ?`,
@@ -271,33 +225,10 @@ class WalletService {
static async decryptMnemonic(encryptedMnemonic, userId) {
try {
// Проверяем наличие ключа шифрования
if (!config.ENCRYPTION_KEY || typeof config.ENCRYPTION_KEY !== 'string') {
throw new Error('Encryption key is not configured');
}
// Разделяем IV и зашифрованные данные
const [ivHex, encryptedData] = encryptedMnemonic.split(':');
if (!ivHex || !encryptedData) {
throw new Error('Invalid encrypted mnemonic format');
}
// Создаем ключ дешифрования
const key = crypto.createHash('sha256')
.update(config.ENCRYPTION_KEY + userId.toString())
.digest();
// Преобразуем IV из hex
const iv = Buffer.from(ivHex, 'hex');
// Создаем дешифратор
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
// Дешифруем данные
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
return decrypt(encryptedMnemonic, userId);
} catch (error) {
console.error('Error decrypting mnemonic:', error);
throw new Error('Failed to decrypt mnemonic: ' + error.message);