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:
60
src/utils/encryption.js
Normal file
60
src/utils/encryption.js
Normal 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');
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user