Initial commit: LuHost - Luanti Server Management Web Interface
A modern web interface for Luanti (Minetest) server management with ContentDB integration. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
185
middleware/security.js
Normal file
185
middleware/security.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// Security middleware for input validation and CSRF protection
|
||||
|
||||
/**
|
||||
* Input validation middleware
|
||||
* Validates common input patterns and sanitizes data
|
||||
*/
|
||||
function validateInput(req, res, next) {
|
||||
// Sanitize query parameters
|
||||
for (const key in req.query) {
|
||||
if (typeof req.query[key] === 'string') {
|
||||
// Remove control characters
|
||||
req.query[key] = req.query[key].replace(/[\x00-\x1F\x7F]/g, '');
|
||||
|
||||
// Limit length
|
||||
if (req.query[key].length > 1000) {
|
||||
req.query[key] = req.query[key].substring(0, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize body data for non-JSON requests
|
||||
if (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) {
|
||||
for (const key in req.body) {
|
||||
if (typeof req.body[key] === 'string') {
|
||||
// Remove control characters but preserve newlines for textareas
|
||||
if (key.includes('description') || key.includes('content') || key.includes('motd')) {
|
||||
req.body[key] = req.body[key].replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
|
||||
} else {
|
||||
req.body[key] = req.body[key].replace(/[\x00-\x1F\x7F]/g, '');
|
||||
}
|
||||
|
||||
// Limit length based on field type
|
||||
const maxLength = getMaxLengthForField(key);
|
||||
if (req.body[key].length > maxLength) {
|
||||
req.body[key] = req.body[key].substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum allowed length for different field types
|
||||
*/
|
||||
function getMaxLengthForField(fieldName) {
|
||||
const fieldLimits = {
|
||||
// User authentication fields
|
||||
'username': 50,
|
||||
'password': 200,
|
||||
'confirmPassword': 200,
|
||||
'currentPassword': 200,
|
||||
'newPassword': 200,
|
||||
|
||||
// Server/world names
|
||||
'name': 100,
|
||||
'worldName': 100,
|
||||
'serverName': 200,
|
||||
|
||||
// Text content
|
||||
'description': 2000,
|
||||
'motd': 500,
|
||||
'content': 5000,
|
||||
|
||||
// Commands and paths
|
||||
'command': 500,
|
||||
'path': 500,
|
||||
|
||||
// Network settings
|
||||
'bind': 100,
|
||||
'serverlist_url': 500,
|
||||
|
||||
// Default
|
||||
'default': 200
|
||||
};
|
||||
|
||||
return fieldLimits[fieldName] || fieldLimits['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* XSS protection middleware
|
||||
* Escapes HTML in user input for specific fields
|
||||
*/
|
||||
function xssProtection(req, res, next) {
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
// Fields that should be HTML escaped
|
||||
const fieldsToEscape = ['username', 'name', 'worldName', 'serverName', 'motd'];
|
||||
|
||||
for (const field of fieldsToEscape) {
|
||||
if (req.body[field] && typeof req.body[field] === 'string') {
|
||||
req.body[field] = escapeHtml(req.body[field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic HTML escape function
|
||||
*/
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Security headers middleware (additional to helmet)
|
||||
*/
|
||||
function additionalSecurityHeaders(req, res, next) {
|
||||
// Prevent MIME type sniffing
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
// Prevent clickjacking
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
|
||||
// XSS protection
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
|
||||
// Referrer policy
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request size validation
|
||||
*/
|
||||
function validateRequestSize(req, res, next) {
|
||||
// Check for unusually large requests that might indicate an attack
|
||||
const contentLength = req.headers['content-length'];
|
||||
if (contentLength && parseInt(contentLength) > 50 * 1024 * 1024) { // 50MB limit
|
||||
return res.status(413).json({ error: 'Request too large' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Path traversal protection
|
||||
*/
|
||||
function pathTraversalProtection(req, res, next) {
|
||||
// Check for path traversal attempts in various parameters
|
||||
const suspiciousPatterns = ['../', '..\\', '%2e%2e%2f', '%2e%2e%5c'];
|
||||
|
||||
function checkForTraversal(obj, path = '') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (typeof value === 'string') {
|
||||
const lowerValue = value.toLowerCase();
|
||||
for (const pattern of suspiciousPatterns) {
|
||||
if (lowerValue.includes(pattern)) {
|
||||
console.warn(`Path traversal attempt detected: ${path}${key} = ${value}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
if (checkForTraversal(value, `${path}${key}.`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((req.query && checkForTraversal(req.query)) ||
|
||||
(req.body && checkForTraversal(req.body))) {
|
||||
return res.status(400).json({ error: 'Invalid request parameters' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateInput,
|
||||
xssProtection,
|
||||
additionalSecurityHeaders,
|
||||
validateRequestSize,
|
||||
pathTraversalProtection,
|
||||
escapeHtml
|
||||
};
|
Reference in New Issue
Block a user