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

@@ -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",

View File

@@ -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 = ?,

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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);