494 lines
35 KiB
JavaScript
494 lines
35 KiB
JavaScript
|
|
import * as plugins from '../../plugins.js';
|
||
|
|
import { logger } from '../../logger.js';
|
||
|
|
import { SecurityLogger, SecurityLogLevel, SecurityEventType } from '../../security/index.js';
|
||
|
|
/**
|
||
|
|
* SPF result qualifiers
|
||
|
|
*/
|
||
|
|
export var SpfQualifier;
|
||
|
|
(function (SpfQualifier) {
|
||
|
|
SpfQualifier["PASS"] = "+";
|
||
|
|
SpfQualifier["NEUTRAL"] = "?";
|
||
|
|
SpfQualifier["SOFTFAIL"] = "~";
|
||
|
|
SpfQualifier["FAIL"] = "-";
|
||
|
|
})(SpfQualifier || (SpfQualifier = {}));
|
||
|
|
/**
|
||
|
|
* SPF mechanism types
|
||
|
|
*/
|
||
|
|
export var SpfMechanismType;
|
||
|
|
(function (SpfMechanismType) {
|
||
|
|
SpfMechanismType["ALL"] = "all";
|
||
|
|
SpfMechanismType["INCLUDE"] = "include";
|
||
|
|
SpfMechanismType["A"] = "a";
|
||
|
|
SpfMechanismType["MX"] = "mx";
|
||
|
|
SpfMechanismType["IP4"] = "ip4";
|
||
|
|
SpfMechanismType["IP6"] = "ip6";
|
||
|
|
SpfMechanismType["EXISTS"] = "exists";
|
||
|
|
SpfMechanismType["REDIRECT"] = "redirect";
|
||
|
|
SpfMechanismType["EXP"] = "exp";
|
||
|
|
})(SpfMechanismType || (SpfMechanismType = {}));
|
||
|
|
/**
|
||
|
|
* Maximum lookup limit for SPF records (prevent infinite loops)
|
||
|
|
*/
|
||
|
|
const MAX_SPF_LOOKUPS = 10;
|
||
|
|
/**
|
||
|
|
* Class for verifying SPF records
|
||
|
|
*/
|
||
|
|
export class SpfVerifier {
|
||
|
|
// DNS Manager reference for verifying records
|
||
|
|
dnsManager;
|
||
|
|
lookupCount = 0;
|
||
|
|
constructor(dnsManager) {
|
||
|
|
this.dnsManager = dnsManager;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* Parse SPF record from TXT record
|
||
|
|
* @param record SPF TXT record
|
||
|
|
* @returns Parsed SPF record or null if invalid
|
||
|
|
*/
|
||
|
|
parseSpfRecord(record) {
|
||
|
|
if (!record.startsWith('v=spf1')) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const 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 mechanismText = term;
|
||
|
|
// Check for qualifier
|
||
|
|
if (term.startsWith('+') || term.startsWith('-') ||
|
||
|
|
term.startsWith('~') || term.startsWith('?')) {
|
||
|
|
qualifier = term[0];
|
||
|
|
mechanismText = term.substring(1);
|
||
|
|
}
|
||
|
|
// Parse mechanism type and value
|
||
|
|
const colonIndex = mechanismText.indexOf(':');
|
||
|
|
let type;
|
||
|
|
let value;
|
||
|
|
if (colonIndex !== -1) {
|
||
|
|
type = mechanismText.substring(0, colonIndex);
|
||
|
|
value = mechanismText.substring(colonIndex + 1);
|
||
|
|
}
|
||
|
|
else {
|
||
|
|
type = mechanismText;
|
||
|
|
}
|
||
|
|
spfRecord.mechanisms.push({ qualifier, type, value });
|
||
|
|
}
|
||
|
|
return spfRecord;
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
logger.log('error', `Error parsing SPF record: ${error.message}`, {
|
||
|
|
record,
|
||
|
|
error: error.message
|
||
|
|
});
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
isIpInCidr(ip, cidr) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async isDomainResolvingToIp(domain, ip) {
|
||
|
|
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
|
||
|
|
*/
|
||
|
|
async verify(email, ip, heloDomain) {
|
||
|
|
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
|
||
|
|
};
|
||
|
|
}
|
||
|
|
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,
|
||
|
|
ip,
|
||
|
|
record: spfVerificationResult.value
|
||
|
|
};
|
||
|
|
}
|
||
|
|
catch (error) {
|
||
|
|
// Log 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
|
||
|
|
},
|
||
|
|
success: false
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
result: 'temperror',
|
||
|
|
explanation: `Error verifying SPF: ${error.message}`,
|
||
|
|
domain,
|
||
|
|
ip,
|
||
|
|
error: error.message
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* 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
|
||
|
|
*/
|
||
|
|
async checkSpfRecord(spfRecord, domain, ip) {
|
||
|
|
// 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
|
||
|
|
*/
|
||
|
|
async verifyAndApply(email, ip, heloDomain) {
|
||
|
|
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
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiY2xhc3Nlcy5zcGZ2ZXJpZmllci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3RzL21haWwvc2VjdXJpdHkvY2xhc3Nlcy5zcGZ2ZXJpZmllci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQSxPQUFPLEtBQUssT0FBTyxNQUFNLGtCQUFrQixDQUFDO0FBQzVDLE9BQU8sRUFBRSxNQUFNLEVBQUUsTUFBTSxpQkFBaUIsQ0FBQztBQUN6QyxPQUFPLEVBQUUsY0FBYyxFQUFFLGdCQUFnQixFQUFFLGlCQUFpQixFQUFFLE1BQU0seUJBQXlCLENBQUM7QUFLOUY7O0dBRUc7QUFDSCxNQUFNLENBQU4sSUFBWSxZQUtYO0FBTEQsV0FBWSxZQUFZO0lBQ3RCLDBCQUFVLENBQUE7SUFDViw2QkFBYSxDQUFBO0lBQ2IsOEJBQWMsQ0FBQTtJQUNkLDBCQUFVLENBQUE7QUFDWixDQUFDLEVBTFcsWUFBWSxLQUFaLFlBQVksUUFLdkI7QUFFRDs7R0FFRztBQUNILE1BQU0sQ0FBTixJQUFZLGdCQVVYO0FBVkQsV0FBWSxnQkFBZ0I7SUFDMUIsK0JBQVcsQ0FBQTtJQUNYLHVDQUFtQixDQUFBO0lBQ25CLDJCQUFPLENBQUE7SUFDUCw2QkFBUyxDQUFBO0lBQ1QsK0JBQVcsQ0FBQTtJQUNYLCtCQUFXLENBQUE7SUFDWCxxQ0FBaUIsQ0FBQTtJQUNqQix5Q0FBcUIsQ0FBQTtJQUNyQiwrQkFBVyxDQUFBO0FBQ2IsQ0FBQyxFQVZXLGdCQUFnQixLQUFoQixnQkFBZ0IsUUFVM0I7QUFnQ0Q7O0dBRUc7QUFDSCxNQUFNLGVBQWUsR0FBRyxFQUFFLENBQUM7QUFFM0I7O0dBRUc7QUFDSCxNQUFNLE9BQU8sV0FBVztJQUN0Qiw4Q0FBOEM7SUFDdEMsVUFBVSxDQUFPO0lBQ2pCLFdBQVcsR0FBVyxDQUFDLENBQUM7SUFFaEMsWUFBWSxVQUFnQjtRQUMxQixJQUFJLENBQUMsVUFBVSxHQUFHLFVBQVUsQ0FBQztJQUMvQixDQUFDO0lBRUQ7Ozs7T0FJRztJ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
|