← back to Handbag Auth Nextjs

auction-viewer/server-secure.js

674 lines

const express = require('express');
const Database = require('better-sqlite3');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss');
const compression = require('compression');
const { body, query, param, validationResult } = require('express-validator');
const winston = require('winston');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

// Environment configuration
require('dotenv').config();

const app = express();
const PORT = process.env.PORT || 7500;
const JWT_SECRET = process.env.JWT_SECRET || crypto.randomBytes(64).toString('hex');
const API_KEY = process.env.API_KEY || crypto.randomBytes(32).toString('hex');

// Security: Create secure logger
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  defaultMeta: { service: 'auction-viewer' },
  transports: [
    new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
    new winston.transports.File({ filename: 'logs/combined.log' }),
    new winston.transports.File({ filename: 'logs/security.log', level: 'warn' })
  ]
});

if (process.env.NODE_ENV !== 'production') {
  logger.add(new winston.transports.Console({
    format: winston.format.simple()
  }));
}

// Security: Request ID tracking
app.use((req, res, next) => {
  req.id = crypto.randomUUID();
  res.setHeader('X-Request-ID', req.id);
  next();
});

// Security: Helmet for security headers
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
      scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net"],
      imgSrc: ["'self'", "data:", "https:"],
      fontSrc: ["'self'", "https://fonts.gstatic.com"],
      connectSrc: ["'self'"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      mediaSrc: ["'none'"],
      manifestSrc: ["'self'"],
      workerSrc: ["'self'"],
      formAction: ["'self'"],
      frameAncestors: ["'none'"],
      baseUri: ["'self'"],
      upgradeInsecureRequests: []
    }
  },
  crossOriginEmbedderPolicy: false,
  hsts: {
    maxAge: 31536000,
    includeSubDomains: true,
    preload: true
  }
}));

// Security: CORS configuration
const corsOptions = {
  origin: function (origin, callback) {
    const allowedOrigins = process.env.ALLOWED_ORIGINS
      ? process.env.ALLOWED_ORIGINS.split(',')
      : ['http://45.61.58.125:7500', 'http://localhost:3000'];

    // Allow requests with no origin (like mobile apps or Postman)
    if (!origin || allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      logger.warn(`CORS blocked: ${origin}`, { ip: origin });
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
  optionsSuccessStatus: 200,
  maxAge: 86400 // 24 hours
};
app.use(cors(corsOptions));

// Security: Compression
app.use(compression());

// Security: Body parser with size limits
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));

// Security: MongoDB injection prevention
app.use(mongoSanitize({
  replaceWith: '_',
  onSanitize: ({ req, key }) => {
    logger.warn(`Sanitized ${key} in request ${req.id}`);
  }
}));

// Security: Rate limiting configurations
const generalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: 'Too many requests from this IP, please try again later.',
  standardHeaders: true,
  legacyHeaders: false,
  handler: (req, res) => {
    logger.warn(`Rate limit exceeded for ${req.ip}`, {
      ip: req.ip,
      path: req.path,
      requestId: req.id
    });
    res.status(429).json({
      success: false,
      error: 'Too many requests, please try again later.'
    });
  }
});

const strictLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 20,
  skipSuccessfulRequests: false
});

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipFailedRequests: false
});

// Apply general rate limiting to all routes
app.use('/api/', generalLimiter);

// Security: API Key validation middleware (optional)
const validateApiKey = (req, res, next) => {
  const providedKey = req.headers['x-api-key'] || req.query.apiKey;

  // If API key authentication is disabled, proceed
  if (process.env.REQUIRE_API_KEY !== 'true') {
    return next();
  }

  if (!providedKey) {
    logger.warn('Missing API key', { ip: req.ip, path: req.path });
    return res.status(401).json({ success: false, error: 'API key required' });
  }

  if (providedKey !== API_KEY) {
    logger.warn('Invalid API key attempt', { ip: req.ip, path: req.path });
    return res.status(401).json({ success: false, error: 'Invalid API key' });
  }

  next();
};

// Security: Input sanitization helper
const sanitizeInput = (input) => {
  if (typeof input !== 'string') return input;
  // Remove any potential SQL injection patterns
  let sanitized = input.replace(/['";\\]/g, '');
  // Apply XSS protection
  sanitized = xss(sanitized);
  // Trim whitespace
  sanitized = sanitized.trim();
  // Limit length
  if (sanitized.length > 500) {
    sanitized = sanitized.substring(0, 500);
  }
  return sanitized;
};

// Security: Database helper with prepared statements
class SecureDatabase {
  constructor(dbPath) {
    this.dbPath = dbPath;
  }

  execute(query, params = [], readonly = true) {
    let db = null;
    try {
      db = new Database(this.dbPath, {
        readonly,
        fileMustExist: true,
        timeout: 5000
      });

      // Enable foreign key constraints
      db.pragma('foreign_keys = ON');

      const stmt = db.prepare(query);
      const result = query.trim().toUpperCase().startsWith('SELECT')
        ? stmt.all(...params)
        : stmt.run(...params);

      return result;
    } catch (error) {
      logger.error('Database error', { error: error.message, query });
      throw new Error('Database operation failed');
    } finally {
      if (db) {
        try {
          db.close();
        } catch (closeError) {
          logger.error('Failed to close database', { error: closeError.message });
        }
      }
    }
  }
}

// Database paths
const DB_PATH = path.join(__dirname, '..', 'data', 'auction-history', 'auctions.db');
const CSV_PATH = path.join(__dirname, '..', 'data', 'auction-history', 'auction-history.csv');
const LOG_PATH = path.join(__dirname, '..', 'data', 'auction-history', 'scraper.log');

// Initialize secure database
const secureDb = new SecureDatabase(DB_PATH);

// Create logs directory if it doesn't exist
const logsDir = path.join(__dirname, 'logs');
if (!fs.existsSync(logsDir)) {
  fs.mkdirSync(logsDir, { recursive: true });
}

// Serve static files with security restrictions
app.use(express.static(__dirname, {
  dotfiles: 'deny',
  index: false,
  redirect: false,
  setHeaders: (res, path) => {
    if (path.endsWith('.html')) {
      res.setHeader('X-Content-Type-Options', 'nosniff');
      res.setHeader('X-Frame-Options', 'DENY');
    }
  }
}));

// Security: Validation middleware
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    logger.warn('Validation failed', {
      errors: errors.array(),
      ip: req.ip,
      requestId: req.id
    });
    return res.status(400).json({
      success: false,
      errors: errors.array().map(err => ({
        field: err.param,
        message: err.msg
      }))
    });
  }
  next();
};

// API: Health check endpoint
app.get('/api/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime()
  });
});

// API: Get all auctions with validation and sanitization
app.get('/api/auctions',
  validateApiKey,
  [
    query('limit').optional().isInt({ min: 1, max: 1000 }).toInt(),
    query('offset').optional().isInt({ min: 0 }).toInt(),
    query('search').optional().isString().trim().escape()
  ],
  handleValidationErrors,
  (req, res) => {
    try {
      const limit = req.query.limit || 1000;
      const offset = req.query.offset || 0;
      const search = req.query.search ? sanitizeInput(req.query.search) : null;

      let query = 'SELECT * FROM auctions';
      let params = [];

      if (search) {
        query += ' WHERE title LIKE ? OR auction_house LIKE ? OR search_term LIKE ?';
        params = [`%${search}%`, `%${search}%`, `%${search}%`];
      }

      query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
      params.push(limit, offset);

      const auctions = secureDb.execute(query, params);

      // Sanitize output
      const sanitizedAuctions = auctions.map(auction => ({
        ...auction,
        title: sanitizeInput(auction.title || ''),
        auction_house: sanitizeInput(auction.auction_house || ''),
        url: sanitizeInput(auction.url || ''),
        search_term: sanitizeInput(auction.search_term || '')
      }));

      logger.info('Auctions retrieved', { count: auctions.length, ip: req.ip });

      res.json({
        success: true,
        count: sanitizedAuctions.length,
        data: sanitizedAuctions
      });
    } catch (error) {
      logger.error('Error fetching auctions', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ success: false, error: 'Failed to retrieve auctions' });
    }
  }
);

// API: Get best value auctions with enhanced security
app.get('/api/best-values',
  validateApiKey,
  [
    query('sort').optional().isIn(['percent', 'dollar']).trim(),
    query('limit').optional().isInt({ min: 1, max: 100 }).toInt()
  ],
  handleValidationErrors,
  (req, res) => {
    try {
      const sortBy = req.query.sort || 'percent';
      const limit = req.query.limit || 100;

      // Use parameterized query - column names cannot be parameterized
      // but we've already validated sortBy to be one of two values
      const orderBy = sortBy === 'dollar' ? 'savings_amount' : 'savings_percent';

      // Validate orderBy against whitelist
      if (!['savings_amount', 'savings_percent'].includes(orderBy)) {
        throw new Error('Invalid sort parameter');
      }

      const query = `
        SELECT *,
          CASE
            WHEN estimate_low > 0 AND current_price > 0
            THEN ((estimate_low - current_price) / estimate_low * 100)
            ELSE 0
          END as savings_percent,
          CASE
            WHEN estimate_low > 0 AND current_price > 0
            THEN (estimate_low - current_price)
            ELSE 0
          END as savings_amount
        FROM auctions
        WHERE current_price > 0 AND estimate_low > 0
        ORDER BY ${orderBy} DESC
        LIMIT ?
      `;

      const auctions = secureDb.execute(query, [limit]);

      // Sanitize output
      const sanitizedAuctions = auctions.map(auction => ({
        ...auction,
        title: sanitizeInput(auction.title || ''),
        auction_house: sanitizeInput(auction.auction_house || ''),
        url: sanitizeInput(auction.url || ''),
        search_term: sanitizeInput(auction.search_term || '')
      }));

      logger.info('Best values retrieved', { count: auctions.length, sortBy, ip: req.ip });

      res.json({
        success: true,
        count: sanitizedAuctions.length,
        data: sanitizedAuctions,
        sortedBy: sortBy
      });
    } catch (error) {
      logger.error('Error fetching best values', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ success: false, error: 'Failed to retrieve best values' });
    }
  }
);

// API: Get statistics with caching
const statsCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes

app.get('/api/stats',
  validateApiKey,
  strictLimiter,
  (req, res) => {
    try {
      // Check cache
      const cacheKey = 'stats';
      const cached = statsCache.get(cacheKey);

      if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) {
        return res.json({ success: true, stats: cached.data, cached: true });
      }

      const total = secureDb.execute(
        'SELECT COUNT(*) as count FROM auctions',
        []
      )[0];

      const byBrand = secureDb.execute(`
        SELECT search_term, COUNT(*) as count
        FROM auctions
        GROUP BY search_term
        ORDER BY count DESC
        LIMIT 20
      `, []);

      const byAuctionHouse = secureDb.execute(`
        SELECT auction_house, COUNT(*) as count
        FROM auctions
        GROUP BY auction_house
        ORDER BY count DESC
        LIMIT 10
      `, []);

      const avgPrice = secureDb.execute(`
        SELECT AVG(current_price) as avg_price
        FROM auctions
        WHERE current_price > 0 AND current_price < 1000000
      `, [])[0];

      const priceRange = secureDb.execute(`
        SELECT
          MIN(current_price) as min_price,
          MAX(current_price) as max_price
        FROM auctions
        WHERE current_price > 0 AND current_price < 1000000
      `, [])[0];

      const stats = {
        total: total.count,
        byBrand: byBrand.map(b => ({
          ...b,
          search_term: sanitizeInput(b.search_term || '')
        })),
        byAuctionHouse: byAuctionHouse.map(h => ({
          ...h,
          auction_house: sanitizeInput(h.auction_house || '')
        })),
        avgPrice: avgPrice.avg_price || 0,
        priceRange: priceRange || { min_price: 0, max_price: 0 }
      };

      // Update cache
      statsCache.set(cacheKey, {
        data: stats,
        timestamp: Date.now()
      });

      logger.info('Stats retrieved', { ip: req.ip });

      res.json({
        success: true,
        stats,
        cached: false
      });
    } catch (error) {
      logger.error('Error fetching stats', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ success: false, error: 'Failed to retrieve statistics' });
    }
  }
);

// API: Get recent logs with access control
app.get('/api/logs',
  validateApiKey,
  authLimiter,
  [
    query('lines').optional().isInt({ min: 1, max: 500 }).toInt()
  ],
  handleValidationErrors,
  (req, res) => {
    try {
      // Additional security check for logs endpoint
      const authHeader = req.headers.authorization;
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        logger.warn('Unauthorized log access attempt', { ip: req.ip });
        return res.status(401).json({ success: false, error: 'Authorization required' });
      }

      const lines = req.query.lines || 100;

      if (!fs.existsSync(LOG_PATH)) {
        return res.json({ success: true, logs: [] });
      }

      const logContent = fs.readFileSync(LOG_PATH, 'utf8');
      const logs = logContent
        .split('\n')
        .filter(line => line.trim())
        .slice(-lines)
        .reverse()
        .map(line => sanitizeInput(line));

      logger.info('Logs accessed', { lines, ip: req.ip });

      res.json({ success: true, logs });
    } catch (error) {
      logger.error('Error fetching logs', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ success: false, error: 'Failed to retrieve logs' });
    }
  }
);

// API: Download CSV with authentication
app.get('/api/download-csv',
  validateApiKey,
  strictLimiter,
  (req, res) => {
    try {
      if (!fs.existsSync(CSV_PATH)) {
        logger.warn('CSV file not found', { ip: req.ip });
        return res.status(404).json({ success: false, error: 'CSV file not found' });
      }

      // Check file size to prevent large file attacks
      const stats = fs.statSync(CSV_PATH);
      if (stats.size > 50 * 1024 * 1024) { // 50MB limit
        logger.warn('CSV file too large', { size: stats.size, ip: req.ip });
        return res.status(413).json({ success: false, error: 'File too large' });
      }

      logger.info('CSV downloaded', { ip: req.ip });

      res.download(CSV_PATH, 'auction-history.csv', (err) => {
        if (err) {
          logger.error('Error downloading CSV', { error: err.message, ip: req.ip });
        }
      });
    } catch (error) {
      logger.error('Error downloading CSV', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ error: 'Failed to download file' });
    }
  }
);

// API: System status with authentication
app.get('/api/status',
  validateApiKey,
  (req, res) => {
    try {
      const status = {
        database: fs.existsSync(DB_PATH),
        csv: fs.existsSync(CSV_PATH),
        logs: fs.existsSync(LOG_PATH),
        dbSize: fs.existsSync(DB_PATH) ? fs.statSync(DB_PATH).size : 0,
        csvSize: fs.existsSync(CSV_PATH) ? fs.statSync(CSV_PATH).size : 0,
        logSize: fs.existsSync(LOG_PATH) ? fs.statSync(LOG_PATH).size : 0,
        uptime: process.uptime(),
        memory: process.memoryUsage(),
        nodeVersion: process.version
      };

      logger.info('Status checked', { ip: req.ip });

      res.json({ success: true, status });
    } catch (error) {
      logger.error('Error fetching status', {
        error: error.message,
        ip: req.ip,
        requestId: req.id
      });
      res.status(500).json({ success: false, error: 'Failed to retrieve status' });
    }
  }
);

// Security: 404 handler
app.use((req, res) => {
  logger.warn('404 Not Found', { path: req.path, ip: req.ip });
  res.status(404).json({ success: false, error: 'Endpoint not found' });
});

// Security: Error handler
app.use((err, req, res, next) => {
  logger.error('Unhandled error', {
    error: err.message,
    stack: err.stack,
    ip: req.ip,
    requestId: req.id
  });

  // Don't leak error details in production
  const message = process.env.NODE_ENV === 'production'
    ? 'Internal server error'
    : err.message;

  res.status(err.status || 500).json({
    success: false,
    error: message
  });
});

// Start server with security logging
const server = app.listen(PORT, '0.0.0.0', () => {
  console.log('🔒 SECURE Auction Data Viewer running');
  console.log(`📊 Dashboard: http://45.61.58.125:${PORT}`);
  console.log(`🔌 API: http://45.61.58.125:${PORT}/api/auctions`);
  console.log('');
  console.log('Security Features Enabled:');
  console.log('✅ Helmet.js security headers');
  console.log('✅ CORS restrictions');
  console.log('✅ Rate limiting');
  console.log('✅ Input validation & sanitization');
  console.log('✅ SQL injection protection');
  console.log('✅ XSS protection');
  console.log('✅ Request logging');
  console.log('✅ API key authentication (optional)');
  console.log('');

  if (process.env.REQUIRE_API_KEY === 'true') {
    console.log(`🔑 API Key: ${API_KEY}`);
  } else {
    console.log('⚠️  API Key authentication disabled');
  }

  logger.info('Secure server started', {
    port: PORT,
    apiKeyRequired: process.env.REQUIRE_API_KEY === 'true'
  });
});

// Graceful shutdown
process.on('SIGTERM', () => {
  logger.info('SIGTERM signal received: closing HTTP server');
  server.close(() => {
    logger.info('HTTP server closed');
    process.exit(0);
  });
});

module.exports = app;