← back to Watches

middleware/security.js

654 lines

/**
 * Security Middleware - Enterprise-Grade Security Implementation
 * Implements OWASP Top 10 protections and industry best practices
 */

import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import mongoSanitize from 'express-mongo-sanitize';
import { body, validationResult, param, query } from 'express-validator';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';

// ============================================================================
// RATE LIMITING - Protection against brute force and DDoS attacks
// ============================================================================

/**
 * General API rate limiter - 100 requests per 15 minutes per IP
 * OWASP Recommendation: Implement rate limiting on all API endpoints
 */
export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // Limit each IP to 100 requests per windowMs
  message: {
    error: 'Too many requests from this IP, please try again after 15 minutes',
    retryAfter: '15 minutes'
  },
  standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
  legacyHeaders: false, // Disable the `X-RateLimit-*` headers
  handler: (req, res) => {
    securityLogger.logRateLimitExceeded(req);
    res.status(429).json({
      error: 'Rate limit exceeded',
      message: 'Too many requests from this IP address',
      retryAfter: '15 minutes'
    });
  }
});

/**
 * Strict rate limiter for authentication endpoints - 5 attempts per 15 minutes
 * Prevents brute force attacks on login/admin endpoints
 */
export const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: {
    error: 'Too many authentication attempts, please try again after 15 minutes',
    retryAfter: '15 minutes'
  },
  skipSuccessfulRequests: true, // Don't count successful requests
  handler: (req, res) => {
    securityLogger.logBruteForceAttempt(req);
    res.status(429).json({
      error: 'Authentication rate limit exceeded',
      message: 'Too many failed login attempts',
      retryAfter: '15 minutes',
      accountLocked: true
    });
  }
});

/**
 * Lenient rate limiter for read-only operations - 200 requests per 15 minutes
 */
export const readLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 200,
  message: 'Too many read requests, please slow down'
});

/**
 * Strict rate limiter for write operations - 20 requests per 15 minutes
 */
export const writeLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,
  message: 'Too many write requests, please slow down'
});

// ============================================================================
// HELMET SECURITY HEADERS - Comprehensive HTTP security headers
// ============================================================================

/**
 * Configure Helmet with strict security policies
 * Implements multiple layers of browser-side security
 */
export const helmetConfig = helmet({
  // Content Security Policy - Prevents XSS attacks
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: [
        "'self'",
        "'unsafe-inline'", // Required for React inline scripts
        "'unsafe-eval'", // Required for development
        "https://cdn.jsdelivr.net" // For Chart.js and other CDN libraries
      ],
      styleSrc: [
        "'self'",
        "'unsafe-inline'", // Required for Tailwind and inline styles
        "https://fonts.googleapis.com"
      ],
      fontSrc: ["'self'", "https://fonts.gstatic.com", "data:"],
      imgSrc: ["'self'", "data:", "https:", "blob:"],
      connectSrc: [
        "'self'",
        "ws://45.61.58.125:7600", // WebSocket connection
        "wss://45.61.58.125:7600",
        "http://45.61.58.125:7600",
        "https://45.61.58.125:7600"
      ],
      frameSrc: ["'none'"], // Prevent clickjacking
      objectSrc: ["'none'"], // Disable plugins
      upgradeInsecureRequests: [] // Upgrade HTTP to HTTPS
    }
  },

  // HTTP Strict Transport Security - Force HTTPS
  hsts: {
    maxAge: 31536000, // 1 year
    includeSubDomains: true,
    preload: true
  },

  // X-Frame-Options - Prevent clickjacking
  frameguard: {
    action: 'deny'
  },

  // X-Content-Type-Options - Prevent MIME type sniffing
  noSniff: true,

  // X-XSS-Protection - Enable browser XSS filter
  xssFilter: true,

  // Referrer-Policy - Control referrer information
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin'
  },

  // Permissions-Policy - Control browser features
  permittedCrossDomainPolicies: {
    permittedPolicies: 'none'
  },

  // Hide X-Powered-By header
  hidePoweredBy: true
});

// ============================================================================
// INPUT VALIDATION - Prevent injection attacks
// ============================================================================

/**
 * Validation rules for watch ID parameters
 */
export const validateWatchId = [
  param('id')
    .trim()
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage('Invalid watch ID format')
    .isLength({ min: 1, max: 100 })
    .withMessage('Watch ID must be between 1 and 100 characters')
];

/**
 * Validation rules for search queries
 */
export const validateSearchQuery = [
  query('q')
    .optional()
    .trim()
    .isLength({ max: 200 })
    .withMessage('Search query too long')
    .matches(/^[a-zA-Z0-9\s\-_.]+$/)
    .withMessage('Search query contains invalid characters'),
  query('page')
    .optional()
    .isInt({ min: 1, max: 1000 })
    .withMessage('Invalid page number'),
  query('limit')
    .optional()
    .isInt({ min: 1, max: 100 })
    .withMessage('Invalid limit value')
];

/**
 * Validation rules for price filters
 */
export const validatePriceFilter = [
  query('minPrice')
    .optional()
    .isInt({ min: 0, max: 10000000 })
    .withMessage('Invalid minimum price'),
  query('maxPrice')
    .optional()
    .isInt({ min: 0, max: 10000000 })
    .withMessage('Invalid maximum price')
];

/**
 * Validation rules for watchlist operations
 */
export const validateWatchlistOperation = [
  body('userId')
    .trim()
    .notEmpty()
    .withMessage('User ID is required')
    .isLength({ max: 100 })
    .withMessage('User ID too long')
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage('Invalid user ID format'),
  body('watchId')
    .trim()
    .notEmpty()
    .withMessage('Watch ID is required')
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage('Invalid watch ID format'),
  body('action')
    .trim()
    .isIn(['add', 'remove'])
    .withMessage('Action must be either "add" or "remove"')
];

/**
 * Validation rules for compare operation
 */
export const validateCompareRequest = [
  body('watchIds')
    .isArray({ min: 1, max: 5 })
    .withMessage('watchIds must be an array of 1-5 watch IDs'),
  body('watchIds.*')
    .trim()
    .matches(/^[a-zA-Z0-9_-]+$/)
    .withMessage('Invalid watch ID format in array')
];

/**
 * Middleware to check validation results
 */
export const checkValidationResult = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    securityLogger.logValidationError(req, errors.array());
    return res.status(400).json({
      error: 'Validation failed',
      details: errors.array()
    });
  }
  next();
};

// ============================================================================
// SANITIZATION - Remove malicious content
// ============================================================================

/**
 * Sanitize MongoDB queries to prevent NoSQL injection
 */
export const sanitizeInput = mongoSanitize({
  replaceWith: '_',
  onSanitize: ({ req, key }) => {
    securityLogger.logSanitization(req, key);
  }
});

/**
 * XSS Protection - Clean user input
 */
export const xssProtection = (req, res, next) => {
  // Clean all string inputs
  const cleanString = (str) => {
    if (typeof str !== 'string') return str;
    return str
      .replace(/[<>]/g, '') // Remove < and >
      .replace(/javascript:/gi, '') // Remove javascript: protocol
      .replace(/on\w+\s*=/gi, '') // Remove event handlers
      .trim();
  };

  // Recursively clean object
  const cleanObject = (obj) => {
    if (!obj || typeof obj !== 'object') return obj;

    Object.keys(obj).forEach(key => {
      if (typeof obj[key] === 'string') {
        obj[key] = cleanString(obj[key]);
      } else if (typeof obj[key] === 'object') {
        cleanObject(obj[key]);
      }
    });
    return obj;
  };

  // Clean all input sources
  req.body = cleanObject(req.body);
  req.query = cleanObject(req.query);
  req.params = cleanObject(req.params);

  next();
};

// ============================================================================
// AUTHENTICATION & AUTHORIZATION
// ============================================================================

// JWT Secret - In production, use environment variable
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(32).toString('hex');
const JWT_EXPIRY = '24h';

/**
 * Generate API key for analytics endpoints
 */
export const generateApiKey = () => {
  return crypto.randomBytes(32).toString('hex');
};

/**
 * Verify API key for protected analytics endpoints
 */
export const verifyApiKey = (req, res, next) => {
  const apiKey = req.headers['x-api-key'] || req.query.apiKey;

  if (!apiKey) {
    securityLogger.logUnauthorizedAccess(req, 'Missing API key');
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'API key is required for this endpoint'
    });
  }

  // In production, verify against database
  const validApiKeys = process.env.VALID_API_KEYS
    ? process.env.VALID_API_KEYS.split(',')
    : [];

  if (!validApiKeys.includes(apiKey)) {
    securityLogger.logUnauthorizedAccess(req, 'Invalid API key');
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Invalid API key'
    });
  }

  next();
};

/**
 * JWT Authentication middleware for admin endpoints
 */
export const authenticateJWT = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    securityLogger.logUnauthorizedAccess(req, 'Missing JWT token');
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Authentication token is required'
    });
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    securityLogger.logUnauthorizedAccess(req, 'Invalid JWT token');
    return res.status(403).json({
      error: 'Forbidden',
      message: 'Invalid or expired token'
    });
  }
};

/**
 * Generate JWT token (for admin login)
 */
export const generateJWT = (userId, role = 'admin') => {
  return jwt.sign(
    { userId, role },
    JWT_SECRET,
    { expiresIn: JWT_EXPIRY }
  );
};

// ============================================================================
// SECURITY LOGGING - Track security events
// ============================================================================

class SecurityLogger {
  constructor() {
    this.events = [];
    this.maxEvents = 10000; // Keep last 10k events in memory
  }

  log(event) {
    const logEntry = {
      timestamp: new Date().toISOString(),
      ...event
    };

    this.events.push(logEntry);

    // Keep only last N events
    if (this.events.length > this.maxEvents) {
      this.events.shift();
    }

    // In production, send to external logging service
    console.log(`[SECURITY] ${JSON.stringify(logEntry)}`);
  }

  logRateLimitExceeded(req) {
    this.log({
      type: 'RATE_LIMIT_EXCEEDED',
      severity: 'WARNING',
      ip: req.ip,
      path: req.path,
      method: req.method,
      userAgent: req.get('user-agent')
    });
  }

  logBruteForceAttempt(req) {
    this.log({
      type: 'BRUTE_FORCE_ATTEMPT',
      severity: 'CRITICAL',
      ip: req.ip,
      path: req.path,
      method: req.method,
      userAgent: req.get('user-agent')
    });
  }

  logValidationError(req, errors) {
    this.log({
      type: 'VALIDATION_ERROR',
      severity: 'WARNING',
      ip: req.ip,
      path: req.path,
      method: req.method,
      errors: errors
    });
  }

  logSanitization(req, key) {
    this.log({
      type: 'INPUT_SANITIZED',
      severity: 'INFO',
      ip: req.ip,
      path: req.path,
      sanitizedKey: key
    });
  }

  logUnauthorizedAccess(req, reason) {
    this.log({
      type: 'UNAUTHORIZED_ACCESS',
      severity: 'HIGH',
      ip: req.ip,
      path: req.path,
      method: req.method,
      reason: reason,
      userAgent: req.get('user-agent')
    });
  }

  logSuspiciousActivity(req, description) {
    this.log({
      type: 'SUSPICIOUS_ACTIVITY',
      severity: 'HIGH',
      ip: req.ip,
      path: req.path,
      method: req.method,
      description: description,
      userAgent: req.get('user-agent')
    });
  }

  getRecentEvents(limit = 100) {
    return this.events.slice(-limit).reverse();
  }

  getEventsByType(type) {
    return this.events.filter(e => e.type === type);
  }

  getEventsBySeverity(severity) {
    return this.events.filter(e => e.severity === severity);
  }

  getSecurityStats() {
    const now = Date.now();
    const oneHourAgo = now - (60 * 60 * 1000);

    const recentEvents = this.events.filter(e =>
      new Date(e.timestamp).getTime() > oneHourAgo
    );

    const stats = {
      total: this.events.length,
      lastHour: recentEvents.length,
      byType: {},
      bySeverity: {},
      criticalAlerts: 0
    };

    recentEvents.forEach(event => {
      stats.byType[event.type] = (stats.byType[event.type] || 0) + 1;
      stats.bySeverity[event.severity] = (stats.bySeverity[event.severity] || 0) + 1;
      if (event.severity === 'CRITICAL') {
        stats.criticalAlerts++;
      }
    });

    return stats;
  }
}

export const securityLogger = new SecurityLogger();

// ============================================================================
// CORS CONFIGURATION - Control cross-origin requests
// ============================================================================

export const corsOptions = {
  origin: function (origin, callback) {
    // Allow requests with no origin (mobile apps, Postman, etc.)
    if (!origin) return callback(null, true);

    // In production, whitelist specific domains
    const whitelist = [
      'http://45.61.58.125:7600',
      'https://45.61.58.125:7600',
      'http://localhost:7600',
      'http://localhost:3000'
    ];

    if (whitelist.indexOf(origin) !== -1 || process.env.NODE_ENV === 'development') {
      callback(null, true);
    } else {
      securityLogger.logSuspiciousActivity(
        { ip: origin },
        `CORS blocked origin: ${origin}`
      );
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  optionsSuccessStatus: 200,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  exposedHeaders: ['RateLimit-Limit', 'RateLimit-Remaining', 'RateLimit-Reset']
};

// ============================================================================
// ADDITIONAL SECURITY MIDDLEWARE
// ============================================================================

/**
 * HTTP Parameter Pollution (HPP) Protection
 */
export const hppProtection = (req, res, next) => {
  // Remove duplicate parameters
  ['query', 'body', 'params'].forEach(source => {
    if (req[source]) {
      Object.keys(req[source]).forEach(key => {
        if (Array.isArray(req[source][key])) {
          // Keep only the last value
          req[source][key] = req[source][key][req[source][key].length - 1];
        }
      });
    }
  });
  next();
};

/**
 * Request ID middleware - Track requests for debugging
 */
export const requestIdMiddleware = (req, res, next) => {
  req.id = crypto.randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
};

/**
 * Security headers middleware - Additional custom headers
 */
export const additionalSecurityHeaders = (req, res, next) => {
  // Prevent caching of sensitive data
  res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
  res.setHeader('Pragma', 'no-cache');
  res.setHeader('Expires', '0');
  res.setHeader('Surrogate-Control', 'no-store');

  // Additional security headers
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');

  next();
};

/**
 * WebSocket authentication
 */
export const authenticateWebSocket = (ws, req) => {
  const token = new URL(req.url, 'ws://localhost').searchParams.get('token');

  if (!token) {
    ws.close(4001, 'Authentication required');
    return false;
  }

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    ws.userId = decoded.userId;
    return true;
  } catch (error) {
    ws.close(4001, 'Invalid token');
    return false;
  }
};

export default {
  apiLimiter,
  authLimiter,
  readLimiter,
  writeLimiter,
  helmetConfig,
  validateWatchId,
  validateSearchQuery,
  validatePriceFilter,
  validateWatchlistOperation,
  validateCompareRequest,
  checkValidationResult,
  sanitizeInput,
  xssProtection,
  verifyApiKey,
  authenticateJWT,
  generateJWT,
  generateApiKey,
  securityLogger,
  corsOptions,
  hppProtection,
  requestIdMiddleware,
  additionalSecurityHeaders,
  authenticateWebSocket
};