import { tap, expect } from '@git.zone/tstest/tapbundle'; import * as net from 'net'; import { startTestServer, stopTestServer, 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).toBeDefined(); }); 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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO first socket.write('EHLO testhost\r\n'); await new Promise((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((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).toEqual(true); } // Test server is still responsive socket.write('NOOP\r\n'); const noopResponse = await new Promise((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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner and send EHLO await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('EHLO testhost\r\n'); await new Promise((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((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((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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Setup connection await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('EHLO testhost\r\n'); await new Promise((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((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).toMatch(/syntax|parameter|address/i); // 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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Setup connection await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('EHLO testhost\r\n'); await new Promise((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:\r\n'); await new Promise((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((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).toMatch(/syntax|parameter|address/i); // 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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO without hostname socket.write('EHLO\r\n'); const ehloResponse = await new Promise((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((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((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((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('error', reject); }); // Get banner await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Send EHLO socket.write('EHLO testhost\r\n'); await new Promise((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((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); setTimeout(() => resolve('TIMEOUT'), 1000); }); } // Now test that server is still functional socket.write('MAIL FROM:\r\n'); const mailResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(mailResponse).toInclude('250'); socket.write('RCPT TO:\r\n'); const rcptResponse = await new Promise((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).toEqual(true); }); export default tap.start();