feat: implement critical security fixes for SQL injection and authentication vulnerabilities
Test Gitea Actions / test (push) Successful in 20s
continuous-integration/drone/push Build is passing

Security Enhancements:
- Fix SQL injection vulnerabilities by replacing whereRaw queries with parameterized queries
- Add LIKE pattern escaping to prevent SQL injection in search functionality
- Implement account lockout protection (5 failed attempts = 30 min lockout)
- Add comprehensive login attempt tracking and audit trail
- Enhance JWT tokens with issuer validation, IP tracking, and password change detection
- Add logout endpoint and session management
- Prevent user enumeration with generic error messages

Database Changes:
- Add login_attempts table for authentication tracking
- Add security columns to admin_users (password_changed_at, last_login_ip, two_factor_enabled)

New Security Features:
- Brute force protection with configurable lockout duration
- Automatic cleanup of old login attempts
- Enhanced authentication middleware with stricter validation
- Monitoring scripts for security health checks

All fixes are backward compatible and production-ready with rollback plans included.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-07-13 00:40:05 +02:00
parent 0d33f21ee6
commit e35ac6a41c
36 changed files with 4152 additions and 20 deletions
+189
View File
@@ -0,0 +1,189 @@
/**
* Authentication Security Utilities
* Provides enhanced security features for authentication
*/
const { db } = require('../database/db');
const logger = require('./logger');
// Configuration constants
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
/**
* Track failed login attempt
* @param {string} identifier - Username or email
* @param {string} ipAddress - IP address of the attempt
* @param {string} userAgent - User agent string
*/
async function trackFailedAttempt(identifier, ipAddress, userAgent) {
try {
await db('login_attempts').insert({
identifier,
ip_address: ipAddress,
user_agent: userAgent,
attempt_time: new Date().toISOString(),
success: false
});
// Log security event
logger.warn('Failed login attempt', {
identifier,
ipAddress,
userAgent,
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Error tracking failed login attempt:', error);
}
}
/**
* Track successful login
* @param {string} identifier - Username or email
* @param {string} ipAddress - IP address
* @param {string} userAgent - User agent string
*/
async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
try {
await db('login_attempts').insert({
identifier,
ip_address: ipAddress,
user_agent: userAgent,
attempt_time: new Date().toISOString(),
success: true
});
// Clear old failed attempts for this user
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('attempt_time', '<', cutoffTime.toISOString())
.delete();
} catch (error) {
logger.error('Error tracking successful login:', error);
}
}
/**
* Check if account is locked due to too many failed attempts
* @param {string} identifier - Username or email
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
*/
async function checkAccountLockout(identifier) {
try {
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
// Check if still within lockout period
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
const now = Date.now();
if (now < lockoutEnd) {
return {
isLocked: true,
remainingTime: Math.ceil((lockoutEnd - now) / 1000) // seconds
};
}
}
return { isLocked: false };
} catch (error) {
logger.error('Error checking account lockout:', error);
return { isLocked: false }; // Fail open to avoid locking users out due to errors
}
}
/**
* Check for suspicious login patterns
* @param {string} identifier - Username or email
* @param {string} ipAddress - Current IP address
* @returns {Promise<boolean>} - True if suspicious
*/
async function checkSuspiciousActivity(identifier, ipAddress) {
try {
// Check for rapid attempts from different IPs
const recentWindow = new Date(Date.now() - 5 * 60 * 1000); // 5 minutes
const recentAttempts = await db('login_attempts')
.where('identifier', identifier)
.where('attempt_time', '>=', recentWindow.toISOString())
.select('ip_address')
.distinct('ip_address');
// If more than 3 different IPs in 5 minutes, it's suspicious
if (recentAttempts.length > 3) {
logger.warn('Suspicious login activity detected', {
identifier,
uniqueIPs: recentAttempts.length,
currentIP: ipAddress
});
return true;
}
return false;
} catch (error) {
logger.error('Error checking suspicious activity:', error);
return false;
}
}
/**
* Get generic error message to prevent user enumeration
* @returns {string}
*/
function getGenericAuthError() {
return 'Invalid credentials';
}
/**
* Clean up old login attempts (should be run periodically)
*/
async function cleanupOldAttempts() {
try {
const cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // 7 days
const deleted = await db('login_attempts')
.where('attempt_time', '<', cutoffDate.toISOString())
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} old login attempts`);
}
} catch (error) {
logger.error('Error cleaning up login attempts:', error);
}
}
/**
* Initialize cleanup job
*/
function initializeCleanupJob() {
// Run cleanup every 24 hours
setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000);
// Run initial cleanup
cleanupOldAttempts();
}
module.exports = {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
checkSuspiciousActivity,
getGenericAuthError,
initializeCleanupJob,
MAX_LOGIN_ATTEMPTS,
LOCKOUT_DURATION
};
+117
View File
@@ -0,0 +1,117 @@
/**
* SQL Security Utilities
* Provides safe methods for handling user input in SQL queries
*/
/**
* Validate and sanitize days parameter for date range queries
* @param {any} days - The days parameter from user input
* @returns {number} Safe integer between 1 and 365
*/
function sanitizeDays(days) {
const parsed = parseInt(days);
// Check if it's a valid number
if (isNaN(parsed)) {
return 7; // Default to 7 days
}
// Ensure it's within reasonable bounds
if (parsed < 1) {
return 1;
}
if (parsed > 365) {
return 365; // Maximum 1 year
}
return parsed;
}
/**
* Escape special characters in LIKE queries
* @param {string} input - The search string from user input
* @returns {string} Escaped string safe for LIKE queries
*/
function escapeLikePattern(input) {
if (!input || typeof input !== 'string') {
return '';
}
// Escape special LIKE pattern characters
// In SQL LIKE patterns:
// % matches any sequence of characters
// _ matches any single character
// \ is the escape character
return input
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/%/g, '\\%') // Escape percent signs
.replace(/_/g, '\\_') // Escape underscores
.replace(/'/g, "''"); // Escape single quotes for safety
}
/**
* Create a safe date range condition using Knex
* @param {object} query - Knex query builder instance
* @param {string} column - The timestamp column name
* @param {number} days - Number of days to go back
* @returns {object} Modified query with safe date range condition
*/
function addDateRangeCondition(query, column, days) {
const safeDays = sanitizeDays(days);
const startDate = new Date();
startDate.setDate(startDate.getDate() - safeDays);
// Use Knex's built-in date comparison which handles parameterization
return query.where(column, '>=', startDate.toISOString());
}
/**
* Create a safe LIKE condition using Knex
* @param {object} query - Knex query builder instance
* @param {string} column - The column to search
* @param {string} pattern - The search pattern
* @returns {object} Modified query with safe LIKE condition
*/
function addLikeCondition(query, column, pattern) {
if (!pattern || typeof pattern !== 'string') {
return query;
}
const escapedPattern = escapeLikePattern(pattern);
// Knex handles parameterization of the LIKE value
return query.where(column, 'like', `%${escapedPattern}%`);
}
/**
* Validate sort column against whitelist
* @param {string} column - The column name to sort by
* @param {string[]} allowedColumns - Array of allowed column names
* @param {string} defaultColumn - Default column if invalid
* @returns {string} Safe column name
*/
function validateSortColumn(column, allowedColumns, defaultColumn) {
if (!column || !allowedColumns.includes(column)) {
return defaultColumn;
}
return column;
}
/**
* Validate sort order
* @param {string} order - The sort order (asc/desc)
* @returns {string} Safe sort order
*/
function validateSortOrder(order) {
const lowerOrder = (order || '').toLowerCase();
return lowerOrder === 'asc' ? 'asc' : 'desc';
}
module.exports = {
sanitizeDays,
escapeLikePattern,
addDateRangeCondition,
addLikeCondition,
validateSortColumn,
validateSortOrder
};