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
Some checks failed
Build Images and Deploy / Update-PROD-Stack (push) Failing after 8s
This commit is contained in:
11
.env.example
11
.env.example
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
79
src/app.js
79
src/app.js
@@ -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 => {
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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
42
src/utils/logger.js
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user