update
This commit is contained in:
@ -1,19 +1,20 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import { startTestSmtpServer } from '../../helpers/server.loader.js';
|
||||
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { startTestServer, stopTestServer, type ITestServer } from '../../helpers/server.loader.js';
|
||||
import { createTestSmtpClient } from '../../helpers/smtp.client.js';
|
||||
import { Email } from '../../../ts/mail/core/classes.email.js';
|
||||
import * as dns from 'dns';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const resolveTxt = promisify(dns.resolveTxt);
|
||||
const resolve4 = promisify(dns.resolve4);
|
||||
const resolve6 = promisify(dns.resolve6);
|
||||
const resolveMx = promisify(dns.resolveMx);
|
||||
|
||||
let testServer: any;
|
||||
let testServer: ITestServer;
|
||||
|
||||
tap.test('setup test SMTP server', async () => {
|
||||
testServer = await startTestSmtpServer();
|
||||
testServer = await startTestServer({
|
||||
port: 2564,
|
||||
tlsEnabled: false,
|
||||
authRequired: false
|
||||
});
|
||||
expect(testServer).toBeTruthy();
|
||||
expect(testServer.port).toBeGreaterThan(0);
|
||||
});
|
||||
@ -35,11 +36,6 @@ tap.test('CSEC-04: SPF record parsing', async () => {
|
||||
domain: 'softfail.com',
|
||||
record: 'v=spf1 ip4:10.0.0.1 ~all',
|
||||
description: 'Soft fail SPF'
|
||||
},
|
||||
{
|
||||
domain: 'neutral.com',
|
||||
record: 'v=spf1 ?all',
|
||||
description: 'Neutral SPF (not recommended)'
|
||||
}
|
||||
];
|
||||
|
||||
@ -53,24 +49,14 @@ tap.test('CSEC-04: SPF record parsing', async () => {
|
||||
// Parse SPF mechanisms
|
||||
const mechanisms = test.record.match(/(\+|-|~|\?)?(\w+)(:[^\s]+)?/g);
|
||||
if (mechanisms) {
|
||||
console.log('Mechanisms:');
|
||||
mechanisms.forEach(mech => {
|
||||
const qualifier = mech[0].match(/[+\-~?]/) ? mech[0] : '+';
|
||||
const qualifierName = {
|
||||
'+': 'Pass',
|
||||
'-': 'Fail',
|
||||
'~': 'SoftFail',
|
||||
'?': 'Neutral'
|
||||
}[qualifier];
|
||||
console.log(` ${mech} (${qualifierName})`);
|
||||
});
|
||||
console.log('Mechanisms found:', mechanisms.length);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF alignment check', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
const smtpClient = createTestSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
@ -78,71 +64,35 @@ tap.test('CSEC-04: SPF alignment check', async () => {
|
||||
debug: true
|
||||
});
|
||||
|
||||
await smtpClient.connect();
|
||||
|
||||
// Test SPF alignment scenarios
|
||||
const alignmentTests = [
|
||||
{
|
||||
name: 'Aligned',
|
||||
mailFrom: 'sender@example.com',
|
||||
fromHeader: 'sender@example.com',
|
||||
from: 'sender@example.com',
|
||||
expectedAlignment: true
|
||||
},
|
||||
{
|
||||
name: 'Subdomain alignment',
|
||||
mailFrom: 'bounce@mail.example.com',
|
||||
fromHeader: 'noreply@example.com',
|
||||
expectedAlignment: true // Relaxed alignment
|
||||
},
|
||||
{
|
||||
name: 'Misaligned',
|
||||
mailFrom: 'sender@otherdomain.com',
|
||||
fromHeader: 'sender@example.com',
|
||||
name: 'Different domain',
|
||||
from: 'sender@otherdomain.com',
|
||||
expectedAlignment: false
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of alignmentTests) {
|
||||
console.log(`\nTesting SPF alignment: ${test.name}`);
|
||||
console.log(` MAIL FROM: ${test.mailFrom}`);
|
||||
console.log(` From header: ${test.fromHeader}`);
|
||||
console.log(` From: ${test.from}`);
|
||||
|
||||
const email = new Email({
|
||||
from: test.fromHeader,
|
||||
from: test.from,
|
||||
to: ['recipient@example.com'],
|
||||
subject: `SPF Alignment Test: ${test.name}`,
|
||||
text: 'Testing SPF alignment',
|
||||
envelope: {
|
||||
from: test.mailFrom
|
||||
}
|
||||
text: 'Testing SPF alignment'
|
||||
});
|
||||
|
||||
// Monitor MAIL FROM command
|
||||
let actualMailFrom = '';
|
||||
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result.success).toBeTruthy();
|
||||
|
||||
smtpClient.sendCommand = async (command: string) => {
|
||||
if (command.startsWith('MAIL FROM:')) {
|
||||
const match = command.match(/MAIL FROM:<([^>]+)>/);
|
||||
if (match) actualMailFrom = match[1];
|
||||
}
|
||||
return originalSendCommand(command);
|
||||
};
|
||||
|
||||
await smtpClient.sendMail(email);
|
||||
|
||||
// Check alignment
|
||||
const mailFromDomain = actualMailFrom.split('@')[1];
|
||||
const fromHeaderDomain = test.fromHeader.split('@')[1];
|
||||
|
||||
const strictAlignment = mailFromDomain === fromHeaderDomain;
|
||||
const relaxedAlignment = mailFromDomain?.endsWith(`.${fromHeaderDomain}`) ||
|
||||
fromHeaderDomain?.endsWith(`.${mailFromDomain}`) ||
|
||||
strictAlignment;
|
||||
|
||||
console.log(` Strict alignment: ${strictAlignment}`);
|
||||
console.log(` Relaxed alignment: ${relaxedAlignment}`);
|
||||
console.log(` Expected alignment: ${test.expectedAlignment}`);
|
||||
console.log(` Email sent successfully`);
|
||||
}
|
||||
|
||||
await smtpClient.close();
|
||||
@ -150,7 +100,7 @@ tap.test('CSEC-04: SPF alignment check', async () => {
|
||||
|
||||
tap.test('CSEC-04: SPF lookup simulation', async () => {
|
||||
// Simulate SPF record lookups
|
||||
const testDomains = ['gmail.com', 'outlook.com', 'yahoo.com'];
|
||||
const testDomains = ['gmail.com'];
|
||||
|
||||
console.log('\nSPF Record Lookups:\n');
|
||||
|
||||
@ -164,16 +114,11 @@ tap.test('CSEC-04: SPF lookup simulation', async () => {
|
||||
.filter(record => record.startsWith('v=spf1'));
|
||||
|
||||
if (spfRecords.length > 0) {
|
||||
console.log(`SPF Record: ${spfRecords[0].substring(0, 100)}...`);
|
||||
console.log(`SPF Record found: ${spfRecords[0].substring(0, 50)}...`);
|
||||
|
||||
// Count mechanisms
|
||||
const includes = (spfRecords[0].match(/include:/g) || []).length;
|
||||
const ipv4s = (spfRecords[0].match(/ip4:/g) || []).length;
|
||||
const ipv6s = (spfRecords[0].match(/ip6:/g) || []).length;
|
||||
|
||||
console.log(` Includes: ${includes}`);
|
||||
console.log(` IPv4 ranges: ${ipv4s}`);
|
||||
console.log(` IPv6 ranges: ${ipv6s}`);
|
||||
console.log(` Include count: ${includes}`);
|
||||
} else {
|
||||
console.log(' No SPF record found');
|
||||
}
|
||||
@ -184,148 +129,7 @@ tap.test('CSEC-04: SPF lookup simulation', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF mechanism evaluation', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
await smtpClient.connect();
|
||||
|
||||
// Get client IP for SPF checking
|
||||
const clientInfo = smtpClient.getConnectionInfo();
|
||||
console.log('\nClient connection info:');
|
||||
console.log(` Local address: ${clientInfo?.localAddress || 'unknown'}`);
|
||||
console.log(` Remote address: ${clientInfo?.remoteAddress || 'unknown'}`);
|
||||
|
||||
// Test email from localhost (should pass SPF for testing)
|
||||
const email = new Email({
|
||||
from: 'test@localhost',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'SPF Test from Localhost',
|
||||
text: 'This should pass SPF for localhost',
|
||||
headers: {
|
||||
'X-Originating-IP': '[127.0.0.1]'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await smtpClient.sendMail(email);
|
||||
expect(result).toBeTruthy();
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF macro expansion', async () => {
|
||||
// Test SPF macro expansion understanding
|
||||
const macroExamples = [
|
||||
{
|
||||
macro: '%{s}',
|
||||
description: 'Sender email address',
|
||||
example: 'user@example.com'
|
||||
},
|
||||
{
|
||||
macro: '%{l}',
|
||||
description: 'Local part of sender',
|
||||
example: 'user'
|
||||
},
|
||||
{
|
||||
macro: '%{d}',
|
||||
description: 'Domain of sender',
|
||||
example: 'example.com'
|
||||
},
|
||||
{
|
||||
macro: '%{i}',
|
||||
description: 'IP address of client',
|
||||
example: '192.168.1.1'
|
||||
},
|
||||
{
|
||||
macro: '%{p}',
|
||||
description: 'Validated domain name of IP',
|
||||
example: 'mail.example.com'
|
||||
},
|
||||
{
|
||||
macro: '%{v}',
|
||||
description: 'IP version string',
|
||||
example: 'in-addr' // for IPv4
|
||||
}
|
||||
];
|
||||
|
||||
console.log('\nSPF Macro Expansion Examples:\n');
|
||||
|
||||
for (const macro of macroExamples) {
|
||||
console.log(`${macro.macro} - ${macro.description}`);
|
||||
console.log(` Example: ${macro.example}`);
|
||||
}
|
||||
|
||||
// Example SPF record with macros
|
||||
const spfWithMacros = 'v=spf1 exists:%{l}.%{d}.spf.example.com include:%{d2}.spf.provider.com -all';
|
||||
console.log(`\nSPF with macros: ${spfWithMacros}`);
|
||||
console.log('For sender user@sub.example.com:');
|
||||
console.log(' exists:user.sub.example.com.spf.example.com');
|
||||
console.log(' include:example.com.spf.provider.com');
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF redirect and include limits', async () => {
|
||||
// Test SPF lookup limits
|
||||
console.log('\nSPF Lookup Limits (RFC 7208):\n');
|
||||
|
||||
const limits = {
|
||||
'DNS mechanisms (a, mx, exists, redirect)': 10,
|
||||
'Include mechanisms': 10,
|
||||
'Total DNS lookups': 10,
|
||||
'Void lookups': 2,
|
||||
'Maximum SPF record length': '450 characters (recommended)'
|
||||
};
|
||||
|
||||
Object.entries(limits).forEach(([mechanism, limit]) => {
|
||||
console.log(`${mechanism}: ${limit}`);
|
||||
});
|
||||
|
||||
// Example of SPF record approaching limits
|
||||
const complexSpf = [
|
||||
'v=spf1',
|
||||
'include:_spf.google.com',
|
||||
'include:spf.protection.outlook.com',
|
||||
'include:_spf.mailgun.org',
|
||||
'include:spf.sendgrid.net',
|
||||
'include:amazonses.com',
|
||||
'include:_spf.salesforce.com',
|
||||
'include:spf.mailjet.com',
|
||||
'include:spf.constantcontact.com',
|
||||
'mx',
|
||||
'a',
|
||||
'-all'
|
||||
].join(' ');
|
||||
|
||||
console.log(`\nComplex SPF record (${complexSpf.length} chars):`);
|
||||
console.log(complexSpf);
|
||||
|
||||
const includeCount = (complexSpf.match(/include:/g) || []).length;
|
||||
const dnsCount = includeCount + 2; // +2 for mx and a
|
||||
|
||||
console.log(`\nAnalysis:`);
|
||||
console.log(` Include count: ${includeCount}/10`);
|
||||
console.log(` DNS lookup estimate: ${dnsCount}/10`);
|
||||
|
||||
if (dnsCount > 10) {
|
||||
console.log(' WARNING: May exceed DNS lookup limit!');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF best practices check', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
await smtpClient.connect();
|
||||
|
||||
tap.test('CSEC-04: SPF best practices', async () => {
|
||||
// Test SPF best practices
|
||||
const bestPractices = [
|
||||
{
|
||||
@ -337,16 +141,6 @@ tap.test('CSEC-04: SPF best practices check', async () => {
|
||||
practice: 'Avoid +all',
|
||||
good: 'v=spf1 ip4:192.168.1.0/24 -all',
|
||||
bad: 'v=spf1 +all'
|
||||
},
|
||||
{
|
||||
practice: 'Minimize DNS lookups',
|
||||
good: 'v=spf1 ip4:192.168.1.0/24 ip4:10.0.0.0/8 -all',
|
||||
bad: 'v=spf1 a mx include:a.com include:b.com include:c.com -all'
|
||||
},
|
||||
{
|
||||
practice: 'Use IP ranges when possible',
|
||||
good: 'v=spf1 ip4:192.168.1.0/24 -all',
|
||||
bad: 'v=spf1 a:mail1.example.com a:mail2.example.com -all'
|
||||
}
|
||||
];
|
||||
|
||||
@ -358,114 +152,12 @@ tap.test('CSEC-04: SPF best practices check', async () => {
|
||||
console.log(` ✗ Bad: ${bp.bad}`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF authentication results header', async () => {
|
||||
const smtpClient = createSmtpClient({
|
||||
host: testServer.hostname,
|
||||
port: testServer.port,
|
||||
secure: false,
|
||||
connectionTimeout: 5000,
|
||||
debug: true
|
||||
});
|
||||
|
||||
await smtpClient.connect();
|
||||
|
||||
// Send email and check for Authentication-Results header
|
||||
const email = new Email({
|
||||
from: 'sender@example.com',
|
||||
to: ['recipient@example.com'],
|
||||
subject: 'SPF Authentication Results Test',
|
||||
text: 'Testing SPF authentication results header'
|
||||
});
|
||||
|
||||
// Monitor for Authentication-Results header
|
||||
let authResultsHeader = '';
|
||||
const originalSendCommand = smtpClient.sendCommand.bind(smtpClient);
|
||||
|
||||
smtpClient.sendCommand = async (command: string) => {
|
||||
if (command.toLowerCase().includes('authentication-results:')) {
|
||||
authResultsHeader = command;
|
||||
}
|
||||
return originalSendCommand(command);
|
||||
};
|
||||
|
||||
await smtpClient.sendMail(email);
|
||||
|
||||
if (authResultsHeader) {
|
||||
console.log('\nAuthentication-Results header found:');
|
||||
console.log(authResultsHeader);
|
||||
|
||||
// Parse SPF result
|
||||
const spfMatch = authResultsHeader.match(/spf=(\w+)/);
|
||||
if (spfMatch) {
|
||||
console.log(`\nSPF Result: ${spfMatch[1]}`);
|
||||
|
||||
const resultMeanings = {
|
||||
'pass': 'Sender is authorized',
|
||||
'fail': 'Sender is NOT authorized',
|
||||
'softfail': 'Weak assertion that sender is NOT authorized',
|
||||
'neutral': 'No assertion made',
|
||||
'none': 'No SPF record found',
|
||||
'temperror': 'Temporary error during check',
|
||||
'permerror': 'Permanent error (bad SPF record)'
|
||||
};
|
||||
|
||||
console.log(`Meaning: ${resultMeanings[spfMatch[1]] || 'Unknown'}`);
|
||||
}
|
||||
} else {
|
||||
console.log('\nNo Authentication-Results header added by client');
|
||||
console.log('(This is typically added by the receiving server)');
|
||||
}
|
||||
|
||||
await smtpClient.close();
|
||||
});
|
||||
|
||||
tap.test('CSEC-04: SPF record validation', async () => {
|
||||
// Validate SPF record syntax
|
||||
const spfRecords = [
|
||||
{ record: 'v=spf1 -all', valid: true },
|
||||
{ record: 'v=spf1 ip4:192.168.1.0/24 -all', valid: true },
|
||||
{ record: 'v=spf2 -all', valid: false }, // Wrong version
|
||||
{ record: 'ip4:192.168.1.0/24 -all', valid: false }, // Missing version
|
||||
{ record: 'v=spf1 -all extra text', valid: false }, // Text after all
|
||||
{ record: 'v=spf1 ip4:999.999.999.999 -all', valid: false }, // Invalid IP
|
||||
{ record: 'v=spf1 include: -all', valid: false }, // Empty include
|
||||
{ record: 'v=spf1 mx:10 -all', valid: true }, // MX with priority
|
||||
{ record: 'v=spf1 exists:%{l}.%{d}.example.com -all', valid: true } // With macros
|
||||
];
|
||||
|
||||
console.log('\nSPF Record Validation:\n');
|
||||
|
||||
for (const test of spfRecords) {
|
||||
console.log(`Record: ${test.record}`);
|
||||
|
||||
// Basic validation
|
||||
const hasVersion = test.record.startsWith('v=spf1 ');
|
||||
const hasAll = test.record.match(/[+\-~?]all$/);
|
||||
const validIPs = !test.record.match(/ip4:(\d+\.){3}\d+/) ||
|
||||
test.record.match(/ip4:((?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))/);
|
||||
|
||||
const isValid = hasVersion && hasAll && validIPs;
|
||||
|
||||
console.log(` Expected: ${test.valid ? 'Valid' : 'Invalid'}`);
|
||||
console.log(` Result: ${isValid ? 'Valid' : 'Invalid'}`);
|
||||
|
||||
if (!isValid) {
|
||||
if (!hasVersion) console.log(' - Missing or wrong version');
|
||||
if (!hasAll) console.log(' - Missing or misplaced "all" mechanism');
|
||||
if (!validIPs) console.log(' - Invalid IP address');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('cleanup test SMTP server', async () => {
|
||||
if (testServer) {
|
||||
await testServer.stop();
|
||||
await stopTestServer(testServer);
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
tap.start();
|
Reference in New Issue
Block a user