242 lines
18 KiB
JavaScript
242 lines
18 KiB
JavaScript
|
|
/**
|
||
|
|
* SMTP Security Handler
|
||
|
|
* Responsible for security aspects including IP reputation checking,
|
||
|
|
* email validation, and authentication
|
||
|
|
*/
|
||
|
|
import * as plugins from '../../../plugins.js';
|
||
|
|
import { SmtpLogger } from './utils/logging.js';
|
||
|
|
import { SecurityEventType, SecurityLogLevel } from './constants.js';
|
||
|
|
import { isValidEmail } from './utils/validation.js';
|
||
|
|
import { getSocketDetails, getTlsDetails } from './utils/helpers.js';
|
||
|
|
import { IPReputationChecker } from '../../../security/classes.ipreputationchecker.js';
|
||
|
|
/**
|
||
|
|
* Handles security aspects for SMTP server
|
||
|
|
*/
|
||
|
|
export class SecurityHandler {
|
||
|
|
/**
|
||
|
|
* Reference to the SMTP server instance
|
||
|
|
*/
|
||
|
|
smtpServer;
|
||
|
|
/**
|
||
|
|
* IP reputation checker service
|
||
|
|
*/
|
||
|
|
ipReputationService;
|
||
|
|
/**
|
||
|
|
* Simple in-memory IP denylist
|
||
|
|
*/
|
||
|
|
ipDenylist = [];
|
||
|
|
/**
|
||
|
|
* Cleanup interval timer
|
||
|
|
*/
|
||
|
|
cleanupInterval = null;
|
||
|
|
/**
|
||
|
|
* Creates a new security handler
|
||
|
|
* @param smtpServer - SMTP server instance
|
||
|
|
*/
|
||
|
|
constructor(smtpServer) {
|
||
|
|
this.smtpServer = smtpServer;
|
||
|
|
// Initialize IP reputation checker
|
||
|
|
this.ipReputationService = new IPReputationChecker();
|
||
|
|
// Clean expired denylist entries periodically
|
||
|
|
this.cleanupInterval = setInterval(() => this.cleanExpiredDenylistEntries(), 60000); // Every minute
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check IP reputation for a connection
|
||
|
|
* @param socket - Client socket
|
||
|
|
* @returns Promise that resolves to true if IP is allowed, false if blocked
|
||
|
|
*/
|
||
|
|
async checkIpReputation(socket) {
|
||
|
|
const socketDetails = getSocketDetails(socket);
|
||
|
|
const ip = socketDetails.remoteAddress;
|
||
|
|
// Check local denylist first
|
||
|
|
if (this.isIpDenylisted(ip)) {
|
||
|
|
// Log the blocked connection
|
||
|
|
this.logSecurityEvent(SecurityEventType.IP_REPUTATION, SecurityLogLevel.WARN, `Connection blocked from denylisted IP: ${ip}`, { reason: this.getDenylistReason(ip) });
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Check with IP reputation service
|
||
|
|
if (!this.ipReputationService) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
// Check with IP reputation service
|
||
|
|
const reputationResult = await this.ipReputationService.checkReputation(ip);
|
||
|
|
// Block if score is below HIGH_RISK threshold (20) or if it's spam/proxy/tor/vpn
|
||
|
|
const isBlocked = reputationResult.score < 20 ||
|
||
|
|
reputationResult.isSpam ||
|
||
|
|
reputationResult.isTor ||
|
||
|
|
reputationResult.isProxy;
|
||
|
|
if (isBlocked) {
|
||
|
|
// Add to local denylist temporarily
|
||
|
|
const reason = reputationResult.isSpam ? 'spam' :
|
||
|
|
reputationResult.isTor ? 'tor' :
|
||
|
|
reputationResult.isProxy ? 'proxy' :
|
||
|
|
`low reputation score: ${reputationResult.score}`;
|
||
|
|
this.addToDenylist(ip, reason, 3600000); // 1 hour
|
||
|
|
// Log the blocked connection
|
||
|
|
this.logSecurityEvent(SecurityEventType.IP_REPUTATION, SecurityLogLevel.WARN, `Connection blocked by reputation service: ${ip}`, {
|
||
|
|
reason,
|
||
|
|
score: reputationResult.score,
|
||
|
|
isSpam: reputationResult.isSpam,
|
||
|
|
isTor: reputationResult.isTor,
|
||
|
|
isProxy: reputationResult.isProxy,
|
||
|
|
isVPN: reputationResult.isVPN
|
||
|
|
});
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Log the allowed connection
|
||
|
|
this.logSecurityEvent(SecurityEventType.IP_REPUTATION, SecurityLogLevel.INFO, `IP reputation check passed: ${ip}`, {
|
||
|
|
score: reputationResult.score,
|
||
|
|
country: reputationResult.country,
|
||
|
|
org: reputationResult.org
|
||
|
|
});
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Log the error
|
||
|
|
SmtpLogger.error(`IP reputation check error: ${error instanceof Error ? error.message : String(error)}`, {
|
||
|
|
ip,
|
||
|
|
error: error instanceof Error ? error : new Error(String(error))
|
||
|
|
});
|
||
|
|
// Allow the connection on error (fail open)
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Validate an email address
|
||
|
|
* @param email - Email address to validate
|
||
|
|
* @returns Whether the email address is valid
|
||
|
|
*/
|
||
|
|
isValidEmail(email) {
|
||
|
|
return isValidEmail(email);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Validate authentication credentials
|
||
|
|
* @param auth - Authentication credentials
|
||
|
|
* @returns Promise that resolves to true if authenticated
|
||
|
|
*/
|
||
|
|
async authenticate(auth) {
|
||
|
|
const { username, password } = auth;
|
||
|
|
// Get auth options from server
|
||
|
|
const options = this.smtpServer.getOptions();
|
||
|
|
const authOptions = options.auth;
|
||
|
|
// Check if authentication is enabled
|
||
|
|
if (!authOptions) {
|
||
|
|
this.logSecurityEvent(SecurityEventType.AUTHENTICATION, SecurityLogLevel.WARN, 'Authentication attempt when auth is disabled', { username });
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Note: Method validation and TLS requirement checks would need to be done
|
||
|
|
// at the caller level since the interface doesn't include session/method info
|
||
|
|
try {
|
||
|
|
let authenticated = false;
|
||
|
|
// Use custom validation function if provided
|
||
|
|
if (authOptions.validateUser) {
|
||
|
|
authenticated = await authOptions.validateUser(username, password);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// Default behavior - no authentication
|
||
|
|
authenticated = false;
|
||
|
|
}
|
||
|
|
// Log the authentication result
|
||
|
|
this.logSecurityEvent(SecurityEventType.AUTHENTICATION, authenticated ? SecurityLogLevel.INFO : SecurityLogLevel.WARN, authenticated ? 'Authentication successful' : 'Authentication failed', { username });
|
||
|
|
return authenticated;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Log authentication error
|
||
|
|
this.logSecurityEvent(SecurityEventType.AUTHENTICATION, SecurityLogLevel.ERROR, `Authentication error: ${error instanceof Error ? error.message : String(error)}`, { username, error: error instanceof Error ? error.message : String(error) });
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Log a security event
|
||
|
|
* @param event - Event type
|
||
|
|
* @param level - Log level
|
||
|
|
* @param details - Event details
|
||
|
|
*/
|
||
|
|
logSecurityEvent(event, level, message, details) {
|
||
|
|
SmtpLogger.logSecurityEvent(level, event, message, details, details.ip, details.domain, details.success);
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Add an IP to the denylist
|
||
|
|
* @param ip - IP address
|
||
|
|
* @param reason - Reason for denylisting
|
||
|
|
* @param duration - Duration in milliseconds (optional, indefinite if not specified)
|
||
|
|
*/
|
||
|
|
addToDenylist(ip, reason, duration) {
|
||
|
|
// Remove existing entry if present
|
||
|
|
this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip);
|
||
|
|
// Create new entry
|
||
|
|
const entry = {
|
||
|
|
ip,
|
||
|
|
reason,
|
||
|
|
expiresAt: duration ? Date.now() + duration : undefined
|
||
|
|
};
|
||
|
|
// Add to denylist
|
||
|
|
this.ipDenylist.push(entry);
|
||
|
|
// Log the action
|
||
|
|
this.logSecurityEvent(SecurityEventType.ACCESS_CONTROL, SecurityLogLevel.INFO, `Added IP to denylist: ${ip}`, {
|
||
|
|
ip,
|
||
|
|
reason,
|
||
|
|
duration: duration ? `${duration / 1000} seconds` : 'indefinite'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if an IP is denylisted
|
||
|
|
* @param ip - IP address
|
||
|
|
* @returns Whether the IP is denylisted
|
||
|
|
*/
|
||
|
|
isIpDenylisted(ip) {
|
||
|
|
const entry = this.ipDenylist.find(e => e.ip === ip);
|
||
|
|
if (!entry) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Check if entry has expired
|
||
|
|
if (entry.expiresAt && entry.expiresAt < Date.now()) {
|
||
|
|
// Remove expired entry
|
||
|
|
this.ipDenylist = this.ipDenylist.filter(e => e !== entry);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Get the reason an IP was denylisted
|
||
|
|
* @param ip - IP address
|
||
|
|
* @returns Reason for denylisting or undefined if not denylisted
|
||
|
|
*/
|
||
|
|
getDenylistReason(ip) {
|
||
|
|
const entry = this.ipDenylist.find(e => e.ip === ip);
|
||
|
|
return entry?.reason;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clean expired denylist entries
|
||
|
|
*/
|
||
|
|
cleanExpiredDenylistEntries() {
|
||
|
|
const now = Date.now();
|
||
|
|
const initialCount = this.ipDenylist.length;
|
||
|
|
this.ipDenylist = this.ipDenylist.filter(entry => {
|
||
|
|
return !entry.expiresAt || entry.expiresAt > now;
|
||
|
|
});
|
||
|
|
const removedCount = initialCount - this.ipDenylist.length;
|
||
|
|
if (removedCount > 0) {
|
||
|
|
this.logSecurityEvent(SecurityEventType.ACCESS_CONTROL, SecurityLogLevel.INFO, `Cleaned up ${removedCount} expired denylist entries`, { remainingCount: this.ipDenylist.length });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Clean up resources
|
||
|
|
*/
|
||
|
|
destroy() {
|
||
|
|
// Clear the cleanup interval
|
||
|
|
if (this.cleanupInterval) {
|
||
|
|
clearInterval(this.cleanupInterval);
|
||
|
|
this.cleanupInterval = null;
|
||
|
|
}
|
||
|
|
// Clear the denylist
|
||
|
|
this.ipDenylist = [];
|
||
|
|
// Clean up IP reputation service if it has a destroy method
|
||
|
|
if (this.ipReputationService && typeof this.ipReputationService.destroy === 'function') {
|
||
|
|
this.ipReputationService.destroy();
|
||
|
|
}
|
||
|
|
SmtpLogger.debug('SecurityHandler destroyed');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic2VjdXJpdHktaGFuZGxlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uL3RzL21haWwvZGVsaXZlcnkvc210cHNlcnZlci9zZWN1cml0eS1oYW5kbGVyLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBOzs7O0dBSUc7QUFFSCxPQUFPLEtBQUssT0FBTyxNQUFNLHFCQUFxQixDQUFDO0FBRy9DLE9BQU8sRUFBRSxVQUFVLEVBQUUsTUFBTSxvQkFBb0IsQ0FBQztBQUNoRCxPQUFPLEVBQUUsaUJBQWlCLEVBQUUsZ0JBQWdCLEVBQUUsTUFBTSxnQkFBZ0IsQ0FBQztBQUNyRSxPQUFPLEVBQUUsWUFBWSxFQUFFLE1BQU0sdUJBQXVCLENBQUM7QUFDckQsT0FBTyxFQUFFLGdCQUFnQixFQUFFLGFBQWEsRUFBRSxNQUFNLG9CQUFvQixDQUFDO0FBQ3JFLE9BQU8sRUFBRSxtQkFBbUIsRUFBRSxNQUFNLGtEQUFrRCxDQUFDO0FBV3ZGOztHQUVHO0FBQ0gsTUFBTSxPQUFPLGVBQWU7SUFDMUI7O09BRUc7SUFDSyxVQUFVLENBQWM7SUFFaEM7O09BRUc7SUFDSyxtQkFBbUIsQ0FBc0I7SUFFakQ7O09BRUc7SUFDSyxVQUFVLEdBQXVCLEVBQUUsQ0FBQztJQUU1Qzs7T0FFRztJQUNLLGVBQWUsR0FBMEIsSUFBSSxDQUFDO0lBRXREOzs7T0FHRztJQUNILFlBQVksVUFBdUI7UUFDakMsSUFBSSxDQUFDLFVBQVUsR0FBRyxVQUFVLENBQUM7UUFFN0IsbUNBQW1DO1FBQ25DLElBQUksQ0FBQyxtQkFBbUIsR0FBRyxJQUFJLG1CQUFtQixFQUFFLENBQUM7UUFFckQsOENBQThDO1FBQzlDLElBQUksQ0FBQyxlQUFlLEdBQUcsV0FBVyxDQUFDLEdBQUcsRUFBRSxDQUFDLElBQUksQ0FBQywyQkFBMkIsRUFBRSxFQUFFLEtBQUssQ0FBQyxDQUFDLENBQUMsZUFBZTtJQUN0RyxDQUFDO0lBRUQ7Ozs7T0FJRztJQUNJLEtBQUssQ0FBQyxpQkFBaUIsQ0FBQyxNQUFrRDtRQUMvRSxNQUFNLGFBQWEsR0FBRyxnQkFBZ0IsQ0FBQyxNQUFNLENBQUMsQ0FBQztRQUMvQyxNQUFNLEVBQUUsR0FBRyxhQUFhLENBQUMsYUFBYSxDQUFDO1FBRXZDLDZCQUE2QjtRQUM3QixJQUFJLElBQUksQ0FBQyxjQUFjLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQztZQUM1Qiw2QkFBNkI7WUFDN0IsSUFBSSxDQUFDLGdCQUFnQixDQUNuQixpQkFBaUIsQ0FBQyxhQUFhLEVBQy9CLGdCQUFnQixDQUFDLElBQUksRUFDckIsMENBQTBDLEVBQUUsRUFBRSxFQUM5QyxFQUFFLE1BQU0sRUFBRSxJQUFJLENBQUMsaUJBQWlCLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FDdkMsQ0FBQztZQUVGLE9BQU8sS0FBSyxDQUFDO1FBQ2YsQ0FBQztRQUVELG1DQUFtQztRQUNuQyxJQUFJLENBQUMsSUFBSSxDQUFDLG1CQUFtQixFQUFFLENBQUM7WUFDOUIsT0FBTyxJQUFJLENBQUM7UUFDZCxDQUFDO1FBRUQsSUFBSSxDQUFDO1lBQ0gsbUNBQW1DO1lBQ25DLE1BQU0sZ0JBQWdCLEdBQUcsTUFBTSxJQUFJLENBQUMsbUJBQW1CLENBQUMsZUFBZSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1lBRTVFLGlGQUFpRjtZQUNqRixNQUFNLFNBQVMsR0FBRyxnQkFBZ0IsQ0FBQyxLQUFLLEdBQUcsRUFBRTtnQkFDNUIsZ0JBQWdCLENBQUMsTUFBTTtnQkFDdkIsZ0JBQWdCLENBQUMsS0FBSztnQkFDdEIsZ0JBQWdCLENBQUMsT0FBTyxDQUFDO1lBRTFDLElBQUksU0FBUyxFQUFFLENBQUM7Z0JBQ2Qsb0NBQW9DO2dCQUNwQyxNQUFNLE1BQU0sR0FBRyxnQkFBZ0IsQ0FBQyxNQUFNLENBQUMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDO29CQUNuQyxnQkFBZ0IsQ0FBQyxLQUFLLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxDQUFDO3dCQUNoQyxnQkFBZ0IsQ0FBQyxPQUFPLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxDQUFDOzRCQUNwQyx5QkFBeUIsZ0JBQWdCLENBQUMsS0FBSyxFQUFFLENBQUM7Z0JBQ2hFLElBQUksQ0FBQyxhQUFhLENBQUMsRUFBRSxFQUFFLE1BQU0sRUFBRSxPQUFPLENBQUMsQ0FBQyxDQUFDLFNBQVM7Z0JBRWxELDZCQUE2QjtnQkFDN0IsSUFBSSxDQUFDLGdCQUFnQixDQUNuQixpQkFBaUIsQ0FBQyxhQUFhLEVBQy9CLGdCQUFnQixDQUFDLElBQUksRUFDckIsNkNBQTZDLEVBQUUsRUFBRSxFQUNqRDtvQkFDRSxNQUFNO29CQUNOLEtBQUssRUFBRSxnQkFBZ0IsQ0FBQyxLQUFLO29CQUM3QixNQUFNLEVBQUUsZ0JBQWdCLENBQUMsTUFBTTtvQkFDL0IsS0FBSyxFQUFFLGdCQUFnQixDQUFDLEtBQUs7b0JBQzdCLE9BQU8sRUFBRSxnQkFBZ0IsQ0FBQyxPQUFPO29CQUNqQyxLQUFLLEVBQUUsZ0JBQWdCLENBQUMsS0FBSztpQkFDOUIsQ0FDRixDQUFDO2dCQUVGLE9BQU8sS0FBSyxDQUFDO1lBQ2YsQ0FBQztZQUVELDZCQUE2QjtZQUM3QixJQUFJLENBQUMsZ0JBQWdCLENBQ25CLGlCQUFpQixDQUFDLGFBQWEsRUFDL0IsZ0JBQWdCLENBQUMsSUFBSSxFQUNyQiwrQkFBK0IsRUFBRSxFQUFFLEVBQ25DO2dCQUNFLEtBQUssRUFBRSxnQkFBZ0IsQ0FBQyxLQUFLO2dCQUM3QixPQUFPLEVBQUUsZ0JBQWdCLENBQUMsT0FBTztnQkFDakMsR0FBRyxFQUFFLGdCQUFnQixDQUFDLEdBQUc7YUFDMUIsQ0FDRixDQUFDO1lBRUYsT0FBTyxJQUFJLENBQUM7UUFDZCxDQUFDO1FBQUMsT0FBTyxLQUFLLEVBQUUsQ0FBQztZQUNmLGdCQUFnQjtZQUNoQixVQUFVLENBQUMsS0FBSyxDQUFDLDhCQUE4QixLQUFLLFlBQVksS0FBSyxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLEVBQUUsRUFBRTtnQkFDdkcsRUFBRTtnQkFDRixLQUFLLEVBQUUsS0FBSyxZQUFZLEtBQUssQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUMsQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLENBQUMsS0FBSyxDQUFDLENBQUM7YUFDakUsQ0FBQyxDQUFDO1lBRUgsNENBQTRDO1lBQzVDLE9BQU8sSUFBSSxDQUFDO1FBQ2QsQ0FBQztJQUNILENBQUM7SUFFRDs7OztPQUlHO0lBQ0ksWUFBWSxDQUFDLEtBQWE7UUFDL0IsT0FBTyxZQUFZLENBQUMsS0FBSyxDQUFDLENBQUM7SUFDN0IsQ0FBQztJQUVEOzs7O09BSUc7SUFDSSxLQUFLLENBQUMsWUFBWSxDQUFDLElBQWU7UUFDdkMsTUFBTSxFQUFFLFFBQVEsRUFBRSxRQUFRLEVBQUUsR0FBR
|