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

60
src/utils/encryption.js Normal file
View File

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

View File

@@ -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;
}
}
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, '');
}
}