391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
import * as net from 'net';
|
|
import * as path from 'path';
|
|
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
|
|
|
|
// Test configuration
|
|
const TEST_PORT = 2525;
|
|
|
|
let testServer;
|
|
const TEST_TIMEOUT = 10000;
|
|
|
|
// Setup
|
|
tap.test('prepare server', async () => {
|
|
testServer = await startTestServer({ port: TEST_PORT });
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
});
|
|
|
|
// Test: Basic VRFY command
|
|
tap.test('VRFY - should respond to VRFY command', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write('VRFY postmaster\r\n');
|
|
} else if (currentStep === 'vrfy' && receivedData.includes(' ')) {
|
|
const lines = receivedData.split('\r\n');
|
|
const vrfyResponse = lines.find(line => line.match(/^\d{3}/));
|
|
const responseCode = vrfyResponse?.substring(0, 3);
|
|
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
|
|
// VRFY may be:
|
|
// 250/251 - User found/will forward
|
|
// 252 - Cannot verify but will try
|
|
// 502 - Command not implemented (common for security)
|
|
// 503 - Bad sequence of commands (this server rejects VRFY due to sequence validation)
|
|
// 550 - User not found
|
|
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: VRFY multiple users
|
|
tap.test('VRFY - should handle multiple VRFY requests', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
const testUsers = ['postmaster', 'admin', 'test', 'nonexistent'];
|
|
let currentUserIndex = 0;
|
|
const vrfyResults: Array<{ user: string; responseCode: string; supported: boolean }> = [];
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
} else if (currentStep === 'vrfy' && receivedData.includes('503') && currentUserIndex < testUsers.length) {
|
|
// This server always returns 503 for VRFY
|
|
vrfyResults.push({
|
|
user: testUsers[currentUserIndex],
|
|
responseCode: '503',
|
|
supported: false
|
|
});
|
|
|
|
currentUserIndex++;
|
|
|
|
if (currentUserIndex < testUsers.length) {
|
|
receivedData = ''; // Clear buffer
|
|
socket.write(`VRFY ${testUsers[currentUserIndex]}\r\n`);
|
|
} else {
|
|
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
|
|
// Should have results for all users
|
|
expect(vrfyResults.length).toEqual(testUsers.length);
|
|
|
|
// All responses should be valid SMTP codes
|
|
vrfyResults.forEach(result => {
|
|
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
});
|
|
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: VRFY without parameter
|
|
tap.test('VRFY - should reject VRFY without parameter', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy_empty';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write('VRFY\r\n'); // No user specified
|
|
} else if (currentStep === 'vrfy_empty' && receivedData.includes(' ')) {
|
|
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
|
|
// Should be 501 (syntax error), 502 (not implemented), or 503 (bad sequence)
|
|
expect(responseCode).toMatch(/^(501|502|503)$/);
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: VRFY during transaction
|
|
tap.test('VRFY - should work during mail transaction', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'mail_from';
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy_during_transaction';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write('VRFY test@example.com\r\n');
|
|
} else if (currentStep === 'vrfy_during_transaction' && receivedData.includes('503')) {
|
|
const responseCode = '503'; // We know this server always returns 503
|
|
|
|
// VRFY may be rejected with 503 during transaction in this server
|
|
expect(responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
|
|
currentStep = 'rcpt_to';
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: VRFY special addresses
|
|
tap.test('VRFY - should handle special addresses', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
const specialAddresses = [
|
|
'postmaster',
|
|
'postmaster@localhost',
|
|
'abuse',
|
|
'abuse@localhost',
|
|
'noreply',
|
|
'<postmaster@localhost>' // With angle brackets
|
|
];
|
|
let currentIndex = 0;
|
|
const results: Array<{ address: string; responseCode: string }> = [];
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy_special';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
} else if (currentStep === 'vrfy_special' && receivedData.includes('503') && currentIndex < specialAddresses.length) {
|
|
// This server always returns 503 for VRFY
|
|
results.push({
|
|
address: specialAddresses[currentIndex],
|
|
responseCode: '503'
|
|
});
|
|
|
|
currentIndex++;
|
|
|
|
if (currentIndex < specialAddresses.length) {
|
|
receivedData = ''; // Clear buffer
|
|
socket.write(`VRFY ${specialAddresses[currentIndex]}\r\n`);
|
|
} else {
|
|
currentStep = 'done'; // Change state to prevent processing QUIT response
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
|
|
// All addresses should get valid responses
|
|
results.forEach(result => {
|
|
expect(result.responseCode).toMatch(/^(250|251|252|502|503|550)$/);
|
|
});
|
|
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Test: VRFY security considerations
|
|
tap.test('VRFY - verify security behavior', async (tools) => {
|
|
const done = tools.defer();
|
|
|
|
const socket = net.createConnection({
|
|
host: 'localhost',
|
|
port: TEST_PORT,
|
|
timeout: TEST_TIMEOUT
|
|
});
|
|
|
|
let receivedData = '';
|
|
let currentStep = 'connecting';
|
|
let commandDisabled = false;
|
|
|
|
socket.on('data', (data) => {
|
|
receivedData += data.toString();
|
|
|
|
if (currentStep === 'connecting' && receivedData.includes('220')) {
|
|
currentStep = 'ehlo';
|
|
socket.write('EHLO test.example.com\r\n');
|
|
} else if (currentStep === 'ehlo' && receivedData.includes('250')) {
|
|
currentStep = 'vrfy_security';
|
|
receivedData = ''; // Clear buffer before sending VRFY
|
|
socket.write('VRFY randomuser123\r\n');
|
|
} else if (currentStep === 'vrfy_security' && receivedData.includes(' ')) {
|
|
const responseCode = receivedData.match(/(\d{3})/)?.[1];
|
|
|
|
// Check if command is disabled for security or sequence validation
|
|
if (responseCode === '502' || responseCode === '252' || responseCode === '503') {
|
|
commandDisabled = true;
|
|
}
|
|
|
|
socket.write('QUIT\r\n');
|
|
setTimeout(() => {
|
|
socket.destroy();
|
|
|
|
// Note: Many servers disable VRFY for security reasons
|
|
// Both enabled and disabled are valid configurations
|
|
// This server rejects VRFY with 503 due to sequence validation
|
|
if (responseCode === '503' || commandDisabled) {
|
|
expect(responseCode).toMatch(/^(502|252|503)$/);
|
|
} else {
|
|
expect(responseCode).toMatch(/^(250|251|550)$/);
|
|
}
|
|
|
|
done.resolve();
|
|
}, 100);
|
|
}
|
|
});
|
|
|
|
socket.on('error', (error) => {
|
|
done.reject(error);
|
|
});
|
|
|
|
socket.on('timeout', () => {
|
|
socket.destroy();
|
|
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
|
|
});
|
|
|
|
await done.promise;
|
|
});
|
|
|
|
// Teardown
|
|
tap.test('cleanup server', async () => {
|
|
await stopTestServer(testServer);
|
|
});
|
|
|
|
// Start the test
|
|
export default tap.start(); |