Fitur kontak, pendaftaran, dan formulir undangan sering menjadi celah eksploitasi utama bagi penyerang. Tanpa pengamanan ketat, endpoint publik ini dapat disalahgunakan menjadi open relay tidak langsung atau alat email bombing (serangan penolakan layanan pada kotak masuk korban). Dampaknya fatal: kuota SMTP habis, alamat IP server masuk ke Real-time Blackhole List (RBL) seperti Spamhaus atau SpamCop, dan reputasi domain pada DMARC/SPF/DKIM hancur.

Vektor Serangan pada Endpoint Email Publik

Dua jenis ancaman utama yang mengeksploitasi endpoint pengiriman email tanpa autentikasi adalah:

  • CRLF Injection (Header Injection): Penyerang menyisipkan karakter Carriage Return (\r atau %0D) dan Line Feed (\n atau %0A) ke dalam field formulir (seperti nama atau subjek). Jika nilai ini dimasukkan langsung ke mail header, penyerang dapat menyuntikkan header baru seperti Bcc: atau Cc: yang berisi ribuan alamat target spam.
  • Email Bombing & Reflection Relay: Penyerang memasukkan alamat email korban ke dalam kolom target. Server aplikasi kemudian mengirimkan pesan konfirmasi atau notifikasi resmi ke korban. Jika penyerang mengotomatisasi proses ini melalui skrip, inbox korban dibanjiri ribuan email per menit menggunakan reputasi domain Anda.

Pertahanan 1: Sanitasi Ketat Mail Header

Semua nilai input yang ditempatkan pada header pesan (Subject, To, From, Reply-To) wajib dibersihkan dari karakter kontrol pemisah baris. Jangan pernah melakukan konkatenasi string mentah ke header SMTP.

// sanitizers.js
function sanitizeHeader(input) {
  if (typeof input !== 'string') return '';
  // Buang karakter CRLF dan kontrol ASCII 0x00-0x1F
  const clean = input.replace(/[\r\n\x00-\x1F]/g, '').trim();
  if (clean.length === 0) {
    throw new Error('HEADER_INJECTION_OR_EMPTY_VALUE');
  }
  return clean;
}

function validateEmailAddress(email) {
  // Tolak langsung jika terdapat newline di alamat email
  if (/[\r\n]/.test(email)) {
    throw new Error('INVALID_EMAIL_FORMAT');
  }
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
  if (!emailRegex.test(email)) {
    throw new Error('INVALID_EMAIL_FORMAT');
  }
  return email.toLowerCase().trim();
}

Pertahanan 2: Rate Limiting Bertingkat dengan Redis Sliding Window

Rate limiting berbasis IP saja tidak cukup karena penyerang dapat mendistribusikan request menggunakan proksi perumahan (residential proxies). Terapkan pembatasan bertingkat: batasi request per IP dan batasi pengiriman ke target domain yang sama.

// rateLimiter.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');

async function isRateLimited(key, limit, windowInSeconds) {
  const now = Date.now();
  const clearBefore = now - (windowInSeconds * 1000);

  const pipeline = redis.pipeline();
  pipeline.zremrangebyscore(key, 0, clearBefore); // Bersihkan hit usang
  pipeline.zadd(key, now, `${now}-${Math.random()}`); // Catat timestamp hit saat ini
  pipeline.zcard(key); // Hitung total hit dalam window
  pipeline.expire(key, windowInSeconds);

  const results = await pipeline.exec();
  const currentCount = results[2][1];

  return currentCount > limit;
}

async function enforceTieredRateLimit(clientIp, targetEmail) {
  const domain = targetEmail.split('@')[1];

  // Aturan 1: Maksimal 5 submit per 10 menit per IP
  const ipBlocked = await isRateLimited(`rl:ip:${clientIp}`, 5, 600);
  if (ipBlocked) throw new Error('ERR_RATE_LIMIT_IP');

  // Aturan 2: Maksimal 3 email per 1 jam ke domain tujuan yang sama
  const domainBlocked = await isRateLimited(`rl:target_domain:${domain}`, 3, 3600);
  if (domainBlocked) throw new Error('ERR_RATE_LIMIT_DOMAIN');
}

Pertahanan 3: Validasi DNS MX Record Penerima

Mencegah antrean SMTP dipenuhi oleh alamat palsu atau domain yang sengaja disiapkan untuk menghasilkan hard bounce. Lakukan pengecekan DNS MX secara asinkron sebelum payload dimasukkan ke antrean pengiriman.

// dnsValidator.js
const dns = require('node:dns').promises;

async function verifyDomainHasMx(email) {
  const domain = email.split('@')[1];
  try {
    const addresses = await dns.resolveMx(domain);
    if (!addresses || addresses.length === 0) {
      return false;
    }
    // Pastikan MX record tidak mengarah ke localhost/private IP
    return addresses.some(record => record.exchange && record.exchange !== '.');
  } catch (err) {
    // Domain tidak ditemukan atau tidak memiliki MX record
    return false;
  }
}

Catatan Operasional: Lookup DNS MX menambahkan latensi sekitar 50-200ms pada HTTP request. Simpan cache hasil validasi domain di Redis dengan TTL 24 jam untuk menghindari beban DNS lookup berulang.

Pertahanan 4: Worker Antrean dan Circuit Breaker Pattern

Jangan pernah mengirim email secara sinkron langsung di dalam lifecycle request HTTP. Gunakan message broker/queue (seperti BullMQ, Celery, atau Redis Stream). Tambahkan Circuit Breaker pada consumer antrean untuk memantau status respon dari SMTP relay.

  • Deteksi Kegagalan: Jika penyedia SMTP (misal: AWS SES, SendGrid) mengembalikan error 421 Too Many Connections, 450 Requested mail action not taken, atau lonjakan penolakan (bounce rate > 5%), buka sirkuit (State: OPEN).
  • Aksi Sirkuit Terbuka: Hentikan konsumsi antrean selama interval tertentu (misal 5 menit). Jangan kirim pesan baru untuk mencegah reputasi IP turun di mata ISP penerima (Gmail, Outlook).
  • Recovery (Half-Open): Kirim batch kecil (1-2 email uji). Jika berhasil tanpa error, tutup sirkuit kembali (State: CLOSED).

Implementasi Minimalis Handler dan Runnable Test

Berikut implementasi backend controller minimal yang mengintegrasikan validasi, sanitasi, dan mitigasi:

// server.js
const http = require('node:http');
const { sanitizeHeader, validateEmailAddress } = require('./sanitizers');
const { verifyDomainHasMx } = require('./dnsValidator');
const { enforceTieredRateLimit } = require('./rateLimiter');

async function handleContactSubmit(req, res) {
  let body = '';
  req.on('data', chunk => { body += chunk; });
  req.on('end', async () => {
    try {
      const payload = JSON.parse(body);
      const clientIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress;

      // 1. Sanitasi input
      const cleanSubject = sanitizeHeader(payload.subject || 'Pesan Kontak');
      const cleanRecipient = validateEmailAddress(payload.email || '');

      // 2. Cek Rate Limit
      await enforceTieredRateLimit(clientIp, cleanRecipient);

      // 3. Verifikasi MX Record
      const isValidMx = await verifyDomainHasMx(cleanRecipient);
      if (!isValidMx) {
        res.writeHead(422, { 'Content-Type': 'application/json' });
        return res.end(JSON.stringify({ error: 'Domain email tujuan tidak valid' }));
      }

      // 4. Dorong ke Queue worker (simulasi)
      // await queue.add('sendEmail', { to: cleanRecipient, subject: cleanSubject, body: payload.message });

      res.writeHead(202, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ status: 'queued' }));
    } catch (err) {
      const statusCode = err.message.startsWith('ERR_RATE_LIMIT') ? 429 : 400;
      res.writeHead(statusCode, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: err.message }));
    }
  });
}

Verifikasi Otomatis (Self-Check Test)

Jalankan pengujian mandiri berikut untuk memastikan fungsi sanitasi dan validasi menolak payload eksploitasi secara deterministik:

// test_security.js
const assert = require('node:assert');
const { sanitizeHeader, validateEmailAddress } = require('./sanitizers');

function runTests() {
  // Test 1: CRLF Injection pada subject harus dibersihkan
  const maliciousSubject = 'Halo Admin\r\nBcc: target1@spam.com,target2@spam.com';
  const cleanedSubject = sanitizeHeader(maliciousSubject);
  assert.strictEqual(
    cleanedSubject.includes('\r') || cleanedSubject.includes('\n'),
    false,
    'CRLF masih lolos pada subject'
  );
  assert.strictEqual(cleanedSubject, 'Halo AdminBcc: target1@spam.com,target2@spam.com');

  // Test 2: Injeksi CRLF pada email address harus memicu error langsung
  assert.throws(() => {
    validateEmailAddress('admin@domain.com%0aBcc:victim@target.com');
  }, /INVALID_EMAIL_FORMAT/, 'Payload CRLF tidak ditolak');

  assert.throws(() => {
    validateEmailAddress('admin@domain.com\r\nBcc:victim@target.com');
  }, /INVALID_EMAIL_FORMAT/, 'Payload raw CRLF tidak ditolak');

  // Test 3: Email normal harus lolos normalisasi
  const valid = validateEmailAddress(' USER.test+tag@DOMAIN.COM ');
  assert.strictEqual(valid, 'user.test+tag@domain.com');

  console.log('Semua security assertion lolos.');
}

runTests();

Trade-offs dan Pertimbangan Performa

Menerapkan pengamanan berlapis memperkenalkan beberapa batasan teknis:

  1. False Positives pada Rate Limiting IP: Pengguna di jaringan kantor besar atau universitas berbagi satu public IPv4 (NAT). Pembatasan IP agresif dapat memblokir pengguna sah. Solusinya, gunakan Captcha (seperti Cloudflare Turnstile) sebagai tantangan step-up sebelum IP langsung diblokir.
  2. Beban Kueri DNS: Resolver DNS internal dapat terkena throttling jika penyerang mengirimkan jutaan domain acak. Pastikan terdapat rate limiter IP sebelum pemanggilan fungsi resolveMx().
  3. Pemberitahuan Error yang Netral: Hindari memberikan pesan error spesifik seperti "Domain MX tidak valid" kepada antarmuka pengguna luar. Penyerang dapat menggunakannya sebagai oracle untuk memetakan domain aktif. Gunakan respon umum seperti "Permintaan tidak dapat diproses" pada level presentasi.