367 lines
28 KiB
JavaScript
367 lines
28 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import { logger } from '../../logger.js';
|
||
|
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||
|
|
/**
|
||
|
|
* DMARC policy types
|
||
|
|
*/
|
||
|
|
export var DmarcPolicy;
|
||
|
|
(function (DmarcPolicy) {
|
||
|
|
DmarcPolicy["NONE"] = "none";
|
||
|
|
DmarcPolicy["QUARANTINE"] = "quarantine";
|
||
|
|
DmarcPolicy["REJECT"] = "reject";
|
||
|
|
})(DmarcPolicy || (DmarcPolicy = {}));
|
||
|
|
/**
|
||
|
|
* DMARC alignment modes
|
||
|
|
*/
|
||
|
|
export var DmarcAlignment;
|
||
|
|
(function (DmarcAlignment) {
|
||
|
|
DmarcAlignment["RELAXED"] = "r";
|
||
|
|
DmarcAlignment["STRICT"] = "s";
|
||
|
|
})(DmarcAlignment || (DmarcAlignment = {}));
|
||
|
|
/**
|
||
|
|
* Class for verifying and enforcing DMARC policies
|
||
|
|
*/
|
||
|
|
export class DmarcVerifier {
|
||
|
|
// DNS Manager reference for verifying records
|
||
|
|
dnsManager;
|
||
|
|
constructor(dnsManager) {
|
||
|
|
this.dnsManager = dnsManager;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Parse a DMARC record from a TXT record string
|
||
|
|
* @param record DMARC TXT record string
|
||
|
|
* @returns Parsed DMARC record or null if invalid
|
||
|
|
*/
|
||
|
|
parseDmarcRecord(record) {
|
||
|
|
if (!record.startsWith('v=DMARC1')) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
// Initialize record with default values
|
||
|
|
const dmarcRecord = {
|
||
|
|
version: 'DMARC1',
|
||
|
|
policy: DmarcPolicy.NONE,
|
||
|
|
pct: 100,
|
||
|
|
adkim: DmarcAlignment.RELAXED,
|
||
|
|
aspf: DmarcAlignment.RELAXED
|
||
|
|
};
|
||
|
|
// Split the record into tag/value pairs
|
||
|
|
const parts = record.split(';').map(part => part.trim());
|
||
|
|
for (const part of parts) {
|
||
|
|
if (!part || !part.includes('='))
|
||
|
|
continue;
|
||
|
|
const [tag, value] = part.split('=').map(p => p.trim());
|
||
|
|
// Process based on tag
|
||
|
|
switch (tag.toLowerCase()) {
|
||
|
|
case 'v':
|
||
|
|
dmarcRecord.version = value;
|
||
|
|
break;
|
||
|
|
case 'p':
|
||
|
|
dmarcRecord.policy = value;
|
||
|
|
break;
|
||
|
|
case 'sp':
|
||
|
|
dmarcRecord.subdomainPolicy = value;
|
||
|
|
break;
|
||
|
|
case 'pct':
|
||
|
|
const pctValue = parseInt(value, 10);
|
||
|
|
if (!isNaN(pctValue) && pctValue >= 0 && pctValue <= 100) {
|
||
|
|
dmarcRecord.pct = pctValue;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case 'adkim':
|
||
|
|
dmarcRecord.adkim = value;
|
||
|
|
break;
|
||
|
|
case 'aspf':
|
||
|
|
dmarcRecord.aspf = value;
|
||
|
|
break;
|
||
|
|
case 'ri':
|
||
|
|
const interval = parseInt(value, 10);
|
||
|
|
if (!isNaN(interval) && interval > 0) {
|
||
|
|
dmarcRecord.reportInterval = interval;
|
||
|
|
}
|
||
|
|
break;
|
||
|
|
case 'fo':
|
||
|
|
dmarcRecord.failureOptions = value;
|
||
|
|
break;
|
||
|
|
case 'rua':
|
||
|
|
dmarcRecord.reportUriAggregate = value.split(',').map(uri => {
|
||
|
|
if (uri.startsWith('mailto:')) {
|
||
|
|
return uri.substring(7).trim();
|
||
|
|
}
|
||
|
|
return uri.trim();
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
case 'ruf':
|
||
|
|
dmarcRecord.reportUriForensic = value.split(',').map(uri => {
|
||
|
|
if (uri.startsWith('mailto:')) {
|
||
|
|
return uri.substring(7).trim();
|
||
|
|
}
|
||
|
|
return uri.trim();
|
||
|
|
});
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
// Ensure subdomain policy is set if not explicitly provided
|
||
|
|
if (!dmarcRecord.subdomainPolicy) {
|
||
|
|
dmarcRecord.subdomainPolicy = dmarcRecord.policy;
|
||
|
|
}
|
||
|
|
return dmarcRecord;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
logger.log('error', `Error parsing DMARC record: ${error.message}`, {
|
||
|
|
record,
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if domains are aligned according to DMARC policy
|
||
|
|
* @param headerDomain Domain from header (From)
|
||
|
|
* @param authDomain Domain from authentication (SPF, DKIM)
|
||
|
|
* @param alignment Alignment mode
|
||
|
|
* @returns Whether the domains are aligned
|
||
|
|
*/
|
||
|
|
isDomainAligned(headerDomain, authDomain, alignment) {
|
||
|
|
if (!headerDomain || !authDomain) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// For strict alignment, domains must match exactly
|
||
|
|
if (alignment === DmarcAlignment.STRICT) {
|
||
|
|
return headerDomain.toLowerCase() === authDomain.toLowerCase();
|
||
|
|
}
|
||
|
|
// For relaxed alignment, the authenticated domain must be a subdomain of the header domain
|
||
|
|
// or the same as the header domain
|
||
|
|
const headerParts = headerDomain.toLowerCase().split('.');
|
||
|
|
const authParts = authDomain.toLowerCase().split('.');
|
||
|
|
// Ensures we have at least two parts (domain and TLD)
|
||
|
|
if (headerParts.length < 2 || authParts.length < 2) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
// Get organizational domain (last two parts)
|
||
|
|
const headerOrgDomain = headerParts.slice(-2).join('.');
|
||
|
|
const authOrgDomain = authParts.slice(-2).join('.');
|
||
|
|
return headerOrgDomain === authOrgDomain;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Extract domain from an email address
|
||
|
|
* @param email Email address
|
||
|
|
* @returns Domain part of the email
|
||
|
|
*/
|
||
|
|
getDomainFromEmail(email) {
|
||
|
|
if (!email)
|
||
|
|
return '';
|
||
|
|
// Handle name + email format: "John Doe <john@example.com>"
|
||
|
|
const matches = email.match(/<([^>]+)>/);
|
||
|
|
const address = matches ? matches[1] : email;
|
||
|
|
const parts = address.split('@');
|
||
|
|
return parts.length > 1 ? parts[1] : '';
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Check if DMARC verification should be applied based on percentage
|
||
|
|
* @param record DMARC record
|
||
|
|
* @returns Whether DMARC verification should be applied
|
||
|
|
*/
|
||
|
|
shouldApplyDmarc(record) {
|
||
|
|
if (record.pct === undefined || record.pct === 100) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
// Apply DMARC randomly based on percentage
|
||
|
|
const random = Math.floor(Math.random() * 100) + 1;
|
||
|
|
return random <= record.pct;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Determine the action to take based on DMARC policy
|
||
|
|
* @param policy DMARC policy
|
||
|
|
* @returns Action to take
|
||
|
|
*/
|
||
|
|
determineAction(policy) {
|
||
|
|
switch (policy) {
|
||
|
|
case DmarcPolicy.REJECT:
|
||
|
|
return 'reject';
|
||
|
|
case DmarcPolicy.QUARANTINE:
|
||
|
|
return 'quarantine';
|
||
|
|
case DmarcPolicy.NONE:
|
||
|
|
default:
|
||
|
|
return 'pass';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Verify DMARC for an incoming email
|
||
|
|
* @param email Email to verify
|
||
|
|
* @param spfResult SPF verification result
|
||
|
|
* @param dkimResult DKIM verification result
|
||
|
|
* @returns DMARC verification result
|
||
|
|
*/
|
||
|
|
async verify(email, spfResult, dkimResult) {
|
||
|
|
const securityLogger = SecurityLogger.getInstance();
|
||
|
|
// Initialize result
|
||
|
|
const result = {
|
||
|
|
hasDmarc: false,
|
||
|
|
spfDomainAligned: false,
|
||
|
|
dkimDomainAligned: false,
|
||
|
|
spfPassed: spfResult.result,
|
||
|
|
dkimPassed: dkimResult.result,
|
||
|
|
policyEvaluated: DmarcPolicy.NONE,
|
||
|
|
actualPolicy: DmarcPolicy.NONE,
|
||
|
|
appliedPercentage: 100,
|
||
|
|
action: 'pass',
|
||
|
|
details: 'DMARC not configured'
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
// Extract From domain
|
||
|
|
const fromHeader = email.getFromEmail();
|
||
|
|
const fromDomain = this.getDomainFromEmail(fromHeader);
|
||
|
|
if (!fromDomain) {
|
||
|
|
result.error = 'Invalid From domain';
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
// Check alignment
|
||
|
|
result.spfDomainAligned = this.isDomainAligned(fromDomain, spfResult.domain, DmarcAlignment.RELAXED);
|
||
|
|
result.dkimDomainAligned = this.isDomainAligned(fromDomain, dkimResult.domain, DmarcAlignment.RELAXED);
|
||
|
|
// Lookup DMARC record
|
||
|
|
const dmarcVerificationResult = this.dnsManager ?
|
||
|
|
await this.dnsManager.verifyDmarcRecord(fromDomain) :
|
||
|
|
{ found: false, valid: false, error: 'DNS Manager not available' };
|
||
|
|
// If DMARC record exists and is valid
|
||
|
|
if (dmarcVerificationResult.found && dmarcVerificationResult.valid) {
|
||
|
|
result.hasDmarc = true;
|
||
|
|
// Parse DMARC record
|
||
|
|
const parsedRecord = this.parseDmarcRecord(dmarcVerificationResult.value);
|
||
|
|
if (parsedRecord) {
|
||
|
|
result.record = parsedRecord;
|
||
|
|
result.actualPolicy = parsedRecord.policy;
|
||
|
|
result.appliedPercentage = parsedRecord.pct || 100;
|
||
|
|
// Override alignment modes if specified in record
|
||
|
|
if (parsedRecord.adkim) {
|
||
|
|
result.dkimDomainAligned = this.isDomainAligned(fromDomain, dkimResult.domain, parsedRecord.adkim);
|
||
|
|
}
|
||
|
|
if (parsedRecord.aspf) {
|
||
|
|
result.spfDomainAligned = this.isDomainAligned(fromDomain, spfResult.domain, parsedRecord.aspf);
|
||
|
|
}
|
||
|
|
// Determine DMARC compliance
|
||
|
|
const spfAligned = result.spfPassed && result.spfDomainAligned;
|
||
|
|
const dkimAligned = result.dkimPassed && result.dkimDomainAligned;
|
||
|
|
// Email passes DMARC if either SPF or DKIM passes with alignment
|
||
|
|
const dmarcPass = spfAligned || dkimAligned;
|
||
|
|
// Use record percentage to determine if policy should be applied
|
||
|
|
const applyPolicy = this.shouldApplyDmarc(parsedRecord);
|
||
|
|
if (!dmarcPass) {
|
||
|
|
// DMARC failed, apply policy
|
||
|
|
result.policyEvaluated = applyPolicy ? parsedRecord.policy : DmarcPolicy.NONE;
|
||
|
|
result.action = this.determineAction(result.policyEvaluated);
|
||
|
|
result.details = `DMARC failed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}, policy=${result.policyEvaluated}`;
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
result.policyEvaluated = DmarcPolicy.NONE;
|
||
|
|
result.action = 'pass';
|
||
|
|
result.details = `DMARC passed: SPF aligned=${spfAligned}, DKIM aligned=${dkimAligned}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
result.error = 'Invalid DMARC record format';
|
||
|
|
result.details = 'DMARC record invalid';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
// No DMARC record found or invalid
|
||
|
|
result.details = dmarcVerificationResult.error || 'No DMARC record found';
|
||
|
|
}
|
||
|
|
// Log the DMARC verification
|
||
|
|
securityLogger.logEvent({
|
||
|
|
level: result.action === 'pass' ? SecurityLogLevel.INFO : SecurityLogLevel.WARN,
|
||
|
|
type: SecurityEventType.DMARC,
|
||
|
|
message: result.details,
|
||
|
|
domain: fromDomain,
|
||
|
|
details: {
|
||
|
|
fromDomain,
|
||
|
|
spfDomain: spfResult.domain,
|
||
|
|
dkimDomain: dkimResult.domain,
|
||
|
|
spfPassed: result.spfPassed,
|
||
|
|
dkimPassed: result.dkimPassed,
|
||
|
|
spfAligned: result.spfDomainAligned,
|
||
|
|
dkimAligned: result.dkimDomainAligned,
|
||
|
|
dmarcPolicy: result.policyEvaluated,
|
||
|
|
action: result.action
|
||
|
|
},
|
||
|
|
success: result.action === 'pass'
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
logger.log('error', `Error verifying DMARC: ${error.message}`, {
|
||
|
|
error: error.message,
|
||
|
|
emailId: email.getMessageId()
|
||
|
|
});
|
||
|
|
result.error = `DMARC verification error: ${error.message}`;
|
||
|
|
// Log error
|
||
|
|
securityLogger.logEvent({
|
||
|
|
level: SecurityLogLevel.ERROR,
|
||
|
|
type: SecurityEventType.DMARC,
|
||
|
|
message: `DMARC verification failed with error`,
|
||
|
|
details: {
|
||
|
|
error: error.message,
|
||
|
|
emailId: email.getMessageId()
|
||
|
|
},
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Apply DMARC policy to an email
|
||
|
|
* @param email Email to apply policy to
|
||
|
|
* @param dmarcResult DMARC verification result
|
||
|
|
* @returns Whether the email should be accepted
|
||
|
|
*/
|
||
|
|
applyPolicy(email, dmarcResult) {
|
||
|
|
// Apply action based on DMARC verification result
|
||
|
|
switch (dmarcResult.action) {
|
||
|
|
case 'reject':
|
||
|
|
// Reject the email
|
||
|
|
email.mightBeSpam = true;
|
||
|
|
logger.log('warn', `Email rejected due to DMARC policy: ${dmarcResult.details}`, {
|
||
|
|
emailId: email.getMessageId(),
|
||
|
|
from: email.getFromEmail(),
|
||
|
|
subject: email.subject
|
||
|
|
});
|
||
|
|
return false;
|
||
|
|
case 'quarantine':
|
||
|
|
// Quarantine the email (mark as spam)
|
||
|
|
email.mightBeSpam = true;
|
||
|
|
// Add spam header
|
||
|
|
if (!email.headers['X-Spam-Flag']) {
|
||
|
|
email.headers['X-Spam-Flag'] = 'YES';
|
||
|
|
}
|
||
|
|
// Add DMARC reason header
|
||
|
|
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||
|
|
logger.log('warn', `Email quarantined due to DMARC policy: ${dmarcResult.details}`, {
|
||
|
|
emailId: email.getMessageId(),
|
||
|
|
from: email.getFromEmail(),
|
||
|
|
subject: email.subject
|
||
|
|
});
|
||
|
|
return true;
|
||
|
|
case 'pass':
|
||
|
|
default:
|
||
|
|
// Accept the email
|
||
|
|
// Add DMARC result header for information
|
||
|
|
email.headers['X-DMARC-Result'] = dmarcResult.details;
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* End-to-end DMARC verification and policy application
|
||
|
|
* This method should be called after SPF and DKIM verification
|
||
|
|
* @param email Email to verify
|
||
|
|
* @param spfResult SPF verification result
|
||
|
|
* @param dkimResult DKIM verification result
|
||
|
|
* @returns Whether the email should be accepted
|
||
|
|
*/
|
||
|
|
async verifyAndApply(email, spfResult, dkimResult) {
|
||
|
|
// Verify DMARC
|
||
|
|
const dmarcResult = await this.verify(email, spfResult, dkimResult);
|
||
|
|
// Apply DMARC policy
|
||
|
|
return this.applyPolicy(email, dmarcResult);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5kbWFyY3ZlcmlmaWVyLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vLi4vLi4vdHMvbWFpbC9zZWN1cml0eS9jbGFzc2VzLmRtYXJjdmVyaWZpZXIudHMiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLLE9BQU8sTUFBTSxrQkFBa0IsQ0FBQztBQUM1QyxPQUFPLEVBQUUsTUFBTSxFQUFFLE1BQU0saUJBQWlCLENBQUM7QUFDekMsT0FBTyxFQUFFLGNBQWMsRUFBRSxnQkFBZ0IsRUFBRSxpQkFBaUIsRUFBRSxNQUFNLHlCQUF5QixDQUFDO0FBSzlGOztHQUVHO0FBQ0gsTUFBTSxDQUFOLElBQVksV0FJWDtBQUpELFdBQVksV0FBVztJQUNyQiw0QkFBYSxDQUFBO0lBQ2Isd0NBQXlCLENBQUE7SUFDekIsZ0NBQWlCLENBQUE7QUFDbkIsQ0FBQyxFQUpXLFdBQVcsS0FBWCxXQUFXLFFBSXRCO0FBRUQ7O0dBRUc7QUFDSCxNQUFNLENBQU4sSUFBWSxjQUdYO0FBSEQsV0FBWSxjQUFjO0lBQ3hCLCtCQUFhLENBQUE7SUFDYiw4QkFBWSxDQUFBO0FBQ2QsQ0FBQyxFQUhXLGNBQWMsS0FBZCxjQUFjLFFBR3pCO0FBdUNEOztHQUVHO0FBQ0gsTUFBTSxPQUFPLGFBQWE7SUFDeEIsOENBQThDO0lBQ3RDLFVBQVUsQ0FBTztJQUV6QixZQUFZLFVBQWdCO1FBQzFCLElBQUksQ0FBQyxVQUFVLEdBQUcsVUFBVSxDQUFDO0lBQy9CLENBQUM7SUFFRDs7OztPQUlHO0lBQ0ksZ0JBQWdCLENBQUMsTUFBYztRQUNwQyxJQUFJLENBQUMsTUFBTSxDQUFDLFVBQVUsQ0FBQyxVQUFVLENBQUMsRUFBRSxDQUFDO1lBQ25DLE9BQU8sSUFBSSxDQUFDO1FBQ2QsQ0FBQztRQUVELElBQUksQ0FBQztZQUNILHdDQUF3QztZQUN4QyxNQUFNLFdBQVcsR0FBZ0I7Z0JBQy9CLE9BQU8sRUFBRSxRQUFRO2dCQUNqQixNQUFNLEVBQUUsV0FBVyxD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
|