dcrouter/test/suite/edge-cases/test.empty-commands.ts
2025-05-23 19:03:44 +00:00

431 lines
12 KiB
TypeScript

import { tap, expect } from '@git.zone/tapbundle';
import * as net from 'net';
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
import type { ITestServer } from '../../helpers/server.loader.js';
const TEST_PORT = 30036;
const TEST_TIMEOUT = 30000;
let testServer: ITestServer;
tap.test('setup - start SMTP server for empty command tests', async () => {
testServer = await startTestServer({
port: TEST_PORT,
hostname: 'localhost'
});
expect(testServer).toBeInstanceOf(Object);
});
tap.test('Empty Commands - should reject empty line (just CRLF)', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO first
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Send empty line (just CRLF)
socket.write('\r\n');
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
setTimeout(() => resolve('TIMEOUT'), 2000);
});
console.log('Response to empty line:', response);
// Should get syntax error (500, 501, or 502)
if (response !== 'TIMEOUT') {
expect(response).toMatch(/^5\d{2}/);
} else {
// Server might ignore empty lines
console.log('Server ignored empty line');
expect(true).toBeTrue();
}
// Test server is still responsive
socket.write('NOOP\r\n');
const noopResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(noopResponse).toInclude('250');
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('Empty Commands - should reject commands with only whitespace', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Get banner and send EHLO
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Test various whitespace-only commands
const whitespaceCommands = [
' \r\n', // Spaces only
'\t\r\n', // Tab only
' \t \r\n', // Mixed whitespace
' \r\n' // Multiple spaces
];
for (const cmd of whitespaceCommands) {
socket.write(cmd);
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
setTimeout(() => resolve('TIMEOUT'), 2000);
});
console.log(`Response to whitespace "${cmd.trim()}"\\r\\n:`, response);
if (response !== 'TIMEOUT') {
// Should get syntax error
expect(response).toMatch(/^5\d{2}/);
}
}
// Verify server still works
socket.write('NOOP\r\n');
const noopResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(noopResponse).toInclude('250');
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('Empty Commands - should reject MAIL FROM with empty parameter', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Setup connection
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Send MAIL FROM with empty parameter
socket.write('MAIL FROM:\r\n');
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
console.log('Response to empty MAIL FROM:', response);
// Should get syntax error (501 or 550)
expect(response).toMatch(/^5\d{2}/);
expect(response.toLowerCase()).toMatch(/syntax|parameter|address/);
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('Empty Commands - should reject RCPT TO with empty parameter', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Setup connection
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Send valid MAIL FROM first
socket.write('MAIL FROM:<sender@example.com>\r\n');
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send RCPT TO with empty parameter
socket.write('RCPT TO:\r\n');
const response = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
console.log('Response to empty RCPT TO:', response);
// Should get syntax error
expect(response).toMatch(/^5\d{2}/);
expect(response.toLowerCase()).toMatch(/syntax|parameter|address/);
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('Empty Commands - should reject EHLO/HELO without hostname', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO without hostname
socket.write('EHLO\r\n');
const ehloResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
console.log('Response to EHLO without hostname:', ehloResponse);
// Should get syntax error
expect(ehloResponse).toMatch(/^5\d{2}/);
// Try HELO without hostname
socket.write('HELO\r\n');
const heloResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
console.log('Response to HELO without hostname:', heloResponse);
// Should get syntax error
expect(heloResponse).toMatch(/^5\d{2}/);
// Send valid EHLO to establish session
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('Empty Commands - server should remain stable after empty commands', async (tools) => {
const done = tools.defer();
try {
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
await new Promise<void>((resolve, reject) => {
socket.once('connect', () => resolve());
socket.once('error', reject);
});
// Get banner
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
// Send EHLO
socket.write('EHLO testhost\r\n');
await new Promise<string>((resolve) => {
let data = '';
const handler = (chunk: Buffer) => {
data += chunk.toString();
if (data.includes('\r\n') && (data.match(/^250 /m) || data.match(/^250-.*\r\n250 /ms))) {
socket.removeListener('data', handler);
resolve(data);
}
};
socket.on('data', handler);
});
// Send multiple empty/invalid commands
const invalidCommands = [
'\r\n',
' \r\n',
'MAIL FROM:\r\n',
'RCPT TO:\r\n',
'EHLO\r\n',
'\t\r\n'
];
for (const cmd of invalidCommands) {
socket.write(cmd);
// Read response but don't fail if error
await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
setTimeout(() => resolve('TIMEOUT'), 1000);
});
}
// Now test that server is still functional
socket.write('MAIL FROM:<test@example.com>\r\n');
const mailResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(mailResponse).toInclude('250');
socket.write('RCPT TO:<recipient@example.com>\r\n');
const rcptResponse = await new Promise<string>((resolve) => {
socket.once('data', (chunk) => resolve(chunk.toString()));
});
expect(rcptResponse).toInclude('250');
console.log('Server remained stable after multiple empty commands');
// Clean up
socket.write('QUIT\r\n');
socket.end();
} finally {
done.resolve();
}
});
tap.test('cleanup - stop SMTP server', async () => {
await stopTestServer(testServer);
expect(true).toBeTrue();
});
tap.start();