enhance security and performance: add rate limiting, session validation, and logging; update environment variables
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 8s

This commit is contained in:
2026-03-24 02:47:53 -04:00
parent c7bbe9a3c1
commit 1665019e8e
7 changed files with 152 additions and 13 deletions

View File

@@ -2,12 +2,21 @@
PORT=3000
NODE_ENV=production
BASE_URL=https://loot-hunt.com
TRUST_PROXY=false
# Session
SESSION_SECRET=change-me-to-a-random-string
SESSION_SECRET=change-me-to-a-random-string-at-least-32-characters-long
# Database (SQLite file path)
DB_PATH=./data/loot-hunt.db
# Uploads directory
UPLOADS_DIR=./data/uploads
# Logging
DATA_PATH=./data
LOG_LEVEL=info
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100

View File

@@ -11,7 +11,9 @@
"bcryptjs": "^2.4.3",
"ejs": "^3.1.10",
"express": "^4.21.1",
"express-rate-limit": "^7.4.0",
"express-session": "^1.18.1",
"express-validator": "^7.0.1",
"multer": "^1.4.5-lts.1",
"pdfkit": "^0.16.0",
"qrcode": "^1.5.4",

View File

@@ -1,9 +1,18 @@
const express = require('express');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const path = require('path');
const fs = require('fs');
// Load .env if present
// Validate required environment variables
if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'loot-hunt-dev-secret-change-me') {
console.error('ERROR: SESSION_SECRET must be set to a secure random string');
process.exit(1);
}
if (!process.env.BASE_URL) {
console.warn('WARNING: BASE_URL not set, using localhost default');
}
try {
const envFile = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
envFile.split('\n').forEach(line => {
@@ -79,6 +88,23 @@ async function start() {
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Rate limiting
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: 'Too many authentication attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
const scanLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // limit each IP to 30 scans per minute
message: 'Too many scan attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
// Body parsing
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
@@ -105,6 +131,28 @@ async function start() {
app.set('trust proxy', 1);
}
// Security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
const csp = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'"
].join('; ');
res.setHeader('Content-Security-Policy', csp);
next();
});
// Load current user into all views
app.use(loadUser);
@@ -116,11 +164,20 @@ async function start() {
});
// Routes
app.use('/auth', require('./routes/auth'));
app.use('/auth', authLimiter, require('./routes/auth'));
app.use('/admin', require('./routes/admin'));
app.use('/loot', require('./routes/loot'));
app.use('/loot', scanLimiter, require('./routes/loot'));
app.use('/', require('./routes/hunts'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
});
// QR Scanner
app.get('/scanner', (req, res) => {
res.render('scanner', { title: 'QR Scanner' });
@@ -151,9 +208,23 @@ async function start() {
res.status(500).render('error', { title: 'Error', message: 'Something went wrong.' });
});
app.listen(PORT, '0.0.0.0', () => {
const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`🎯 Loot Hunt running on port ${PORT}`);
});
// Graceful shutdown
const gracefulShutdown = (signal) => {
console.log(`\nReceived ${signal}. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed.');
// Force save database before exit
db.forceSave();
process.exit(0);
});
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
}
start().catch(err => {

View File

@@ -28,7 +28,7 @@ function scheduleSave() {
_saveTimer = setTimeout(() => {
_saveTimer = null;
save();
}, 250);
}, 1000); // Increased debounce time for better performance
}
/** Convert sql.js result set to array of plain objects */
@@ -223,8 +223,11 @@ const ready = new Promise(resolve => { _readyResolve = resolve; });
'CREATE INDEX IF NOT EXISTS idx_packages_code ON packages(unique_code)',
'CREATE INDEX IF NOT EXISTS idx_scans_package ON scans(package_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_user ON scans(user_id)',
'CREATE INDEX IF NOT EXISTS idx_scans_points ON scans(points_awarded)',
'CREATE INDEX IF NOT EXISTS idx_hunts_short_name ON hunts(short_name)',
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)'
'CREATE INDEX IF NOT EXISTS idx_sessions_expired ON sessions(expired)',
'CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)',
'CREATE INDEX IF NOT EXISTS idx_password_reset_expires ON password_reset_tokens(expires_at)'
];
for (const idx of indexes) {
try { _db.run(idx); } catch (e) { /* index may already exist */ }

View File

@@ -1,23 +1,35 @@
const express = require('express');
const router = express.Router();
const { body, validationResult } = require('express-validator');
const { Users } = require('../models');
const logger = require('../utils/logger');
router.get('/login', (req, res) => {
res.render('auth/login', { title: 'Login', error: null });
});
router.post('/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.render('auth/login', { title: 'Login', error: 'Username and password are required.' });
router.post('/login', [
body('username').trim().isLength({ min: 3, max: 24 }).withMessage('Username must be 3-24 characters'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.render('auth/login', {
title: 'Login',
error: errors.array()[0].msg
});
}
const { username, password } = req.body;
const user = Users.findByUsername(username);
if (!user || !Users.verifyPassword(user, password)) {
logger.warn('Failed login attempt', { username, ip: req.ip });
return res.render('auth/login', { title: 'Login', error: 'Invalid username or password.' });
}
logger.info('User logged in', { userId: user.id, username, ip: req.ip });
req.session.userId = user.id;
req.session.username = user.username;
req.session.displayName = user.display_name || user.username;

42
src/utils/logger.js Normal file
View File

@@ -0,0 +1,42 @@
const fs = require('fs');
const path = require('path');
const logsDir = path.join(process.env.DATA_PATH || './data', 'logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const logger = {
info: (message, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'info',
message,
...meta
};
console.log(JSON.stringify(logEntry));
},
error: (message, error = null, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'error',
message,
error: error ? { message: error.message, stack: error.stack } : null,
...meta
};
console.error(JSON.stringify(logEntry));
},
warn: (message, meta = {}) => {
const logEntry = {
timestamp: new Date().toISOString(),
level: 'warn',
message,
...meta
};
console.warn(JSON.stringify(logEntry));
}
};
module.exports = logger;

View File

@@ -6,7 +6,7 @@
<h1 class="teaser-headline" id="teaserHeadline">Nice find!</h1>
<p class="teaser-subtext">This loot is worth</p>
<div class="teaser-points"><%= potentialPoints %> <span>points</span></div>
<p class="teaser-cta">Sign in or create an account to claim it!</p>
<p class="teaser-cta">Sign in or create an account to claim it and play! Think you find more hidden around?</p>
<div class="teaser-buttons">
<a href="/auth/login" class="btn btn-primary btn-lg">Log In</a>
<a href="/auth/register" class="btn btn-success btn-lg">Sign Up</a>