BREAKING CHANGE(rust-bridge): make Rust the primary security backend, remove all TS fallbacks
Some checks failed
CI / Build Test (Current Platform) (push) Failing after 4s
CI / Type Check & Lint (push) Failing after 6s
CI / Build All Platforms (push) Failing after 4s

Phase 3 of the Rust migration: the Rust security bridge is now mandatory
and all TypeScript security fallback implementations have been removed.

- UnifiedEmailServer.start() throws if Rust bridge fails to start
- SpfVerifier gutted to thin wrapper (parseSpfRecord stays in TS)
- DKIMVerifier gutted to thin wrapper delegating to bridge.verifyDkim()
- IPReputationChecker delegates to bridge.checkIpReputation(), keeps LRU cache
- DmarcVerifier keeps alignment logic (works with pre-computed results)
- DKIM signing via bridge.signDkim() in all 4 locations
- Removed mailauth and ip packages from plugins.ts (~1,200 lines deleted)
This commit is contained in:
2026-02-10 20:30:43 +00:00
parent ffe294643c
commit b82468ab1e
24 changed files with 457 additions and 2695 deletions

View File

@@ -1,9 +1,8 @@
import * as plugins from '../../plugins.js';
import { logger } from '../../logger.js';
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
// MtaService reference removed
import { RustSecurityBridge } from '../../security/classes.rustsecuritybridge.js';
import type { Email } from '../core/classes.email.js';
import type { IDnsVerificationResult } from '../routing/classes.dnsmanager.js';
/**
* SPF result qualifiers
@@ -61,79 +60,64 @@ export interface SpfResult {
}
/**
* Maximum lookup limit for SPF records (prevent infinite loops)
*/
const MAX_SPF_LOOKUPS = 10;
/**
* Class for verifying SPF records
* Class for verifying SPF records.
* Delegates actual SPF evaluation to the Rust security bridge.
* Retains parseSpfRecord() for lightweight local parsing.
*/
export class SpfVerifier {
// DNS Manager reference for verifying records
private dnsManager?: any;
private lookupCount: number = 0;
constructor(dnsManager?: any) {
this.dnsManager = dnsManager;
constructor(_dnsManager?: any) {
// dnsManager is no longer needed — Rust handles DNS lookups
}
/**
* Parse SPF record from TXT record
* @param record SPF TXT record
* @returns Parsed SPF record or null if invalid
* Parse SPF record from TXT record (pure string parsing, no DNS)
*/
public parseSpfRecord(record: string): SpfRecord | null {
if (!record.startsWith('v=spf1')) {
return null;
}
try {
const spfRecord: SpfRecord = {
version: 'spf1',
mechanisms: [],
modifiers: {}
};
// Split into terms
const terms = record.split(' ').filter(term => term.length > 0);
// Skip version term
for (let i = 1; i < terms.length; i++) {
const term = terms[i];
// Check if it's a modifier (name=value)
if (term.includes('=')) {
const [name, value] = term.split('=');
spfRecord.modifiers[name] = value;
continue;
}
// Parse as mechanism
let qualifier = SpfQualifier.PASS; // Default is +
let qualifier = SpfQualifier.PASS;
let mechanismText = term;
// Check for qualifier
if (term.startsWith('+') || term.startsWith('-') ||
if (term.startsWith('+') || term.startsWith('-') ||
term.startsWith('~') || term.startsWith('?')) {
qualifier = term[0] as SpfQualifier;
mechanismText = term.substring(1);
}
// Parse mechanism type and value
const colonIndex = mechanismText.indexOf(':');
let type: SpfMechanismType;
let value: string | undefined;
if (colonIndex !== -1) {
type = mechanismText.substring(0, colonIndex) as SpfMechanismType;
value = mechanismText.substring(colonIndex + 1);
} else {
type = mechanismText as SpfMechanismType;
}
spfRecord.mechanisms.push({ qualifier, type, value });
}
return spfRecord;
} catch (error) {
logger.log('error', `Error parsing SPF record: ${error.message}`, {
@@ -143,60 +127,9 @@ export class SpfVerifier {
return null;
}
}
/**
* Check if IP is in CIDR range
* @param ip IP address to check
* @param cidr CIDR range
* @returns Whether the IP is in the CIDR range
*/
private isIpInCidr(ip: string, cidr: string): boolean {
try {
const ipAddress = plugins.ip.Address4.parse(ip);
return ipAddress.isInSubnet(new plugins.ip.Address4(cidr));
} catch (error) {
// Try IPv6
try {
const ipAddress = plugins.ip.Address6.parse(ip);
return ipAddress.isInSubnet(new plugins.ip.Address6(cidr));
} catch (e) {
return false;
}
}
}
/**
* Check if a domain has the specified IP in its A or AAAA records
* @param domain Domain to check
* @param ip IP address to check
* @returns Whether the domain resolves to the IP
*/
private async isDomainResolvingToIp(domain: string, ip: string): Promise<boolean> {
try {
// First try IPv4
const ipv4Addresses = await plugins.dns.promises.resolve4(domain);
if (ipv4Addresses.includes(ip)) {
return true;
}
// Then try IPv6
const ipv6Addresses = await plugins.dns.promises.resolve6(domain);
if (ipv6Addresses.includes(ip)) {
return true;
}
return false;
} catch (error) {
return false;
}
}
/**
* Verify SPF for a given email with IP and helo domain
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns SPF verification result
* Verify SPF for a given email — delegates to Rust bridge
*/
public async verify(
email: Email,
@@ -204,109 +137,48 @@ export class SpfVerifier {
heloDomain: string
): Promise<SpfResult> {
const securityLogger = SecurityLogger.getInstance();
// Reset lookup count
this.lookupCount = 0;
// Get domain from envelope from (return-path)
const domain = email.getEnvelopeFrom().split('@')[1] || '';
if (!domain) {
return {
result: 'permerror',
explanation: 'No envelope from domain',
domain: '',
ip
};
}
const mailFrom = email.from || '';
const domain = mailFrom.split('@')[1] || '';
try {
// Look up SPF record
const spfVerificationResult = this.dnsManager ?
await this.dnsManager.verifySpfRecord(domain) :
{ found: false, valid: false, error: 'DNS Manager not available' };
if (!spfVerificationResult.found) {
return {
result: 'none',
explanation: 'No SPF record found',
domain,
ip
};
}
if (!spfVerificationResult.valid) {
return {
result: 'permerror',
explanation: 'Invalid SPF record',
domain,
ip,
record: spfVerificationResult.value
};
}
// Parse SPF record
const spfRecord = this.parseSpfRecord(spfVerificationResult.value);
if (!spfRecord) {
return {
result: 'permerror',
explanation: 'Failed to parse SPF record',
domain,
ip,
record: spfVerificationResult.value
};
}
// Check SPF record
const result = await this.checkSpfRecord(spfRecord, domain, ip);
// Log the result
const spfLogLevel = result.result === 'pass' ?
SecurityLogLevel.INFO :
(result.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO);
securityLogger.logEvent({
level: spfLogLevel,
type: SecurityEventType.SPF,
message: `SPF ${result.result} for ${domain} from IP ${ip}`,
domain,
details: {
ip,
heloDomain,
result: result.result,
explanation: result.explanation,
record: spfVerificationResult.value
},
success: result.result === 'pass'
});
return {
...result,
domain,
const bridge = RustSecurityBridge.getInstance();
const result = await bridge.checkSpf({
ip,
record: spfVerificationResult.value
heloDomain,
hostname: plugins.os.hostname(),
mailFrom,
});
const spfResult: SpfResult = {
result: result.result as SpfResult['result'],
domain: result.domain,
ip: result.ip,
explanation: result.explanation ?? undefined,
};
} catch (error) {
// Log error
logger.log('error', `SPF verification error: ${error.message}`, {
domain,
ip,
error: error.message
securityLogger.logEvent({
level: spfResult.result === 'pass' ? SecurityLogLevel.INFO :
(spfResult.result === 'fail' ? SecurityLogLevel.WARN : SecurityLogLevel.INFO),
type: SecurityEventType.SPF,
message: `SPF ${spfResult.result} for ${spfResult.domain} from IP ${ip}`,
domain: spfResult.domain,
details: { ip, heloDomain, result: spfResult.result, explanation: spfResult.explanation },
success: spfResult.result === 'pass'
});
return spfResult;
} catch (error) {
logger.log('error', `SPF verification error: ${error.message}`, { domain, ip, error: error.message });
securityLogger.logEvent({
level: SecurityLogLevel.ERROR,
type: SecurityEventType.SPF,
message: `SPF verification error for ${domain}`,
domain,
details: {
ip,
error: error.message
},
details: { ip, error: error.message },
success: false
});
return {
result: 'temperror',
explanation: `Error verifying SPF: ${error.message}`,
@@ -316,247 +188,9 @@ export class SpfVerifier {
};
}
}
/**
* Check SPF record against IP address
* @param spfRecord Parsed SPF record
* @param domain Domain being checked
* @param ip IP address to check
* @returns SPF result
*/
private async checkSpfRecord(
spfRecord: SpfRecord,
domain: string,
ip: string
): Promise<SpfResult> {
// Check for 'redirect' modifier
if (spfRecord.modifiers.redirect) {
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Handle redirect
const redirectDomain = spfRecord.modifiers.redirect;
const redirectResult = this.dnsManager ?
await this.dnsManager.verifySpfRecord(redirectDomain) :
{ found: false, valid: false, error: 'DNS Manager not available' };
if (!redirectResult.found || !redirectResult.valid) {
return {
result: 'permerror',
explanation: `Invalid redirect to ${redirectDomain}`,
domain,
ip
};
}
const redirectRecord = this.parseSpfRecord(redirectResult.value);
if (!redirectRecord) {
return {
result: 'permerror',
explanation: `Failed to parse redirect record from ${redirectDomain}`,
domain,
ip
};
}
return this.checkSpfRecord(redirectRecord, redirectDomain, ip);
}
// Check each mechanism in order
for (const mechanism of spfRecord.mechanisms) {
let matched = false;
switch (mechanism.type) {
case SpfMechanismType.ALL:
matched = true;
break;
case SpfMechanismType.IP4:
if (mechanism.value) {
matched = this.isIpInCidr(ip, mechanism.value);
}
break;
case SpfMechanismType.IP6:
if (mechanism.value) {
matched = this.isIpInCidr(ip, mechanism.value);
}
break;
case SpfMechanismType.A:
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check if domain has A/AAAA record matching IP
const checkDomain = mechanism.value || domain;
matched = await this.isDomainResolvingToIp(checkDomain, ip);
break;
case SpfMechanismType.MX:
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check MX records
const mxDomain = mechanism.value || domain;
try {
const mxRecords = await plugins.dns.promises.resolveMx(mxDomain);
for (const mx of mxRecords) {
// Check if this MX record's IP matches
const mxMatches = await this.isDomainResolvingToIp(mx.exchange, ip);
if (mxMatches) {
matched = true;
break;
}
}
} catch (error) {
// No MX records or error
matched = false;
}
break;
case SpfMechanismType.INCLUDE:
if (!mechanism.value) {
continue;
}
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check included domain's SPF record
const includeDomain = mechanism.value;
const includeResult = this.dnsManager ?
await this.dnsManager.verifySpfRecord(includeDomain) :
{ found: false, valid: false, error: 'DNS Manager not available' };
if (!includeResult.found || !includeResult.valid) {
continue; // Skip this mechanism
}
const includeRecord = this.parseSpfRecord(includeResult.value);
if (!includeRecord) {
continue; // Skip this mechanism
}
// Recursively check the included SPF record
const includeCheck = await this.checkSpfRecord(includeRecord, includeDomain, ip);
// Include mechanism matches if the result is "pass"
matched = includeCheck.result === 'pass';
break;
case SpfMechanismType.EXISTS:
if (!mechanism.value) {
continue;
}
this.lookupCount++;
if (this.lookupCount > MAX_SPF_LOOKUPS) {
return {
result: 'permerror',
explanation: 'Too many DNS lookups',
domain,
ip
};
}
// Check if domain exists (has any A record)
try {
await plugins.dns.promises.resolve(mechanism.value, 'A');
matched = true;
} catch (error) {
matched = false;
}
break;
}
// If this mechanism matched, return its result
if (matched) {
switch (mechanism.qualifier) {
case SpfQualifier.PASS:
return {
result: 'pass',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.FAIL:
return {
result: 'fail',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.SOFTFAIL:
return {
result: 'softfail',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
case SpfQualifier.NEUTRAL:
return {
result: 'neutral',
explanation: `Matched ${mechanism.type}${mechanism.value ? ':' + mechanism.value : ''}`,
domain,
ip
};
}
}
}
// If no mechanism matched, default to neutral
return {
result: 'neutral',
explanation: 'No matching mechanism found',
domain,
ip
};
}
/**
* Check if email passes SPF verification
* @param email Email to verify
* @param ip Sender IP address
* @param heloDomain HELO/EHLO domain used by sender
* @returns Whether email passes SPF
* Check if email passes SPF verification and apply headers
*/
public async verifyAndApply(
email: Email,
@@ -564,43 +198,36 @@ export class SpfVerifier {
heloDomain: string
): Promise<boolean> {
const result = await this.verify(email, ip, heloDomain);
// Add headers
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
// Apply policy based on result
email.headers['Received-SPF'] = `${result.result} (${result.domain}: ${result.explanation || ''}) client-ip=${ip}; envelope-from=${email.getEnvelopeFrom()}; helo=${heloDomain};`;
switch (result.result) {
case 'fail':
// Fail - mark as spam
email.mightBeSpam = true;
logger.log('warn', `SPF failed for ${result.domain} from ${ip}: ${result.explanation}`);
return false;
case 'softfail':
// Soft fail - accept but mark as suspicious
email.mightBeSpam = true;
logger.log('info', `SPF softfailed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'neutral':
case 'none':
// Neutral or none - accept but note in headers
logger.log('info', `SPF ${result.result} for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'pass':
// Pass - accept
logger.log('info', `SPF passed for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
case 'temperror':
case 'permerror':
// Temporary or permanent error - log but accept
logger.log('error', `SPF error for ${result.domain} from ${ip}: ${result.explanation}`);
return true;
default:
return true;
}
}
}
}