479 lines
14 KiB
TypeScript
479 lines
14 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 = 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<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 envelope
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('DATA\r\n');
|
|
const dataResponse = await new Promise<string>((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<string>((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).toBeTrue();
|
|
|
|
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<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 envelope
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('DATA\r\n');
|
|
await new Promise<string>((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<string>((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<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 envelope
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('DATA\r\n');
|
|
await new Promise<string>((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<string>((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<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 envelope
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('DATA\r\n');
|
|
await new Promise<string>((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<string>((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).toBeTrue();
|
|
|
|
// 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<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 envelope
|
|
socket.write('MAIL FROM:<sender@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('RCPT TO:<recipient@example.com>\r\n');
|
|
await new Promise<string>((resolve) => {
|
|
socket.once('data', (chunk) => resolve(chunk.toString()));
|
|
});
|
|
|
|
socket.write('DATA\r\n');
|
|
await new Promise<string>((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<string>((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).toBeTrue();
|
|
});
|
|
|
|
tap.start(); |