hotfix: Security, performance, and code cleanup

- Remove hardcoded database password fallback
- Add encryption salt validation (min 32 chars)
- Separate EMAIL_ENCRYPTION_KEY from JWT_SECRET
- Fix command injection in status.service.js (use execFileSync)
- Remove unnecessary SQL injection regex middleware
- Create shared utilities (queryBuilder, pagination, emailAccountHelper)
- Fix N+1 query problems in contact and todo services
- Merge duplicate JMAP config functions
- Add database indexes migration
- Standardize error responses with error codes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
richardtekula
2026-01-19 07:17:23 +01:00
parent 0523087961
commit 73a3c6bf95
15 changed files with 278 additions and 114 deletions

View File

@@ -3,8 +3,6 @@ import morgan from 'morgan';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
dotenv.config();
import { validateBody } from './middlewares/global/validateBody.js';
import { notFound } from './middlewares/global/notFound.js';

View File

@@ -8,7 +8,7 @@ const pool = new Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASSWORD || 'heslo123',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'crm',
max: 20, // maximum number of connections in pool
idleTimeoutMillis: 30000,

2
src/config/env.js Normal file
View File

@@ -0,0 +1,2 @@
import dotenv from 'dotenv';
dotenv.config();

View File

@@ -0,0 +1,21 @@
-- Add indexes for frequently used foreign keys
CREATE INDEX IF NOT EXISTS idx_contacts_email_account_id ON contacts(email_account_id);
CREATE INDEX IF NOT EXISTS idx_contacts_company_id ON contacts(company_id);
CREATE INDEX IF NOT EXISTS idx_todos_project_id ON todos(project_id);
CREATE INDEX IF NOT EXISTS idx_todos_company_id ON todos(company_id);
CREATE INDEX IF NOT EXISTS idx_notes_company_id ON notes(company_id);
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_todo_id ON notes(todo_id);
-- Add indexes for search fields
CREATE INDEX IF NOT EXISTS idx_contacts_email ON contacts(email);
CREATE INDEX IF NOT EXISTS idx_companies_name ON companies(name);
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
-- Add indexes for status/filter fields
CREATE INDEX IF NOT EXISTS idx_todos_status ON todos(status);
CREATE INDEX IF NOT EXISTS idx_projects_status ON projects(status);
-- Add composite indexes for frequent queries
CREATE INDEX IF NOT EXISTS idx_todos_user_status ON todo_users(user_id, todo_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_user_start ON time_entries(user_id, start_time);

View File

@@ -1,3 +1,4 @@
import './config/env.js';
import app from './app.js';
import { startAllCronJobs } from './cron/index.js';
import { logger } from './utils/logger.js';

View File

@@ -1,20 +1,9 @@
import { logger } from '../../utils/logger.js';
/**
* Body validation middleware
* NOTE: SQL injection regex patterns have been removed as they are unnecessary
* when using Drizzle ORM which uses parameterized queries.
* The regex patterns also caused false positives (e.g., when user types "SELECT" in text).
*/
export function validateBody(req, res, next) {
const data = JSON.stringify({ body: req.body, query: req.query, params: req.params });
const dangerousPatterns = [
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|TRUNCATE|ALTER|CREATE|EXEC|UNION|LOAD_FILE|OUTFILE)\b.*\b(FROM|INTO|TABLE|DATABASE)\b)/gi,
/\b(OR 1=1|AND 1=1|OR '1'='1'|--|#|\/\*|\*\/|;|\bUNION\b.*?\bSELECT\b)/gi,
/\b(\$where|\$ne|\$gt|\$lt|\$regex|\$exists|\$not|\$or|\$and)\b/gi,
/(<script|<\/script>|document\.cookie|eval\(|alert\(|javascript:|onerror=|onmouseover=)/gi,
/(\bexec\s*xp_cmdshell|\bshutdown\b|\bdrop\s+database|\bdelete\s+from)/gi,
/(\b(base64_decode|cmd|powershell|wget|curl|rm -rf|nc -e|perl -e|python -c)\b)/gi,
];
for (const pattern of dangerousPatterns) {
if (pattern.test(data)) {
logger.warn('Detegovaný podozrivý vstup', { data: data.substring(0, 100) });
return res.status(400).json({ message: 'Detegovaný škodlivý obsah v požiadavke' });
}
}
next();
}

View File

@@ -1,9 +1,33 @@
import { db } from '../config/database.js';
import { contacts, emails, companies } from '../db/schema.js';
import { contacts, emails, companies, emailAccounts } from '../db/schema.js';
import { eq, and, desc, or, ne } from 'drizzle-orm';
import { NotFoundError, ConflictError } from '../utils/errors.js';
import { syncEmailsFromSender } from './jmap/index.js';
/**
* Get contacts with related data (emailAccount, company) using joins
* Avoids N+1 query problem by fetching all related data in a single query
*/
export const getContactsWithRelations = async (emailAccountId) => {
const result = await db
.select({
contact: contacts,
emailAccount: emailAccounts,
company: companies,
})
.from(contacts)
.leftJoin(emailAccounts, eq(contacts.emailAccountId, emailAccounts.id))
.leftJoin(companies, eq(contacts.companyId, companies.id))
.where(eq(contacts.emailAccountId, emailAccountId))
.orderBy(desc(contacts.addedAt));
return result.map((row) => ({
...row.contact,
emailAccount: row.emailAccount,
company: row.company,
}));
};
/**
* Get all contacts for an email account
* Kontakty patria k email accountu, nie k jednotlivým používateľom

View File

@@ -1,38 +1,40 @@
import { decryptPassword } from '../../utils/password.js';
/**
* Get JMAP configuration for user (legacy - for backward compatibility)
* Get JMAP configuration - from email account or user object
* @param {object} source - Email account object or user object with email credentials
* @param {boolean} isEncrypted - Whether the password needs decryption (default: true for user, false for account)
*/
export const getJmapConfig = (user) => {
if (!user.email || !user.emailPassword || !user.jmapAccountId) {
throw new Error('Používateľ nemá nastavený email účet');
export const getJmapConfig = (source, isEncrypted = true) => {
if (!source) {
throw new Error('Source object is required');
}
// Decrypt email password for JMAP API
const decryptedPassword = decryptPassword(user.emailPassword);
// Check required fields
const email = source.email;
const password = source.emailPassword;
const accountId = source.jmapAccountId;
if (!email || !password || !accountId) {
throw new Error('Email account je neuplny');
}
// Decrypt password if needed
const decryptedPassword = isEncrypted ? decryptPassword(password) : password;
return {
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
username: user.email,
username: email,
password: decryptedPassword,
accountId: user.jmapAccountId,
accountId: accountId,
};
};
/**
* Get JMAP configuration from email account object
* NOTE: Expects emailPassword to be already decrypted (from getEmailAccountWithCredentials)
* @deprecated Use getJmapConfig(emailAccount, false) instead
*/
export const getJmapConfigFromAccount = (emailAccount) => {
if (!emailAccount.email || !emailAccount.emailPassword || !emailAccount.jmapAccountId) {
throw new Error('Email účet je neúplný');
}
// Password is already decrypted by getEmailAccountWithCredentials
return {
server: process.env.JMAP_SERVER || 'https://mail.truemail.sk/jmap/',
username: emailAccount.email,
password: emailAccount.emailPassword,
accountId: emailAccount.jmapAccountId,
};
return getJmapConfig(emailAccount, false);
};

View File

@@ -1,6 +1,7 @@
import os from 'os';
import path from 'path';
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import fs from 'fs';
import { pool } from '../config/database.js';
/**
@@ -31,7 +32,7 @@ const getCpuUsage = () => {
const getMemoryUsage = () => {
try {
// Read /proc/meminfo for accurate memory stats (like htop)
const meminfo = execSync('cat /proc/meminfo', { encoding: 'utf8' });
const meminfo = fs.readFileSync('/proc/meminfo', 'utf8');
const lines = meminfo.split('\n');
const getValue = (key) => {
@@ -79,8 +80,9 @@ const getMemoryUsage = () => {
*/
const getDiskUsage = () => {
try {
const output = execSync('df -BG / | tail -1', { encoding: 'utf8' });
const parts = output.trim().split(/\s+/);
const output = execFileSync('df', ['-BG', '/'], { encoding: 'utf8' });
const lines = output.trim().split('\n');
const parts = lines[lines.length - 1].split(/\s+/);
const totalGB = parseInt(parts[1]) || 0;
const usedGB = parseInt(parts[2]) || 0;
@@ -118,7 +120,7 @@ const getBackendStats = () => {
const getUploadsSize = () => {
try {
const uploadsPath = path.join(process.cwd(), 'uploads');
const output = execSync(`du -sm "${uploadsPath}" 2>/dev/null || echo "0"`, { encoding: 'utf8' });
const output = execFileSync('du', ['-sm', uploadsPath], { encoding: 'utf8' });
const sizeMB = parseInt(output.split('\t')[0]) || 0;
return {

View File

@@ -325,32 +325,29 @@ export const deleteTodo = async (todoId) => {
/**
* Get todo with related data (notes, project, company, assigned users)
* Optimized to use joins and Promise.all to avoid N+1 query problem
*/
export const getTodoWithRelations = async (todoId) => {
const todo = await getTodoById(todoId);
// Get project if exists
let project = null;
if (todo.projectId) {
[project] = await db
.select()
.from(projects)
.where(eq(projects.id, todo.projectId))
// Fetch todo with project and company in single query using joins
const [todoResult] = await db
.select({
todo: todos,
project: projects,
company: companies,
})
.from(todos)
.leftJoin(projects, eq(todos.projectId, projects.id))
.leftJoin(companies, eq(todos.companyId, companies.id))
.where(eq(todos.id, todoId))
.limit(1);
if (!todoResult) {
throw new NotFoundError('Todo nenajdene');
}
// Get company if exists
let company = null;
if (todo.companyId) {
[company] = await db
.select()
.from(companies)
.where(eq(companies.id, todo.companyId))
.limit(1);
}
// Get assigned users from todo_users junction table
const assignedUsers = await db
// Batch fetch for assigned users and notes in parallel
const [assignedUsers, todoNotes] = await Promise.all([
db
.select({
id: users.id,
username: users.username,
@@ -360,19 +357,19 @@ export const getTodoWithRelations = async (todoId) => {
})
.from(todoUsers)
.innerJoin(users, eq(todoUsers.userId, users.id))
.where(eq(todoUsers.todoId, todoId));
.where(eq(todoUsers.todoId, todoId)),
// Get related notes
const todoNotes = await db
db
.select()
.from(notes)
.where(eq(notes.todoId, todoId))
.orderBy(desc(notes.createdAt));
.orderBy(desc(notes.createdAt)),
]);
return {
...todo,
project,
company,
...todoResult.todo,
project: todoResult.project,
company: todoResult.company,
assignedUsers,
notes: todoNotes,
};

View File

@@ -0,0 +1,31 @@
import { db } from '../config/database.js';
import { emailAccounts } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { NotFoundError, BadRequestError } from './errors.js';
export const getEmailAccountById = async (accountId, userId = null) => {
const conditions = [eq(emailAccounts.id, accountId)];
if (userId) {
conditions.push(eq(emailAccounts.userId, userId));
}
const [account] = await db
.select()
.from(emailAccounts)
.where(and(...conditions))
.limit(1);
if (!account) {
throw new NotFoundError('Email account not found');
}
return account;
};
export const getActiveEmailAccount = async (accountId, userId = null) => {
const account = await getEmailAccountById(accountId, userId);
if (!account.isActive) {
throw new BadRequestError('Email account is not active');
}
return account;
};

View File

@@ -1,11 +1,12 @@
/**
* Custom error classes pre aplikáciu
* Custom error classes pre aplikaciu
*/
export class AppError extends Error {
constructor(message, statusCode = 500, details = null) {
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR', details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
this.isOperational = true;
@@ -15,75 +16,77 @@ export class AppError extends Error {
export class ValidationError extends AppError {
constructor(message, details = null) {
super(message, 400, details);
super(message, 400, 'VALIDATION_ERROR', details);
this.name = 'ValidationError';
}
}
export class BadRequestError extends AppError {
constructor(message = 'Zlá požiadavka') {
super(message, 400);
constructor(message = 'Zla poziadavka') {
super(message, 400, 'BAD_REQUEST');
this.name = 'BadRequestError';
}
}
export class AuthenticationError extends AppError {
constructor(message = 'Neautorizovaný prístup') {
super(message, 401);
constructor(message = 'Neautorizovany pristup') {
super(message, 401, 'UNAUTHORIZED');
this.name = 'AuthenticationError';
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Prístup zamietnutý') {
super(message, 403);
constructor(message = 'Pristup zamietnuty') {
super(message, 403, 'FORBIDDEN');
this.name = 'ForbiddenError';
}
}
export class NotFoundError extends AppError {
constructor(message = 'Nenájdené') {
super(message, 404);
constructor(message = 'Nenajdene') {
super(message, 404, 'NOT_FOUND');
this.name = 'NotFoundError';
}
}
export class ConflictError extends AppError {
constructor(message = 'Konflikt') {
super(message, 409);
super(message, 409, 'CONFLICT');
this.name = 'ConflictError';
}
}
export class RateLimitError extends AppError {
constructor(message = 'Príliš veľa požiadaviek') {
super(message, 429);
constructor(message = 'Prilis vela poziadaviek') {
super(message, 429, 'RATE_LIMIT_EXCEEDED');
this.name = 'RateLimitError';
}
}
/**
* Error response formatter
* Standardized error response formatter
* @param {Error} error
* @param {boolean} includeStack - Či má zahrnúť stack trace (len development)
* @param {boolean} includeStack - Whether to include stack trace (development only)
* @returns {Object} Formatted error response
*/
export const formatErrorResponse = (error, includeStack = false) => {
const response = {
success: false,
error: {
message: error.message || 'Interná chyba servera',
statusCode: error.statusCode || 500,
code: error.code || 'INTERNAL_ERROR',
message: error.message || 'Interna chyba servera',
details: error.details || null,
},
};
if (error.details) {
response.error.details = error.details;
}
if (includeStack && process.env.NODE_ENV === 'development') {
response.error.stack = error.stack;
}
return response;
};
/**
* Format error for API response (alias for formatErrorResponse)
*/
export const formatError = formatErrorResponse;

25
src/utils/pagination.js Normal file
View File

@@ -0,0 +1,25 @@
export const DEFAULT_PAGE_SIZE = 20;
export const MAX_PAGE_SIZE = 100;
export const parsePagination = (query) => {
const page = Math.max(1, parseInt(query.page) || 1);
const limit = Math.min(
MAX_PAGE_SIZE,
Math.max(1, parseInt(query.limit) || DEFAULT_PAGE_SIZE)
);
const offset = (page - 1) * limit;
return { page, limit, offset };
};
export const paginatedResponse = (data, total, { page, limit }) => ({
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
});

View File

@@ -66,15 +66,18 @@ export const generateVerificationToken = () => {
* @returns {string} Encrypted password in format: iv:authTag:encrypted
*/
export const encryptPassword = (text) => {
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required for password encryption');
if (!process.env.EMAIL_ENCRYPTION_KEY) {
throw new Error('EMAIL_ENCRYPTION_KEY environment variable is required for password encryption');
}
if (!process.env.ENCRYPTION_SALT) {
throw new Error('ENCRYPTION_SALT environment variable is required for password encryption');
}
if (process.env.ENCRYPTION_SALT.length < 32) {
throw new Error('ENCRYPTION_SALT must be at least 32 characters');
}
const algorithm = 'aes-256-gcm';
const key = crypto.scryptSync(process.env.JWT_SECRET, process.env.ENCRYPTION_SALT, 32);
const key = crypto.scryptSync(process.env.EMAIL_ENCRYPTION_KEY, process.env.ENCRYPTION_SALT, 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
@@ -92,15 +95,18 @@ export const encryptPassword = (text) => {
* @returns {string} Plain text password
*/
export const decryptPassword = (encryptedText) => {
if (!process.env.JWT_SECRET) {
throw new Error('JWT_SECRET environment variable is required for password decryption');
if (!process.env.EMAIL_ENCRYPTION_KEY) {
throw new Error('EMAIL_ENCRYPTION_KEY environment variable is required for password decryption');
}
if (!process.env.ENCRYPTION_SALT) {
throw new Error('ENCRYPTION_SALT environment variable is required for password decryption');
}
if (process.env.ENCRYPTION_SALT.length < 32) {
throw new Error('ENCRYPTION_SALT must be at least 32 characters');
}
const algorithm = 'aes-256-gcm';
const key = crypto.scryptSync(process.env.JWT_SECRET, process.env.ENCRYPTION_SALT, 32);
const key = crypto.scryptSync(process.env.EMAIL_ENCRYPTION_KEY, process.env.ENCRYPTION_SALT, 32);
const parts = encryptedText.split(':');
const iv = Buffer.from(parts[0], 'hex');

63
src/utils/queryBuilder.js Normal file
View File

@@ -0,0 +1,63 @@
import { and, or, ilike, eq, desc, asc } from 'drizzle-orm';
import { NotFoundError } from './errors.js';
/**
* Genericky query builder pre list operacie
* @param {object} db - Drizzle db instance
* @param {object} table - Drizzle table
* @param {object} options - Query options
*/
export const buildListQuery = (db, table, options = {}) => {
const {
searchTerm,
searchFields = [],
filters = {},
orderBy = 'createdAt',
orderDir = 'desc',
limit,
offset,
} = options;
let query = db.select().from(table);
const conditions = [];
// Search
if (searchTerm && searchFields.length > 0) {
const searchConditions = searchFields.map((field) =>
ilike(table[field], `%${searchTerm}%`)
);
conditions.push(or(...searchConditions));
}
// Filters
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
conditions.push(eq(table[key], value));
}
});
if (conditions.length > 0) {
query = query.where(and(...conditions));
}
// Order
const orderFn = orderDir === 'desc' ? desc : asc;
query = query.orderBy(orderFn(table[orderBy]));
// Pagination
if (limit) query = query.limit(limit);
if (offset) query = query.offset(offset);
return query;
};
/**
* Wrapper pre single item fetch s NotFoundError
*/
export const findOneOrThrow = async (db, table, whereClause, errorMessage) => {
const [item] = await db.select().from(table).where(whereClause).limit(1);
if (!item) {
throw new NotFoundError(errorMessage);
}
return item;
};