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 = 30035; const TEST_TIMEOUT = 30000; let testServer: ITestServer; tap.test('setup - start SMTP server for invalid character tests', async () => { testServer = await startTestServer({ port: TEST_PORT, hostname: 'localhost' }); expect(testServer).toBeInstanceOf(Object); }); tap.test('Invalid Character Handling - should handle control characters in email', 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 envelope socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('DATA\r\n'); const dataResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); expect(dataResponse).toInclude('354'); // Test with control characters const controlChars = [ '\x00', // NULL '\x01', // SOH '\x02', // STX '\x03', // ETX '\x7F' // DEL ]; const emailWithControlChars = 'From: sender@example.com\r\n' + 'To: recipient@example.com\r\n' + `Subject: Control Character Test ${controlChars.join('')}\r\n` + '\r\n' + `This email contains control characters: ${controlChars.join('')}\r\n` + 'Null byte: \x00\r\n' + 'Delete char: \x7F\r\n' + '.\r\n'; socket.write(emailWithControlChars); const finalResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to control characters:', finalResponse); // Server might accept or reject based on security settings const accepted = finalResponse.includes('250'); const rejected = finalResponse.includes('550') || finalResponse.includes('554'); expect(accepted || rejected).toEqual(true); if (rejected) { console.log('Server rejected control characters (strict security)'); } else { console.log('Server accepted control characters (may sanitize internally)'); } // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Invalid Character Handling - should handle high-byte characters', 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 envelope socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('DATA\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Test with high-byte characters const highByteChars = [ '\xFF', // 255 '\xFE', // 254 '\xFD', // 253 '\xFC', // 252 '\xFB' // 251 ]; const emailWithHighBytes = 'From: sender@example.com\r\n' + 'To: recipient@example.com\r\n' + 'Subject: High-byte Character Test\r\n' + '\r\n' + `High-byte characters: ${highByteChars.join('')}\r\n` + 'Extended ASCII: \xE0\xE1\xE2\xE3\xE4\r\n' + '.\r\n'; socket.write(emailWithHighBytes); const finalResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to high-byte characters:', finalResponse); // Both acceptance and rejection are valid expect(finalResponse).toMatch(/^[2-5]\d{2}/); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Invalid Character Handling - should handle Unicode special characters', 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 envelope socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('DATA\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Test with Unicode special characters const unicodeSpecials = [ '\u2000', // EN QUAD '\u2028', // LINE SEPARATOR '\u2029', // PARAGRAPH SEPARATOR '\uFEFF', // ZERO WIDTH NO-BREAK SPACE (BOM) '\u200B', // ZERO WIDTH SPACE '\u200C', // ZERO WIDTH NON-JOINER '\u200D' // ZERO WIDTH JOINER ]; const emailWithUnicode = 'From: sender@example.com\r\n' + 'To: recipient@example.com\r\n' + 'Subject: Unicode Special Characters Test\r\n' + 'Content-Type: text/plain; charset=utf-8\r\n' + '\r\n' + `Unicode specials: ${unicodeSpecials.join('')}\r\n` + 'Line separator: \u2028\r\n' + 'Paragraph separator: \u2029\r\n' + 'Zero-width space: word\u200Bword\r\n' + '.\r\n'; socket.write(emailWithUnicode); const finalResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to Unicode special characters:', finalResponse); // Most servers should accept Unicode with proper charset declaration expect(finalResponse).toMatch(/^[2-5]\d{2}/); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Invalid Character Handling - should handle bare LF and CR', 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 envelope socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('DATA\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Test with bare LF and CR (not allowed in SMTP) const emailWithBareLfCr = 'From: sender@example.com\r\n' + 'To: recipient@example.com\r\n' + 'Subject: Bare LF and CR Test\r\n' + '\r\n' + 'Line with bare LF:\nThis should not be allowed\r\n' + 'Line with bare CR:\rThis should also not be allowed\r\n' + 'Correct line ending\r\n' + '.\r\n'; socket.write(emailWithBareLfCr); const finalResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to bare LF/CR:', finalResponse); // Servers may accept and fix, or reject const accepted = finalResponse.includes('250'); const rejected = finalResponse.includes('550') || finalResponse.includes('554'); if (accepted) { console.log('Server accepted bare LF/CR (may convert to CRLF)'); } else if (rejected) { console.log('Server rejected bare LF/CR (strict SMTP compliance)'); } expect(accepted || rejected).toEqual(true); // Clean up socket.write('QUIT\r\n'); socket.end(); } finally { done.resolve(); } }); tap.test('Invalid Character Handling - should handle long lines without proper folding', 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 envelope socket.write('MAIL FROM:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('RCPT TO:\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); socket.write('DATA\r\n'); await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); // Create a line that exceeds RFC 5322 limit (998 characters) const longLine = 'X'.repeat(1500); const emailWithLongLine = 'From: sender@example.com\r\n' + 'To: recipient@example.com\r\n' + 'Subject: Long Line Test\r\n' + '\r\n' + 'Normal line\r\n' + longLine + '\r\n' + 'Another normal line\r\n' + '.\r\n'; socket.write(emailWithLongLine); const finalResponse = await new Promise((resolve) => { socket.once('data', (chunk) => resolve(chunk.toString())); }); console.log('Response to long line:', finalResponse); console.log(`Line length: ${longLine.length} characters`); // Server should handle this (accept, wrap, or reject) expect(finalResponse).toMatch(/^[2-5]\d{2}/); // 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); }); tap.start();