fix: Harden security - CORS, XSS, file uploads, error handling
- Restrict no-origin CORS bypass to development only - Activate xss-clean middleware for input sanitization - Add MIME type whitelist and filename sanitization to file uploads - Reduce project upload limit from 50MB to 20MB - Stop leaking stack traces in error responses Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
43
src/app.js
43
src/app.js
@@ -3,6 +3,7 @@ import morgan from 'morgan';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import xssClean from 'xss-clean';
|
||||||
|
|
||||||
import { validateBody } from './middlewares/global/validateBody.js';
|
import { validateBody } from './middlewares/global/validateBody.js';
|
||||||
import { notFound } from './middlewares/global/notFound.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 = {
|
const corsOptions = {
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
// Requests with no origin (curl, Postman, server-to-server)
|
||||||
if (!origin) return callback(null, true);
|
// 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
|
// Check CORS_ORIGIN env variable first
|
||||||
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
|
|
||||||
const corsOrigin = process.env.CORS_ORIGIN;
|
const corsOrigin = process.env.CORS_ORIGIN;
|
||||||
if (corsOrigin && origin === corsOrigin) {
|
if (corsOrigin && origin === corsOrigin) {
|
||||||
return callback(null, true);
|
return callback(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedPatterns.some(pattern => pattern.test(origin))) {
|
// In development, allow localhost and local network IPs
|
||||||
return callback(null, true);
|
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'));
|
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(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// XSS sanitization - clean user input in body, params, and query
|
||||||
|
app.use(xssClean());
|
||||||
|
|
||||||
// Custom body validation middleware
|
// Custom body validation middleware
|
||||||
app.use(validateBody);
|
app.use(validateBody);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function errorHandler(err, req, res, next) {
|
|||||||
return next(err);
|
return next(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log error
|
// Log full error server-side (including stack trace)
|
||||||
logger.error('Neošetrená chyba', err);
|
logger.error('Neošetrená chyba', err);
|
||||||
|
|
||||||
// Get status code
|
// Get status code
|
||||||
@@ -16,8 +16,8 @@ export function errorHandler(err, req, res, next) {
|
|||||||
? res.statusCode
|
? res.statusCode
|
||||||
: 500;
|
: 500;
|
||||||
|
|
||||||
// Format error response
|
// Never send stack traces to the client, even in development
|
||||||
const errorResponse = formatErrorResponse(err, process.env.NODE_ENV === 'development');
|
const errorResponse = formatErrorResponse(err, false);
|
||||||
|
|
||||||
res.status(statusCode).json(errorResponse);
|
res.status(statusCode).json(errorResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,19 +16,42 @@ if (!fs.existsSync(uploadsDir)) {
|
|||||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
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({
|
const storage = multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
cb(null, uploadsDir);
|
cb(null, uploadsDir);
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
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);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
cb(null, uniqueSuffix + '-' + file.originalname);
|
cb(null, uniqueSuffix + '-' + sanitized);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
|
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
|
// Validation schemas
|
||||||
|
|||||||
@@ -10,10 +10,33 @@ import { createProjectSchema, updateProjectSchema } from '../validators/crm.vali
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Configure multer for file uploads (memory storage)
|
// 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({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
limits: {
|
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.'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user