dcrouter/ts/mail/delivery/smtpserver/security-handler.ts
2025-05-23 00:06:07 +00:00

345 lines
9.9 KiB
TypeScript

/**
* SMTP Security Handler
* Responsible for security aspects including IP reputation checking,
* email validation, and authentication
*/
import * as plugins from '../../../plugins.js';
import type { ISmtpSession, ISmtpAuth } from './interfaces.js';
import type { ISecurityHandler, ISmtpServer } from './interfaces.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';
/**
* Interface for IP denylist entry
*/
interface IIpDenylistEntry {
ip: string;
reason: string;
expiresAt?: number;
}
/**
* Handles security aspects for SMTP server
*/
export class SecurityHandler implements ISecurityHandler {
/**
* Reference to the SMTP server instance
*/
private smtpServer: ISmtpServer;
/**
* IP reputation checker service
*/
private ipReputationService: IPReputationChecker;
/**
* Simple in-memory IP denylist
*/
private ipDenylist: IIpDenylistEntry[] = [];
/**
* Cleanup interval timer
*/
private cleanupInterval: NodeJS.Timeout | null = null;
/**
* Creates a new security handler
* @param smtpServer - SMTP server instance
*/
constructor(smtpServer: ISmtpServer) {
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
*/
public async checkIpReputation(socket: plugins.net.Socket | plugins.tls.TLSSocket): Promise<boolean> {
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
*/
public isValidEmail(email: string): boolean {
return isValidEmail(email);
}
/**
* Validate authentication credentials
* @param auth - Authentication credentials
* @returns Promise that resolves to true if authenticated
*/
public async authenticate(auth: ISmtpAuth): Promise<boolean> {
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 as any).validateUser) {
authenticated = await (authOptions as any).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
*/
public logSecurityEvent(event: string, level: string, message: string, details: Record<string, any>): void {
SmtpLogger.logSecurityEvent(
level as SecurityLogLevel,
event as SecurityEventType,
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)
*/
private addToDenylist(ip: string, reason: string, duration?: number): void {
// Remove existing entry if present
this.ipDenylist = this.ipDenylist.filter(entry => entry.ip !== ip);
// Create new entry
const entry: IIpDenylistEntry = {
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
*/
private isIpDenylisted(ip: string): boolean {
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
*/
private getDenylistReason(ip: string): string | undefined {
const entry = this.ipDenylist.find(e => e.ip === ip);
return entry?.reason;
}
/**
* Clean expired denylist entries
*/
private cleanExpiredDenylistEntries(): void {
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
*/
public destroy(): void {
// 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 as any).destroy === 'function') {
(this.ipReputationService as any).destroy();
}
SmtpLogger.debug('SecurityHandler destroyed');
}
}