fix: show hero image in thumbnail grid on hero gallery layout
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Password Validation and Security Utilities
|
||||
* Implements strong password requirements and security checks
|
||||
*/
|
||||
|
||||
const zxcvbn = require('zxcvbn');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Configuration
|
||||
const PASSWORD_CONFIG = {
|
||||
minLength: 12,
|
||||
requireUppercase: true,
|
||||
requireLowercase: true,
|
||||
requireNumbers: true,
|
||||
requireSpecialChars: true,
|
||||
preventCommonPasswords: true,
|
||||
minStrengthScore: 3, // zxcvbn score (0-4, where 3 is "good")
|
||||
bcryptRounds: parseInt(process.env.BCRYPT_ROUNDS) || 12 // Configurable, default 12
|
||||
};
|
||||
|
||||
// Common passwords to block (extend this list)
|
||||
const COMMON_PASSWORDS = [
|
||||
'password', 'password123', 'admin123', 'welcome123', 'test123',
|
||||
'qwerty', 'abc123', '123456', 'password1', 'admin',
|
||||
'letmein', 'welcome', 'monkey', 'dragon', 'baseball'
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate password meets security requirements
|
||||
* @param {string} password - Password to validate
|
||||
* @param {Object} options - Optional configuration overrides
|
||||
* @returns {Object} - { valid: boolean, errors: string[], score: number, feedback: Object }
|
||||
*/
|
||||
function validatePassword(password, options = {}) {
|
||||
const config = { ...PASSWORD_CONFIG, ...options };
|
||||
const errors = [];
|
||||
|
||||
// Check if password exists
|
||||
if (!password || typeof password !== 'string') {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['Password is required'],
|
||||
score: 0,
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if (password.length < config.minLength) {
|
||||
errors.push(`Password must be at least ${config.minLength} characters long`);
|
||||
}
|
||||
|
||||
// Check uppercase requirement
|
||||
if (config.requireUppercase && !/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
// Check lowercase requirement
|
||||
if (config.requireLowercase && !/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
// Check number requirement
|
||||
if (config.requireNumbers && !/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
// Check special character requirement
|
||||
if (config.requireSpecialChars && !/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character');
|
||||
}
|
||||
|
||||
// Check against common passwords
|
||||
if (config.preventCommonPasswords) {
|
||||
const lowerPassword = password.toLowerCase();
|
||||
if (COMMON_PASSWORDS.includes(lowerPassword)) {
|
||||
errors.push('This password is too common. Please choose a more unique password');
|
||||
}
|
||||
}
|
||||
|
||||
// Use zxcvbn for strength analysis
|
||||
const strength = zxcvbn(password);
|
||||
|
||||
// Check minimum strength score
|
||||
if (strength.score < config.minStrengthScore) {
|
||||
errors.push('Password is too weak. Please choose a stronger password');
|
||||
}
|
||||
|
||||
// Add zxcvbn suggestions
|
||||
if (strength.feedback.suggestions.length > 0) {
|
||||
errors.push(...strength.feedback.suggestions);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
score: strength.score,
|
||||
feedback: {
|
||||
warning: strength.feedback.warning,
|
||||
suggestions: strength.feedback.suggestions,
|
||||
crackTime: strength.crack_times_display.offline_slow_hashing_1e4_per_second
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password for specific contexts (admin, gallery)
|
||||
* @param {string} password - Password to validate
|
||||
* @param {string} context - Context ('admin' or 'gallery')
|
||||
* @param {Object} userData - Additional user data for context-aware validation
|
||||
* @returns {Object} - Validation result
|
||||
*/
|
||||
function validatePasswordInContext(password, context, userData = {}) {
|
||||
// Base validation
|
||||
const result = validatePassword(password);
|
||||
|
||||
// Context-specific validation
|
||||
if (context === 'admin') {
|
||||
// Admins need stronger passwords
|
||||
if (result.score < 4) {
|
||||
result.valid = false;
|
||||
result.errors.push('Admin passwords must be very strong (score 4/4)');
|
||||
}
|
||||
|
||||
// Check password doesn't contain username
|
||||
if (userData.username && password.toLowerCase().includes(userData.username.toLowerCase())) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password must not contain your username');
|
||||
}
|
||||
|
||||
// Check password doesn't contain email
|
||||
if (userData.email) {
|
||||
const emailUser = userData.email.split('@')[0];
|
||||
if (password.toLowerCase().includes(emailUser.toLowerCase())) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password must not contain parts of your email');
|
||||
}
|
||||
}
|
||||
} else if (context === 'gallery') {
|
||||
// Gallery passwords can be slightly less strict
|
||||
// but still need to be secure
|
||||
if (result.score < 2) {
|
||||
result.valid = false;
|
||||
result.errors.push('Gallery passwords must have moderate strength or better');
|
||||
}
|
||||
|
||||
// Check password doesn't contain event name
|
||||
if (userData.eventName && password.toLowerCase().includes(userData.eventName.toLowerCase())) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password must not contain the event name');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure random password
|
||||
* @param {Object} options - Generation options
|
||||
* @returns {string} - Generated password
|
||||
*/
|
||||
function generateSecurePassword(options = {}) {
|
||||
const config = {
|
||||
length: options.length || 16,
|
||||
includeUppercase: options.includeUppercase !== false,
|
||||
includeLowercase: options.includeLowercase !== false,
|
||||
includeNumbers: options.includeNumbers !== false,
|
||||
includeSpecialChars: options.includeSpecialChars !== false,
|
||||
excludeAmbiguous: options.excludeAmbiguous !== false
|
||||
};
|
||||
|
||||
let charset = '';
|
||||
|
||||
if (config.includeLowercase) {
|
||||
charset += config.excludeAmbiguous ? 'abcdefghjkmnpqrstuvwxyz' : 'abcdefghijklmnopqrstuvwxyz';
|
||||
}
|
||||
|
||||
if (config.includeUppercase) {
|
||||
charset += config.excludeAmbiguous ? 'ABCDEFGHJKLMNPQRSTUVWXYZ' : 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
}
|
||||
|
||||
if (config.includeNumbers) {
|
||||
charset += config.excludeAmbiguous ? '23456789' : '0123456789';
|
||||
}
|
||||
|
||||
if (config.includeSpecialChars) {
|
||||
charset += '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
}
|
||||
|
||||
if (charset.length === 0) {
|
||||
throw new Error('At least one character type must be included');
|
||||
}
|
||||
|
||||
// Generate password
|
||||
const crypto = require('crypto');
|
||||
let password = '';
|
||||
|
||||
for (let i = 0; i < config.length; i++) {
|
||||
const randomIndex = crypto.randomInt(charset.length);
|
||||
password += charset[randomIndex];
|
||||
}
|
||||
|
||||
// Ensure password meets requirements
|
||||
const validation = validatePassword(password);
|
||||
if (!validation.valid) {
|
||||
// Recursively generate until we get a valid password
|
||||
return generateSecurePassword(options);
|
||||
}
|
||||
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bcrypt rounds configuration
|
||||
* @returns {number} - Number of bcrypt rounds to use
|
||||
*/
|
||||
function getBcryptRounds() {
|
||||
return PASSWORD_CONFIG.bcryptRounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log password validation failures for security monitoring
|
||||
* @param {string} context - Context of validation failure
|
||||
* @param {Array} errors - Validation errors
|
||||
* @param {Object} metadata - Additional metadata
|
||||
*/
|
||||
function logPasswordValidationFailure(context, errors, metadata = {}) {
|
||||
logger.warn('Password validation failed', {
|
||||
context,
|
||||
errorCount: errors.length,
|
||||
errors: errors.slice(0, 3), // Log first 3 errors only
|
||||
...metadata
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validatePassword,
|
||||
validatePasswordInContext,
|
||||
generateSecurePassword,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure,
|
||||
PASSWORD_CONFIG
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Rate Limiting Security Utilities
|
||||
* Provides secure rate limiting that prevents bypass attempts
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Safely check if a request has a valid admin token
|
||||
* Used to determine if rate limiting should be skipped
|
||||
*
|
||||
* IMPORTANT: This prevents the bypass vulnerability where
|
||||
* invalid tokens could skip rate limiting
|
||||
*
|
||||
* @param {Object} req - Express request object
|
||||
* @returns {boolean} - True only if token is valid AND admin type
|
||||
*/
|
||||
function hasValidAdminToken(req) {
|
||||
try {
|
||||
// Only check admin paths
|
||||
if (!req.path.startsWith('/api/admin/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
||||
|
||||
// Critical: Verify token is valid before skipping rate limit
|
||||
// This prevents invalid tokens from bypassing rate limiting
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Additional validation
|
||||
if (!decoded || typeof decoded !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be admin type to skip rate limiting
|
||||
if (decoded.type !== 'admin') {
|
||||
logger.warn('Non-admin token attempted to bypass rate limit', {
|
||||
path: req.path,
|
||||
tokenType: decoded.type,
|
||||
ip: req.ip
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Optional: Check token age (prevent old tokens)
|
||||
const tokenAge = Date.now() - (decoded.iat * 1000);
|
||||
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
if (tokenAge > maxAge) {
|
||||
logger.warn('Old admin token attempted to bypass rate limit', {
|
||||
path: req.path,
|
||||
tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes',
|
||||
ip: req.ip
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Valid admin token - can skip rate limiting
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
// Any error means token is invalid
|
||||
// Log attempts with invalid tokens (potential attacks)
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
logger.warn('Invalid token attempted to bypass rate limit', {
|
||||
path: req.path,
|
||||
error: error.message,
|
||||
ip: req.ip
|
||||
});
|
||||
}
|
||||
|
||||
// Apply rate limiting for any invalid token
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a skip function for rate limiter that prevents bypass
|
||||
* @returns {Function} Skip function for express-rate-limit
|
||||
*/
|
||||
function createSecureSkipFunction() {
|
||||
return (req) => {
|
||||
// In development, be more lenient with public settings
|
||||
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only skip for valid admin tokens
|
||||
return hasValidAdminToken(req);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log rate limit hits for security monitoring
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
function logRateLimitHit(req, res) {
|
||||
logger.warn('Rate limit exceeded', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
userAgent: req.headers['user-agent'],
|
||||
remaining: res.getHeader('X-RateLimit-Remaining'),
|
||||
limit: res.getHeader('X-RateLimit-Limit')
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasValidAdminToken,
|
||||
createSecureSkipFunction,
|
||||
logRateLimitHit
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Token Revocation System
|
||||
* Provides ability to invalidate tokens before expiration
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('./logger');
|
||||
|
||||
/**
|
||||
* Add a token to the revocation list
|
||||
* @param {string} token - JWT token to revoke
|
||||
* @param {string} reason - Reason for revocation
|
||||
* @param {Object} metadata - Additional metadata
|
||||
*/
|
||||
async function revokeToken(token, reason, metadata = {}) {
|
||||
try {
|
||||
// Extract token info without full verification (it might be compromised)
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
await db('revoked_tokens').insert({
|
||||
token_id: payload.jti || `${payload.id}-${payload.iat}`, // JWT ID or fallback
|
||||
user_id: payload.id,
|
||||
token_type: payload.type,
|
||||
revoked_at: new Date().toISOString(),
|
||||
expires_at: new Date(payload.exp * 1000).toISOString(),
|
||||
reason,
|
||||
metadata: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
logger.info('Token revoked', {
|
||||
userId: payload.id,
|
||||
tokenType: payload.type,
|
||||
reason
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke token', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is revoked
|
||||
* @param {Object} decodedToken - Decoded JWT payload
|
||||
* @returns {boolean} - True if token is revoked
|
||||
*/
|
||||
async function isTokenRevoked(decodedToken) {
|
||||
try {
|
||||
const tokenId = decodedToken.jti || `${decodedToken.id}-${decodedToken.iat}`;
|
||||
|
||||
const revoked = await db('revoked_tokens')
|
||||
.where('token_id', tokenId)
|
||||
.orWhere((builder) => {
|
||||
builder
|
||||
.where('user_id', decodedToken.id)
|
||||
.where('revoked_at', '<=', new Date(decodedToken.iat * 1000).toISOString());
|
||||
})
|
||||
.first();
|
||||
|
||||
return !!revoked;
|
||||
} catch (error) {
|
||||
logger.error('Failed to check token revocation', error);
|
||||
// Fail closed - treat as revoked if we can't check
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
* @param {number} userId - User ID
|
||||
* @param {string} reason - Reason for revocation
|
||||
*/
|
||||
async function revokeAllUserTokens(userId, reason) {
|
||||
try {
|
||||
// This effectively revokes all tokens by setting a revocation time
|
||||
// Any token issued before this time will be considered revoked
|
||||
await db('user_token_revocations').insert({
|
||||
user_id: userId,
|
||||
revoked_at: new Date().toISOString(),
|
||||
reason
|
||||
}).onConflict('user_id').merge();
|
||||
|
||||
logger.info('All user tokens revoked', { userId, reason });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke user tokens', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired revoked tokens
|
||||
* Should be run periodically
|
||||
*/
|
||||
async function cleanupExpiredRevocations() {
|
||||
try {
|
||||
const deleted = await db('revoked_tokens')
|
||||
.where('expires_at', '<', new Date().toISOString())
|
||||
.delete();
|
||||
|
||||
if (deleted > 0) {
|
||||
logger.info(`Cleaned up ${deleted} expired token revocations`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cleanup revoked tokens', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cleanup job for expired revocations
|
||||
*/
|
||||
function initializeRevocationCleanup() {
|
||||
// Run cleanup every 6 hours
|
||||
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
|
||||
|
||||
// Run initial cleanup
|
||||
cleanupExpiredRevocations();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
revokeToken,
|
||||
isTokenRevoked,
|
||||
revokeAllUserTokens,
|
||||
cleanupExpiredRevocations,
|
||||
initializeRevocationCleanup
|
||||
};
|
||||
Reference in New Issue
Block a user