update
This commit is contained in:
342
ts/mail/delivery/smtpserver/security-handler.ts
Normal file
342
ts/mail/delivery/smtpserver/security-handler.ts
Normal file
@ -0,0 +1,342 @@
|
||||
/**
|
||||
* 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 } 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 { 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user