diff --git a/src/app.js b/src/app.js index 826001a..f19f09f 100644 --- a/src/app.js +++ b/src/app.js @@ -3,6 +3,7 @@ import morgan from 'morgan'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; +import xssClean from 'xss-clean'; import { validateBody } from './middlewares/global/validateBody.js'; import { notFound } from './middlewares/global/notFound.js'; @@ -58,29 +59,38 @@ app.use( }) ); -// CORS configuration - allow local network access +// CORS configuration +const isProduction = process.env.NODE_ENV === 'production'; const corsOptions = { origin: (origin, callback) => { - // Allow requests with no origin (mobile apps, curl, etc.) - if (!origin) return callback(null, true); + // Requests with no origin (curl, Postman, server-to-server) + // Only allow in development - in production this is a CORS bypass vector + if (!origin) { + if (isProduction) { + return callback(new Error('Not allowed by CORS')); + } + return callback(null, true); + } - // Allow localhost and local network IPs - const allowedPatterns = [ - /^http:\/\/localhost(:\d+)?$/, - /^http:\/\/127\.0\.0\.1(:\d+)?$/, - /^http:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/, - /^http:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, - /^http:\/\/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(:\d+)?$/, - ]; - - // Check if origin matches allowed patterns or CORS_ORIGIN env + // Check CORS_ORIGIN env variable first const corsOrigin = process.env.CORS_ORIGIN; if (corsOrigin && origin === corsOrigin) { return callback(null, true); } - if (allowedPatterns.some(pattern => pattern.test(origin))) { - return callback(null, true); + // In development, allow localhost and local network IPs + if (!isProduction) { + const allowedPatterns = [ + /^https?:\/\/localhost(:\d+)?$/, + /^https?:\/\/127\.0\.0\.1(:\d+)?$/, + /^https?:\/\/192\.168\.\d{1,3}\.\d{1,3}(:\d+)?$/, + /^https?:\/\/10\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/, + /^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}(:\d+)?$/, + ]; + + if (allowedPatterns.some(pattern => pattern.test(origin))) { + return callback(null, true); + } } callback(new Error('Not allowed by CORS')); @@ -95,6 +105,9 @@ app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(cookieParser()); +// XSS sanitization - clean user input in body, params, and query +app.use(xssClean()); + // Custom body validation middleware app.use(validateBody); diff --git a/src/middlewares/global/errorHandler.js b/src/middlewares/global/errorHandler.js index a86143b..017cf3b 100644 --- a/src/middlewares/global/errorHandler.js +++ b/src/middlewares/global/errorHandler.js @@ -6,7 +6,7 @@ export function errorHandler(err, req, res, next) { return next(err); } - // Log error + // Log full error server-side (including stack trace) logger.error('Neošetrená chyba', err); // Get status code @@ -16,8 +16,8 @@ export function errorHandler(err, req, res, next) { ? res.statusCode : 500; - // Format error response - const errorResponse = formatErrorResponse(err, process.env.NODE_ENV === 'development'); + // Never send stack traces to the client, even in development + const errorResponse = formatErrorResponse(err, false); res.status(statusCode).json(errorResponse); } diff --git a/src/routes/ai-kurzy.routes.js b/src/routes/ai-kurzy.routes.js index dfa1da3..2aa488c 100644 --- a/src/routes/ai-kurzy.routes.js +++ b/src/routes/ai-kurzy.routes.js @@ -16,19 +16,42 @@ if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } +const ALLOWED_FILE_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'text/plain', + 'text/csv', +]; + const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, uploadsDir); }, filename: (req, file, cb) => { + // Sanitize filename to prevent path traversal + const sanitized = path.basename(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_'); const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); - cb(null, uniqueSuffix + '-' + file.originalname); + cb(null, uniqueSuffix + '-' + sanitized); } }); const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max + fileFilter: (req, file, cb) => { + if (ALLOWED_FILE_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Nepovolený typ súboru. Povolené: PDF, Word, Excel, obrázky, CSV, TXT.')); + } + }, }); // Validation schemas diff --git a/src/routes/project.routes.js b/src/routes/project.routes.js index dd118d8..0841563 100644 --- a/src/routes/project.routes.js +++ b/src/routes/project.routes.js @@ -10,10 +10,33 @@ import { createProjectSchema, updateProjectSchema } from '../validators/crm.vali import { z } from 'zod'; // Configure multer for file uploads (memory storage) +const ALLOWED_FILE_TYPES = [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'text/plain', + 'text/csv', +]; + const upload = multer({ storage: multer.memoryStorage(), limits: { - fileSize: 50 * 1024 * 1024, // 50MB max + fileSize: 20 * 1024 * 1024, // 20MB max + }, + fileFilter: (req, file, cb) => { + if (ALLOWED_FILE_TYPES.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error('Nepovolený typ súboru. Povolené: PDF, Word, Excel, PowerPoint, obrázky, CSV, TXT.')); + } }, });