From de415633be639e9849623a11009d8c7485af0202 Mon Sep 17 00:00:00 2001 From: NW Date: Wed, 17 Jun 2026 21:52:49 +0100 Subject: [PATCH] =?UTF-8?q?feat(security):=20Phase=201=20=E2=80=94=20criti?= =?UTF-8?q?cal=20security=20fixes=20and=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #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 --- docker-compose.yml | 16 +-- src/__tests__/security.test.js | 34 ++++++ src/config/config.js | 31 ++++- src/config/database.js | 8 ++ .../adminHandlers/adminDumpHandler.js | 2 + .../adminHandlers/adminProductHandler.js | 42 +++++++ .../adminHandlers/adminUserHandler.js | 5 +- .../adminHandlers/adminWalletsHandler.js | 26 +++++ .../userHandlers/userProductHandler.js | 15 +++ .../userHandlers/userPurchaseHandler.js | 1 + .../userHandlers/userWalletsHandler.js | 6 + src/services/productService.js | 19 +++ src/services/purchaseService.js | 110 ++++++++++-------- src/services/userService.js | 13 ++- src/services/walletService.js | 77 +----------- src/utils/encryption.js | 60 ++++++++++ src/utils/validators.js | 49 ++++++-- 17 files changed, 360 insertions(+), 154 deletions(-) create mode 100644 src/__tests__/security.test.js create mode 100644 src/utils/encryption.js diff --git a/docker-compose.yml b/docker-compose.yml index b9d8d8e..1889c75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,20 +7,8 @@ services: hostname: telegram_shop_prod container_name: telegram_shop_prod restart: always - environment: - - WG_ENABLED=false # Включение/выключение WireGuard (true/false) - - BOT_TOKEN=7626758249:AAEdcbXJpW1VsnJJtc8kZ5VBsYMFR242wgk # Токен Telegram бота - - ADMIN_IDS=732563549,390431690,217546867 # ID администраторов через запятую - - SUPPORT_LINK=https://t.me/neroworm # Ссылка на поддержку - - CATALOG_PATH=./catalog # Путь к каталогу товаров - - COMMISSION_ENABLED=true # Включение комиссии (true/false) - - COMMISSION_PERCENT=5 # Процент комиссии - # Кошельки для комиссий: - - COMMISSION_WALLET_BTC=bc1qyourbtcaddress # Bitcoin - - COMMISSION_WALLET_LTC=ltc1qyourltcaddress # Litecoin - - COMMISSION_WALLET_USDT=0x654dbef74cae96f19aa03e1b0abf569b111572cc # USDT (ERC-20) - - COMMISSION_WALLET_USDC=0xYourUsdcAddress # USDC (ERC-20) - - COMMISSION_WALLET_ETH=0xYourEthAddress # Ethereum + env_file: + - .env volumes: - ./db:/app/db/ # Синхронизация базы данных - ./src:/app/src/ # Синхронизация исходного кода diff --git a/src/__tests__/security.test.js b/src/__tests__/security.test.js new file mode 100644 index 0000000..3d68fc1 --- /dev/null +++ b/src/__tests__/security.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; + +describe('SQL Injection Prevention', () => { + it('should reject invalid table name in checkColumnExists', () => { + const ALLOWED_TABLES = new Set([ + 'users', 'crypto_wallets', 'transactions', 'products', + 'purchases', 'locations', 'categories' + ]); + + expect(ALLOWED_TABLES.has('users')).toBe(true); + expect(ALLOWED_TABLES.has('DROP TABLE users;--')).toBe(false); + expect(ALLOWED_TABLES.has('1=1')).toBe(false); + }); + + it('should filter out disallowed user fields', () => { + const ALLOWED_USER_FIELDS = new Set([ + 'telegram_id', 'username', 'country', 'city', + 'district', 'status', 'total_balance', 'bonus_balance' + ]); + + const maliciousData = { + telegram_id: '123', + username: 'test', + admin: true, + role: 'superadmin' + }; + + const safeFields = Object.keys(maliciousData).filter(key => ALLOWED_USER_FIELDS.has(key)); + + expect(safeFields).toEqual(['telegram_id', 'username']); + expect(safeFields).not.toContain('admin'); + expect(safeFields).not.toContain('role'); + }); +}); diff --git a/src/config/config.js b/src/config/config.js index bb7d85e..a21bbab 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -1,16 +1,37 @@ +if (!process.env.BOT_TOKEN) { + console.error('FATAL: BOT_TOKEN environment variable is required'); + process.exit(1); +} + +if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length < 32) { + console.error( + 'FATAL: ENCRYPTION_KEY environment variable is required and must be at least 32 characters. ' + + 'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"' + ); + process.exit(1); +} + +const adminIdsRaw = process.env.ADMIN_IDS; +const ADMIN_IDS = adminIdsRaw + ? adminIdsRaw.split(',').map(id => id.trim()).filter(Boolean) + : []; + +if (!adminIdsRaw) { + console.warn('WARNING: ADMIN_IDS environment variable is not set. No admins configured.'); +} + export default { BOT_TOKEN: process.env.BOT_TOKEN, - ADMIN_IDS: process.env.ADMIN_IDS.split(","), + ADMIN_IDS, SUPPORT_LINK: process.env.SUPPORT_LINK, CATALOG_PATH: process.env.CATALOG_PATH, - ENCRYPTION_KEY: process.env.ENCRYPTION_KEY || '9о234893245wer6ga3425670', - - // Commission settings + ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, + COMMISSION_ENABLED: process.env.COMMISSION_ENABLED === 'true', COMMISSION_PERCENT: parseFloat(process.env.COMMISSION_PERCENT) || 0, COMMISSION_WALLETS: { BTC: process.env.COMMISSION_WALLET_BTC, - LTC: process.env.COMMISSION_WALLET_LTC, + LTC: process.env.COMMISSION_WALLET_LTC, USDT: process.env.COMMISSION_WALLET_USDT, USDC: process.env.COMMISSION_WALLET_USDC, ETH: process.env.COMMISSION_WALLET_ETH diff --git a/src/config/database.js b/src/config/database.js index 41e57b6..955d2ef 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -8,6 +8,11 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DB_PATH = new URL('../../db/shop.db', import.meta.url).pathname; +const ALLOWED_TABLES = new Set([ + 'users', 'crypto_wallets', 'transactions', 'products', + 'purchases', 'locations', 'categories' +]); + // Create database with verbose mode for better error reporting const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_CREATE | sqlite3.OPEN_READWRITE, (err) => { if (err) { @@ -55,6 +60,9 @@ db.getAsync = getAsync; // Function to check if a column exists in a table const checkColumnExists = async (tableName, columnName) => { + if (!ALLOWED_TABLES.has(tableName)) { + throw new Error(`Invalid table name: ${tableName}`); + } try { const result = await db.allAsync(` PRAGMA table_info(${tableName}) diff --git a/src/handlers/adminHandlers/adminDumpHandler.js b/src/handlers/adminHandlers/adminDumpHandler.js index 095efdb..9af1ab3 100644 --- a/src/handlers/adminHandlers/adminDumpHandler.js +++ b/src/handlers/adminHandlers/adminDumpHandler.js @@ -39,6 +39,7 @@ export default class AdminDumpHandler { const chatId = callbackQuery.message.chat.id; + // Table names must match ALLOWED_TABLES whitelist in database.js const tables = [ "categories", "crypto_wallets", @@ -98,6 +99,7 @@ export default class AdminDumpHandler { } static async getDumpStatistic() { + // Table names must match ALLOWED_TABLES whitelist in database.js const tables = [ "categories", "crypto_wallets", diff --git a/src/handlers/adminHandlers/adminProductHandler.js b/src/handlers/adminHandlers/adminProductHandler.js index ad1ebaa..87127f2 100644 --- a/src/handlers/adminHandlers/adminProductHandler.js +++ b/src/handlers/adminHandlers/adminProductHandler.js @@ -6,6 +6,7 @@ import bot from "../../context/bot.js"; import CategoryService from "../../services/categoryService.js"; import userStates from "../../context/userStates.js"; import ProductService from "../../services/productService.js"; +import Validators from '../../utils/validators.js'; export default class AdminProductHandler { static isAdmin(userId) { @@ -189,6 +190,11 @@ export default class AdminProductHandler { try { const locationId = state.action.replace('add_category_', ''); + if (!Validators.isValidString(msg.text, 255)) { + await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории'); + return true; + } + await db.runAsync( 'INSERT INTO categories (location_id, name) VALUES (?, ?)', [locationId, msg.text] @@ -269,6 +275,11 @@ export default class AdminProductHandler { console.log('[DEBUG] Updating category:', { locationId, categoryId, newName: msg.text }); + if (!Validators.isValidString(msg.text, 255)) { + await bot.sendMessage(chatId, 'Ошибка: недопустимое название категории'); + return true; + } + await db.runAsync( 'UPDATE categories SET name = ? WHERE id = ? AND location_id = ?', [msg.text, categoryId, locationId] @@ -380,6 +391,11 @@ export default class AdminProductHandler { try { const [locationId, categoryId] = state.action.replace('add_subcategory_', '').split('_'); + if (!Validators.isValidString(msg.text, 255)) { + await bot.sendMessage(chatId, 'Ошибка: недопустимое название подкатегории'); + return true; + } + await db.runAsync( 'INSERT INTO subcategories (category_id, name) VALUES (?, ?)', [categoryId, msg.text] @@ -680,6 +696,21 @@ export default class AdminProductHandler { await db.runAsync('BEGIN TRANSACTION'); for (const product of products) { + if (!Validators.isValidString(product.name, 255)) { + await bot.sendMessage(chatId, `Ошибка: недопустимое название товара "${product.name}"`); + await db.runAsync('ROLLBACK'); + return true; + } + if (!Validators.isValidPrice(product.price)) { + await bot.sendMessage(chatId, `Ошибка: недопустимая цена "${product.price}"`); + await db.runAsync('ROLLBACK'); + return true; + } + if (!Number.isFinite(product.quantity_in_stock) || product.quantity_in_stock < 0) { + await bot.sendMessage(chatId, `Ошибка: недопустимое количество "${product.quantity_in_stock}"`); + await db.runAsync('ROLLBACK'); + return true; + } await db.runAsync( `INSERT INTO products ( location_id, category_id, @@ -769,6 +800,17 @@ export default class AdminProductHandler { await db.runAsync('BEGIN TRANSACTION'); + if (!Validators.isValidString(product.name, 255)) { + await bot.sendMessage(chatId, 'Ошибка: недопустимое название товара'); + await db.runAsync('ROLLBACK'); + return true; + } + if (!Validators.isValidPrice(product.price)) { + await bot.sendMessage(chatId, 'Ошибка: недопустимая цена'); + await db.runAsync('ROLLBACK'); + return true; + } + await db.runAsync( `UPDATE products SET location_id = ?, diff --git a/src/handlers/adminHandlers/adminUserHandler.js b/src/handlers/adminHandlers/adminUserHandler.js index daa0fda..0289399 100644 --- a/src/handlers/adminHandlers/adminUserHandler.js +++ b/src/handlers/adminHandlers/adminUserHandler.js @@ -7,6 +7,7 @@ import UserService from "../../services/userService.js"; import WalletService from "../../services/walletService.js"; import PurchaseService from "../../services/purchaseService.js"; import userStates from "../../context/userStates.js"; +import Validators from '../../utils/validators.js'; export default class AdminUserHandler { static isAdmin(userId) { @@ -471,8 +472,8 @@ export default class AdminUserHandler { const newValue = parseFloat(msg.text); - if (isNaN(newValue)) { - await bot.sendMessage(chatId, 'Invalid value. Try again'); + if (!Validators.isValidBalance(newValue)) { + await bot.sendMessage(chatId, 'Invalid value. Must be a non-negative number. Try again'); return; } diff --git a/src/handlers/adminHandlers/adminWalletsHandler.js b/src/handlers/adminHandlers/adminWalletsHandler.js index bddb1cc..53b2bf5 100644 --- a/src/handlers/adminHandlers/adminWalletsHandler.js +++ b/src/handlers/adminHandlers/adminWalletsHandler.js @@ -10,6 +10,7 @@ import bot from "../../context/bot.js"; import config from '../../config/config.js'; import WalletService from '../../services/walletService.js'; import WalletUtils from '../../utils/walletUtils.js'; +import Validators from '../../utils/validators.js'; import fs from 'fs'; import csvWriter from 'csv-writer'; @@ -72,6 +73,11 @@ export default class AdminWalletsHandler { const chatId = callbackQuery.message.chat.id; const walletType = action.split('_').pop(); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { // Удаляем предыдущее сообщение перед отправкой нового await bot.deleteMessage(chatId, callbackQuery.message.message_id); @@ -327,6 +333,11 @@ export default class AdminWalletsHandler { const walletType = match[1]; // Тип кошелька (например, BTC) const pageNumber = parseInt(match[2]); // Номер страницы + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { // Удаляем предыдущее сообщение перед отправкой нового await bot.deleteMessage(chatId, callbackQuery.message.message_id); @@ -350,6 +361,11 @@ export default class AdminWalletsHandler { const chatId = callbackQuery.message.chat.id; const walletType = action.split('_').pop(); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { console.log(`[${new Date().toISOString()}] Starting CSV export for ${walletType} by user ${callbackQuery.from.id}`); @@ -497,6 +513,11 @@ export default class AdminWalletsHandler { const chatId = callbackQuery.message.chat.id; const walletType = action.split('_').pop(); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { console.log(`[${new Date().toISOString()}] Checking commission balance for ${walletType} by user ${callbackQuery.from.id}`); @@ -560,6 +581,11 @@ export default class AdminWalletsHandler { const chatId = callbackQuery.message.chat.id; const walletType = callbackQuery.data.split('_').pop(); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { // Удаляем предыдущее сообщение перед отправкой нового await bot.deleteMessage(chatId, callbackQuery.message.message_id); diff --git a/src/handlers/userHandlers/userProductHandler.js b/src/handlers/userHandlers/userProductHandler.js index 372d109..8278cf4 100644 --- a/src/handlers/userHandlers/userProductHandler.js +++ b/src/handlers/userHandlers/userProductHandler.js @@ -7,6 +7,7 @@ import ProductService from "../../services/productService.js"; import CategoryService from "../../services/categoryService.js"; import UserService from "../../services/userService.js"; import PurchaseService from "../../services/purchaseService.js"; +import Validators from '../../utils/validators.js'; export default class UserProductHandler { static async showProducts(msg) { @@ -637,6 +638,20 @@ export default class UserProductHandler { const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_'); const state = userStates.get(chatId); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + if (!Validators.isValidNumericId(Number(productId))) { + await bot.sendMessage(chatId, 'Invalid product.'); + return; + } + const qty = Number(quantity); + if (!Number.isFinite(qty) || qty <= 0) { + await bot.sendMessage(chatId, 'Invalid quantity.'); + return; + } + try { await UserService.recalculateUserBalanceByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId); diff --git a/src/handlers/userHandlers/userPurchaseHandler.js b/src/handlers/userHandlers/userPurchaseHandler.js index 990793b..a7f909b 100644 --- a/src/handlers/userHandlers/userPurchaseHandler.js +++ b/src/handlers/userHandlers/userPurchaseHandler.js @@ -10,6 +10,7 @@ import ProductService from "../../services/productService.js"; import CategoryService from "../../services/categoryService.js"; import bot from "../../context/bot.js"; import userStates from "../../context/userStates.js"; +import Validators from '../../utils/validators.js'; export default class UserPurchaseHandler { static async viewPurchasePage(userId, page) { diff --git a/src/handlers/userHandlers/userWalletsHandler.js b/src/handlers/userHandlers/userWalletsHandler.js index 22ff2e8..f291d30 100644 --- a/src/handlers/userHandlers/userWalletsHandler.js +++ b/src/handlers/userHandlers/userWalletsHandler.js @@ -6,6 +6,7 @@ import WalletUtils from '../../utils/walletUtils.js'; import UserService from "../../services/userService.js"; import WalletService from "../../services/walletService.js"; import bot from "../../context/bot.js"; +import Validators from '../../utils/validators.js'; export default class UserWalletsHandler { @@ -355,6 +356,11 @@ export default class UserWalletsHandler { const telegramId = callbackQuery.from.id; const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' '); + if (!Validators.isValidWalletType(walletType)) { + await bot.sendMessage(chatId, 'Invalid wallet type.'); + return; + } + try { const user = await UserService.getUserByTelegramId(telegramId); diff --git a/src/services/productService.js b/src/services/productService.js index 1c92db9..86f9b9b 100644 --- a/src/services/productService.js +++ b/src/services/productService.js @@ -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 = ?', diff --git a/src/services/purchaseService.js b/src/services/purchaseService.js index b08aea6..058c26b 100644 --- a/src/services/purchaseService.js +++ b/src/services/purchaseService.js @@ -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'); } diff --git a/src/services/userService.js b/src/services/userService.js index 430c7d8..5de369f 100644 --- a/src/services/userService.js +++ b/src/services/userService.js @@ -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(', ')}) diff --git a/src/services/walletService.js b/src/services/walletService.js index eabecf1..6c39499 100644 --- a/src/services/walletService.js +++ b/src/services/walletService.js @@ -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); diff --git a/src/utils/encryption.js b/src/utils/encryption.js new file mode 100644 index 0000000..b4e22a2 --- /dev/null +++ b/src/utils/encryption.js @@ -0,0 +1,60 @@ +import crypto from 'crypto'; +import config from '../config/config.js'; + +const HKDF_SALT_LENGTH = 32; +const IV_LENGTH = 16; +const KEY_LENGTH = 32; +const HKDF_INFO = 'telegram-shop-mnemonic-encryption'; + +function deriveKeyHKDF(masterKey, salt, userId) { + return crypto.hkdfSync( + 'sha256', + Buffer.from(masterKey, 'utf8'), + salt, + HKDF_INFO + ':' + userId.toString(), + KEY_LENGTH + ); +} + +function deriveKeyLegacy(masterKey, userId) { + return crypto.createHash('sha256') + .update(masterKey + userId.toString()) + .digest(); +} + +export function encrypt(plaintext, userId) { + const salt = crypto.randomBytes(HKDF_SALT_LENGTH); + const key = deriveKeyHKDF(config.ENCRYPTION_KEY, salt, userId); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv); + let ciphertext = cipher.update(plaintext, 'utf8', 'hex'); + ciphertext += cipher.final('hex'); + return salt.toString('hex') + ':' + iv.toString('hex') + ':' + ciphertext; +} + +export function decrypt(encryptedData, userId) { + const parts = encryptedData.split(':'); + + if (parts.length === 3) { + const salt = Buffer.from(parts[0], 'hex'); + const key = deriveKeyHKDF(config.ENCRYPTION_KEY, salt, userId); + const iv = Buffer.from(parts[1], 'hex'); + const ciphertext = parts[2]; + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + if (parts.length === 2) { + const key = deriveKeyLegacy(config.ENCRYPTION_KEY, userId); + const iv = Buffer.from(parts[0], 'hex'); + const ciphertext = parts[1]; + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); + let decrypted = decipher.update(ciphertext, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; + } + + throw new Error('Invalid encrypted data format'); +} diff --git a/src/utils/validators.js b/src/utils/validators.js index bcd3775..e6c931a 100644 --- a/src/utils/validators.js +++ b/src/utils/validators.js @@ -1,22 +1,51 @@ +const WALLET_TYPES = new Set(['BTC', 'LTC', 'ETH', 'USDT', 'USDC']); + export default class Validators { static isValidLocation(country, city, district) { - return Boolean(country && city && district); + return Boolean(country && city && district && + typeof country === 'string' && typeof city === 'string' && typeof district === 'string' && + country.length <= 255 && city.length <= 255 && district.length <= 255); } static isValidProduct(product) { - return Boolean( - product.name && - product.category && - product.price && - product.price > 0 - ); + return Boolean(product && product.name && product.price && product.price > 0 && + typeof product.name === 'string' && product.name.length <= 255 && + (product.description === undefined || typeof product.description === 'string')); } static isValidQuantity(quantity, stock) { - return quantity > 0 && quantity <= stock; + return Number.isFinite(quantity) && Number.isFinite(stock) && + quantity > 0 && quantity <= stock; } static isValidBalance(balance) { - return balance >= 0; + return Number.isFinite(balance) && balance >= 0; } -} \ No newline at end of file + + static isValidWalletType(walletType) { + return WALLET_TYPES.has(walletType); + } + + static isValidString(value, maxLength = 255) { + return typeof value === 'string' && value.length > 0 && value.length <= maxLength; + } + + static isValidTelegramId(telegramId) { + if (typeof telegramId === 'number') return Number.isFinite(telegramId) && telegramId > 0; + if (typeof telegramId === 'string') return /^\d+$/.test(telegramId) && telegramId.length <= 20; + return false; + } + + static isValidNumericId(id) { + return Number.isFinite(id) && id > 0 && Number.isInteger(id); + } + + static isValidPrice(price) { + return Number.isFinite(price) && price > 0 && price <= 999999999; + } + + static sanitizeString(value, maxLength = 1000) { + if (typeof value !== 'string') return ''; + return value.slice(0, maxLength).replace(/[\x00-\x08\x0B\x0C\x0E\x1F]/g, ''); + } +}