feat: enhance security logging and ensure rate limit blocks are properly tracked
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s

- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-18 19:25:15 +02:00
parent cc7ad4b2bc
commit f6e5a454ae
14 changed files with 617 additions and 33 deletions
+60 -1
View File
@@ -1,24 +1,83 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth attempt without token', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (jwtError) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('JWT validation failed', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
error: jwtError.name,
message: jwtError.message,
timestamp: new Date().toISOString()
});
if (jwtError.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth failed - user not found or inactive', {
ip: clientIp,
userId: decoded.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString()
});
return res.status(401).json({ error: 'Invalid token' });
}
req.admin = admin;
next();
} catch (error) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.error('Admin auth middleware error', {
ip: clientIp,
path: req.path,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
res.status(401).json({ error: 'Invalid token' });
}
}
+7 -1
View File
@@ -109,8 +109,9 @@ async function getRecipientLanguage(email, eventId = null) {
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
@@ -142,6 +143,11 @@ async function processTemplate(template, variables, language = 'en') {
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
let logoUrl = '';
+44 -4
View File
@@ -177,13 +177,33 @@ async function createRateLimiter() {
return shouldSkipRateLimit(req, currentConfig);
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
@@ -217,10 +237,30 @@ async function createAuthRateLimiter() {
return !currentConfig.enabled;
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'
},
requestUrl: req.originalUrl,
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
+40
View File
@@ -0,0 +1,40 @@
/**
* Formatters for email content and other text transformations
*/
/**
* Convert plain text line breaks to HTML line breaks
* @param {string} text - The text to format
* @returns {string} - Text with HTML line breaks
*/
function nl2br(text) {
if (!text) return '';
// Normalize line endings
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Convert newlines to <br> tags
return text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
}
/**
* Format welcome message for email templates
* @param {string} message - The welcome message
* @returns {string} - Formatted message for HTML emails
*/
function formatWelcomeMessage(message) {
if (!message || message.trim() === '') {
return '';
}
return nl2br(message);
}
module.exports = {
nl2br,
formatWelcomeMessage
};
+77 -10
View File
@@ -1,31 +1,98 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
// Ensure logs directory exists
const logDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Custom format for production logs
const productionFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.json(),
winston.format.printf(info => {
// Ensure all security events are properly formatted
if (info.level === 'warn' && (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT'))) {
return JSON.stringify({
timestamp: info.timestamp,
level: info.level,
message: info.message,
security: true,
...info
});
}
return JSON.stringify(info);
})
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
format: productionFormat,
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error'
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
tailable: true
}),
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log')
filename: path.join(logDir, 'combined.log'),
maxsize: 50 * 1024 * 1024, // 50MB
maxFiles: 10,
tailable: true
}),
// Separate security log for authentication and rate limiting
new winston.transports.File({
filename: path.join(logDir, 'security.log'),
level: 'warn',
maxsize: 20 * 1024 * 1024, // 20MB
maxFiles: 10,
tailable: true,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.json(),
winston.format.printf(info => {
// Only log security-related warnings
if (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT') ||
info.message.includes('lockout') ||
info.message.includes('suspicious')) {
return JSON.stringify(info);
}
return null;
})
)
})
]
].filter(Boolean)
});
// Add console logging for non-production environments
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(info => {
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
})
)
}));
} else {
// In production, also log to console for container environments
if (process.env.LOG_TO_CONSOLE === 'true') {
logger.add(new winston.transports.Console({
format: productionFormat
}));
}
}
module.exports = logger;