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:
@@ -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 = ?',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(', ')})
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user