dcrouter/ts/mail/delivery/smtpserver/security-handler.ts

342 lines
9.8 KiB
TypeScript
Raw Normal View History

2025-05-21 12:52:24 +00:00
/**
* SMTP Security Handler
* Responsible for security aspects including IP reputation checking,
* email validation, and authentication
*/
import * as plugins from '../../../plugins.js';
2025-05-21 13:42:12 +00:00
import type { ISmtpSession, ISmtpAuth } from './interfaces.js';
import type { ISecurityHandler } from './interfaces.js';
2025-05-21 12:52:24 +00:00
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 { UnifiedEmailServer } from '../../routing/classes.unified.email.server.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 {
/**
* Email server reference
*/
private emailServer: UnifiedEmailServer;
/**
* IP reputation service
*/
private ipReputationService?: any;
/**
* Authentication options
*/
private authOptions?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
validateUser?: (username: string, password: string) => Promise<boolean>;
};
/**
* Simple in-memory IP denylist
*/
private ipDenylist: IIpDenylistEntry[] = [];
/**
* Creates a new security handler
* @param emailServer - Email server reference
* @param ipReputationService - Optional IP reputation service
* @param authOptions - Authentication options
*/
constructor(
emailServer: UnifiedEmailServer,
ipReputationService?: any,
authOptions?: {
required: boolean;
methods: ('PLAIN' | 'LOGIN' | 'OAUTH2')[];
validateUser?: (username: string, password: string) => Promise<boolean>;
}
) {
this.emailServer = emailServer;
this.ipReputationService = ipReputationService;
this.authOptions = authOptions;
// Clean expired denylist entries periodically
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;
}
// If no reputation service, allow by default
if (!this.ipReputationService) {
return true;
}
try {
// Check with IP reputation service
const reputationResult = await this.ipReputationService.checkIp(ip);
if (!reputationResult.allowed) {
// Add to local denylist temporarily
this.addToDenylist(ip, reputationResult.reason, 3600000); // 1 hour
// Log the blocked connection
this.logSecurityEvent(
SecurityEventType.IP_REPUTATION,
SecurityLogLevel.WARN,
`Connection blocked by reputation service: ${ip}`,
{
reason: reputationResult.reason,
score: reputationResult.score,
categories: reputationResult.categories
}
);
return false;
}
// Log the allowed connection
this.logSecurityEvent(
SecurityEventType.IP_REPUTATION,
SecurityLogLevel.INFO,
`IP reputation check passed: ${ip}`,
{
score: reputationResult.score,
categories: reputationResult.categories
}
);
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> {
// Check if authentication is enabled
if (!this.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 (!this.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 (this.authOptions.validateUser) {
authenticated = await this.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, 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 }
);
}
}
}