← 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;