Semua injeksi yang kita bahas sejauh ini menyusup ke kode atau query aplikasi. Cross-Site Request Forgery (CSRF) bekerja lebih halus: aplikasi kita sama sekali tidak ditembus, tidak ada satu baris kode pun yang diubah — namun permintaan berbahaya tetap terkirim atas nama pengguna yang sedang login. Kuncinya ada pada perilaku browser yang mengirimkan cookie otomatis pada setiap permintaan ke domain yang sama, termasuk permintaan yang dipicu halaman milik orang lain. Chapter ini membedah mekanisme tersebut, lalu membangun pertahanan standarnya — token CSRF — dalam demo node:http lengkap dari login sampai permintaan palsu yang ditolak. Arsitektur session dan cookie yang dipakai demo ini adalah warisan langsung Chapter Session dan Cookie di Section Authentication dan Authorization.

Bayangkan Budi sedang login ke aplikasi bank di aplikasi-bank.example.com, lalu di tab lain membuka halaman forum yang menyembunyikan formulir seperti ini:

html Halaman di situs penyerang: formulir siap kirim
<form method="POST" action="//aplikasi-bank.example.com/transfer">
  <input type="hidden" name="jumlah" value="5000000">
</form>
<script>document.forms[0].submit();</script>

Baris <script> mengirimkan formulir itu begitu halaman terbuka — tanpa klik apa pun. Permintaan POST /transfer pun menuju aplikasi bank, dan inilah titik kritisnya: browser melampirkan cookie sesi Budi pada permintaan tersebut karena domainnya cocok, persis seperti permintaan yang Budi kirim sendiri. Dari sisi server, keduanya nyaris tidak terbedakan — sama-sama membawa kredensial sah. Penyerang tidak perlu mencuri cookie; penyerang cukup meminjam browser Budi sebagai kurir.

Pertahanan Utama: Token CSRF #

Yang tidak dimiliki penyerang adalah konten halaman aplikasi kita — formulir yang server kirim ke Budi. Maka pertahanannya: setiap form mengandung token rahasia yang diterbitkan server dan terikat pada sesi, dan setiap permintaan POST wajib membawa token yang cocok. Halaman penyerang tidak bisa membaca token milik Budi (kebijakan same-origin browser memagari dokumen lintas domain), sehingga formulir palsu selalu kehilangan satu bagian. Mari kita buktikan dalam satu berkas:

js
import http from 'node:http';
import { randomBytes } from 'node:crypto';

// Session store: idSesi -> { nama, tokenCsrf }.
const sesi = new Map();

function ambilSesi(headerCookie) {
  if (headerCookie === undefined) return null;
  for (const pasangan of headerCookie.split(';')) {
    const [nama, nilai] = pasangan.trim().split('=');
    if (nama === 'sesi') return sesi.get(nilai) ?? null;
  }
  return null;
}

function bacaBodyForm(req) {
  return new Promise((resolve, reject) => {
    let teks = '';
    req.on('data', (potongan) => (teks += potongan));
    req.on('end', () => resolve(Object.fromEntries(new URLSearchParams(teks))));
    req.on('error', reject);
  });
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, `http://127.0.0.1`);
  const kirim = (kode, data) => {
    res.writeHead(kode, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
  };

  if (req.method === 'POST' && url.pathname === '/login') {
    const body = await bacaBodyForm(req);
    const idSesi = randomBytes(16).toString('hex');
    const tokenCsrf = randomBytes(16).toString('hex');
    sesi.set(idSesi, { nama: body.nama, tokenCsrf });
    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Set-Cookie': `sesi=${idSesi}; HttpOnly; SameSite=Lax; Path=/`,
    });
    return res.end(JSON.stringify({ nama: body.nama }));
  }

  if (req.method === 'GET' && url.pathname === '/transfer') {
    const data = ambilSesi(req.headers.cookie);
    if (data === null) return kirim(401, { error: 'Belum masuk' });
    const form = `<form method="POST" action="/transfer">
  <input type="hidden" name="_csrf" value="${data.tokenCsrf}">
  <input type="text" name="jumlah" value="50000">
  <button>Kirim</button>
</form>`;
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    return res.end(form);
  }

  if (req.method === 'POST' && url.pathname === '/transfer') {
    const data = ambilSesi(req.headers.cookie);
    if (data === null) return kirim(401, { error: 'Belum masuk' });
    const body = await bacaBodyForm(req);
    if (body._csrf !== data.tokenCsrf) {
      return kirim(403, { error: 'Token CSRF tidak valid' });
    }
    return kirim(200, { pesan: `Transfer ${body.jumlah} diproses untuk ${data.nama}` });
  }

  return kirim(404, { error: 'Endpoint tidak ditemukan' });
});

server.listen(0, '127.0.0.1', async () => {
  const base = `http://127.0.0.1:${server.address().port}`;

  console.log('1. Budi login - server menerbitkan cookie sesi + token CSRF');
  const login = await fetch(`${base}/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ nama: 'Budi' }).toString(),
  });
  const cookie = login.headers.get('set-cookie').split(';')[0];
  console.log('   status:', login.status, await login.text());

  console.log('2. Budi membuka halaman form - token tercetak di dalam form');
  const halaman = await fetch(`${base}/transfer`, { headers: { Cookie: cookie } });
  const html = await halaman.text();
  const token = html.match(/name="_csrf" value="([0-9a-f]{32})"/)[1];
  console.log('   status:', halaman.status, `- token ${token.length} karakter terpasang`);

  console.log('3. Permintaan sah: cookie + token dari form yang sama');
  const sah = await fetch(`${base}/transfer`, {
    method: 'POST',
    headers: { Cookie: cookie, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ _csrf: token, jumlah: '50000' }).toString(),
  });
  console.log('   status:', sah.status, await sah.text());

  console.log('4. Permintaan situs lain: cookie ikut terkirim, token tidak ada');
  const situsLain = await fetch(`${base}/transfer`, {
    method: 'POST',
    headers: { Cookie: cookie, 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ jumlah: '5000000' }).toString(),
  });
  console.log('   status:', situsLain.status, await situsLain.text());

  server.close();
  server.closeAllConnections();
});

Output yang Dihasilkan:

text
1. Budi login - server menerbitkan cookie sesi + token CSRF
   status: 200 {"nama":"Budi"}
2. Budi membuka halaman form - token tercetak di dalam form
   status: 200 - token 32 karakter terpasang
3. Permintaan sah: cookie + token dari form yang sama
   status: 200 {"pesan":"Transfer 50000 diproses untuk Budi"}
4. Permintaan situs lain: cookie ikut terkirim, token tidak ada
   status: 403 {"error":"Token CSRF tidak valid"}

Mari kita bedah lapisan-lapisannya:

  1. Baris 32-42 (handler /login): server menerbitkan dua nilai acak sekaligus — idSesi dan tokenCsrf — lalu menyimpan keduanya berpasangan di session store. Cookie hanya membawa idSesi; token CSRF tidak pernah disimpan sebagai cookie.
  2. Baris 44-54 (handler GET /transfer): halaman form dirender dengan token di dalam input tersembunyi name="_csrf". Inilah momen “serah terima” — token sampai ke Budi hanya lewat halaman yang memang ditujukan untuk Budi.
  3. Baris 56-64 (handler POST /transfer): gerbangnya ada di baris 60 — token yang dikirim form dibandingkan dengan token yang tersimpan di sesi. Tidak cocok berarti 403, dan sama sekali tidak menyentuh logika transfer. Perhatikan juga urutan pemeriksaan: sesi dulu (401), token kemudian (403), baru aksi.
  4. Baris 82-84 (simulasi serangan): langkah ke-4 meniru halaman penyerang — cookie Budi dilampirkan (browser melakukannya otomatis; fetch Node.js melakukannya lewat header eksplisit), tetapi body hanya berisi jumlah tanpa _csrf. Hasilnya 403: permintaan datang membawa identitas yang sah namun tanpa bukti bahwa permintaan itu lahir dari halaman aplikasi.
  5. Baris 78-79 (ekstraksi token): pada aplikasi nyata, browser mengirim field form secara otomatis; pada demo ini kita membaca token dari HTML dengan regex — pekerjaan yang di browser dilakukan DOM.

SameSite sebagai Lapisan Kedua #

Atribut cookie SameSite menutup jalur dari sisi browser, dan demo kita sudah memakainya: Set-Cookie: sesi=...; HttpOnly; SameSite=Lax; Path=/. Nilai Lax mengizinkan cookie terkirim pada navigasi tingkat atas yang aman (klik tautan biasa), tetapi menahan cookie pada permintaan lintas situs seperti POST dari formulir penyerang. Nilai Strict lebih ketat lagi — cookie tidak terkirim bahkan saat Budi mengklik tautan menuju aplikasi dari situs lain. Ini pertahanan yang kuat dan nyaris gratis, tetapi tetap lapisan kedua: beberapa browser lama mengabaikan atribut ini, dan Lax masih meloloskan navigasi GET — alasan lain endpoint pengubah keadaan tidak boleh GET. Kombinasinya dengan token membentuk defense in depth yang sesungguhnya: token bekerja di lapisan aplikasi, SameSite di lapisan browser.

Detail nilai SameSite terdokumentasi lengkap di referensi header Set-Cookie MDN , dan gambaran umum serangannya di Glossary: CSRF . Ada satu bentuk penyalahgunaan yang tidak butuh celah aplikasi sama sekali — hanya mengandalkan volume: banjir permintaan yang menghabiskan sumber daya. Cara membendungnya menjadi topik Chapter Rate Limiting.