368 lines
11 KiB
TypeScript
368 lines
11 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 session - SMTP session
|
|
* @param username - Username
|
|
* @param password - Password
|
|
* @param method - Authentication method
|
|
* @returns Promise that resolves to true if authenticated
|
|
*/
|
|
public async authenticate(session: ISmtpSession, username: string, password: string, method: string): Promise<boolean> {
|
|
// 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, method, sessionId: session.id, ip: session.remoteAddress }
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check if method is supported
|
|
if (!authOptions.methods.includes(method as any)) {
|
|
this.logSecurityEvent(
|
|
SecurityEventType.AUTHENTICATION,
|
|
SecurityLogLevel.WARN,
|
|
`Unsupported authentication method: ${method}`,
|
|
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check if TLS is active (should be required for auth)
|
|
if (!session.useTLS) {
|
|
this.logSecurityEvent(
|
|
SecurityEventType.AUTHENTICATION,
|
|
SecurityLogLevel.WARN,
|
|
'Authentication attempt without TLS',
|
|
{ username, method, sessionId: session.id, ip: session.remoteAddress }
|
|
);
|
|
|
|
return false;
|
|
}
|
|
|
|
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, method, sessionId: session.id, ip: session.remoteAddress }
|
|
);
|
|
|
|
return authenticated;
|
|
} catch (error) {
|
|
// Log authentication error
|
|
this.logSecurityEvent(
|
|
SecurityEventType.AUTHENTICATION,
|
|
SecurityLogLevel.ERROR,
|
|
`Authentication error: ${error instanceof Error ? error.message : String(error)}`,
|
|
{ username, method, sessionId: session.id, ip: session.remoteAddress, 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');
|
|
}
|
|
} |