SQL injection menyerang server dengan menyusup ke query. Cross-Site Scripting (XSS) menyerang pengguna: kode JavaScript titipan penyerang ikut terkirim ke browser orang lain lalu berjalan di sana dengan penuh kepercayaan halaman kita. Aplikasi Node.js sering menjadi penghasil HTML — baik lewat template server-side maupun JSON yang dirender client — sehingga tanggung jawab mencegah XSS ada pada kode yang kita tulis. Pada chapter ini kita menyimpan komentar “berbahaya” ke database sungguhan, melihatnya lolos ke halaman, lalu membangun pertahanannya: enkode output untuk konteks yang tepat, textContent di sisi browser, dan Content-Security-Policy sebagai lapisan terakhir.

Tiga Pintu Masuk XSS #

Kode penyerang bisa masuk lewat tiga jalur yang berbeda asalnya, tetapi bermuara pada satu kesalahan: string tidak dipercaya dirangkai ke HTML sebagai kode:

JenisSumber string berbahayaContoh
Stored XSSTersimpan di database, tampil ke semua pengunjungKomentar berisi <script>
Reflected XSSPantulan langsung dari permintaanHasil pencarian menampilkan kembali kata kunci
DOM-based XSSDiproses di browser oleh JavaScript kita sendirielement.innerHTML = location.hash

Ketiganya diperangi dengan aturan yang sama, sehingga demo kita memakai kasus yang paling sering muncul di aplikasi nyata: stored XSS pada kolom komentar.

Simpan Mentah, Enkode Saat Keluar #

Pertanyaan klasik pemula: “kenapa tidak bersihkan saja komentarnya sebelum disimpan?” Karena data yang dibersihkan untuk HTML belum aman untuk konteks lain — dan karena komentar yang tampil sebagai teks polos tetap harus bisa dibaca apa adanya. Maka alurnya dibalik: database menyimpan isi mentah yang sudah tervalidasi, dan setiap kali data keluar menuju HTML, data dienkode. Demo berikut menjalankan siklus penuh dengan node:sqlite:

js
import { DatabaseSync } from 'node:sqlite';

const db = new DatabaseSync(':memory:');

db.exec(`
  CREATE TABLE komentar (
    id INTEGER PRIMARY KEY,
    nama TEXT NOT NULL,
    isi TEXT NOT NULL
  )
`);

// Skenario: pengunjung menitipkan kode, bukan komentar biasa.
const sisip = db.prepare('INSERT INTO komentar (nama, isi) VALUES (?, ?)');
sisip.run('Dewi', 'Artikel ini sangat membantu!');
sisip.run('Siti', '<script>alert("XSS")</script>');

// Enkode lima karakter yang bermakna di HTML.
const PENGGANTI = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
function escapeHtml(teks) {
  return teks.replace(/[&<>"']/g, (karakter) => PENGGANTI[karakter]);
}

function renderTanpaEscape(daftar) {
  return daftar.map((k) => `<li>${k.nama}: ${k.isi}</li>`).join('\n');
}

function renderAman(daftar) {
  return daftar.map((k) => `<li>${escapeHtml(k.nama)}: ${escapeHtml(k.isi)}</li>`).join('\n');
}

const semua = db.prepare('SELECT nama, isi FROM komentar ORDER BY id').all();

console.log('--- Versi rentan: isi mentah dirangkai ke HTML ---');
console.log(renderTanpaEscape(semua));
console.log('--- Versi aman: isi dienkode saat keluar ---');
console.log(renderAman(semua));

db.close();

Output yang Dihasilkan:

text
--- Versi rentan: isi mentah dirangkai ke HTML ---
<li>Dewi: Artikel ini sangat membantu!</li>
<li>Siti: <script>alert("XSS")</script></li>
--- Versi aman: isi dienkode saat keluar ---
<li>Dewi: Artikel ini sangat membantu!</li>
<li>Siti: &lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;</li>

Mari kita bedah elemen kuncinya:

  1. Baris 18-19 (insert jujur): komentar Siti disimpan apa adanya, termasuk tag <script> di dalamnya. Validasi di pintu masuk tetap berguna — misalnya batas panjang — tetapi tidak perlu memfilter tag, karena pemfilteran konteksnya belum diketahui saat penyimpanan.
  2. Baris 22-25 (PENGGANTI dan escapeHtml): lima karakter inilah yang bermakna di HTML: & < > " '. Menggantinya dengan entity seperti &lt; membuat browser menampilkannya sebagai teks, bukan menafsirkannya sebagai struktur. Tanda & dienkode paling awal secara implisit karena penggantinya sendiri mengandung & — regex mengganti semua sekali jalan dalam satu pass, jadi hasil penggantian tidak diproses ulang.
  3. Baris 27-33 (dua versi render): keduanya merangkai string HTML yang sama bentuknya; bedanya hanya escapeHtml memagari setiap nilai yang berasal dari data. Pada versi rentan, <li>Siti: <script>... adalah HTML yang mengeksekusi script begitu halaman dirender browser.
  4. Baris 35-41 (baca lalu render): data diambil dari database — ingat prinsip chapter pertama, hasil query pun adalah masukan yang tidak dipercaya — lalu dienkode tepat sebelum menyentuh konteks HTML.

Konteks Menentukan Aturan Enkode #

Enkode HTML body saja tidak cukup bila string berpindah tempat. Kasus paling licik adalah atribut: penyerang tidak butuh tag baru — cukup menutup atribut yang sedang terbuka. Perhatikan perbedaannya:

js
const PENGGANTI = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
function escapeHtml(teks) {
  return teks.replace(/[&<>"']/g, (karakter) => PENGGANTI[karakter]);
}

const tautanPalsu = '" onmouseover="alert(1)';

console.log('rentan :', `<a href="${tautanPalsu}">profil</a>`);
console.log('aman   :', `<a href="${escapeHtml(tautanPalsu)}">profil</a>`);

Output yang Dihasilkan:

text
rentan : <a href="" onmouseover="alert(1)">profil</a>
aman   : <a href="&quot; onmouseover=&quot;alert(1)">profil</a>

Pada baris rentan, tanda kutip dalam tautanPalsu menutup atribut href lebih awal sehingga onmouseover="alert(1)" lahir sebagai atribut baru yang sah — kode berjalan tanpa tag <script> sama sekali. Versi aman mengubah tanda kutip menjadi &quot;, menjadikan seluruh string sebagai nilai href yang tidak berbahaya (tautan mati, bukan kode hidup). Setiap konteks punya kewajiban enkode sendiri:

Konteks tujuanyang harus dienkodeAlat
HTML body< > &escapeHtml seperti di atas
Atribut HTMLditambah " 'escapeHtml yang sama, atribut selalu bertanda kutip
URL pada atributkarakter kontrol, skema asingvalidasi/encodeURIComponent pada bagian query
String JavaScriptkutip, newline, \JSON.stringify sebelum disisipkan

Di Browser: textContent Mengalahkan innerHTML #

Sebagian besar XSS modern terjadi di sisi client: kode frontend menerima data lalu menuliskannya ke DOM. Di sana pilihan property menentukan segalanya. Property innerHTML mengurai string sebagai HTML — tag di dalamnya menjadi elemen sungguhan — sedangkan textContent memperlakukan string sebagai teks polos:

js Di browser: dua cara menulis ke DOM (ilustrasi, bukan Node.js)
const komentar = '<script>alert("XSS")</script>';

kotak.innerHTML = komentar;    // RENTAN: string diurai sebagai HTML
kotak.textContent = komentar;  // AMAN: string dirender sebagai teks polos

Kode di atas adalah ilustrasi browser karena document tidak ada di Node.js; dokumentasi Element.innerHTML di MDN sendiri memperingatkan risiko keamanan pemakaiannya. Jadikan textContent pilihan bawaan setiap kali yang kita butuhkan hanyalah menampilkan teks, dan sisakan innerHTML untuk HTML yang sepenuhnya kita susun sendiri. Template engine umumnya mengikuti filosofi yang sama secara otomatis: interpolasi default-nya dienkode, dan opsi “raw/unsafe” harus diminta secara eksplisit.

Content-Security-Policy sebagai Lapisan Terakhir #

Bila kedua lapisan di atas bocor — misalnya satu titik render terlewat — masih ada lapisan terakhir yang bekerja di browser: header Content-Security-Policy (CSP). Kebijakan paling ketat melarang semua script inline:

text Contoh kebijakan CSP pada header response
Content-Security-Policy: default-src 'self'

Dengan kebijakan di atas, browser menolak mengeksekusi <script> inline hasil injeksi sekalipun string itu lolos sampai halaman — script hanya boleh dimuat dari berkas pada origin yang sama. CSP tidak menggantikan enkode output; CSP membatasi kerusakan ketika enkode gagal, persis semangat defense in depth dari chapter pertama. Detail seluruh directive-nya terdokumentasi di referensi header Content-Security-Policy MDN , dan gambaran umum serangannya di Glossary: Cross-site scripting serta Node.textContent .

Pola XSS — data yang keluar tanpa dienkode untuk konteksnya — punya saudara di luar web: data yang keluar menuju shell sistem operasi. Di sana string berbahaya bukan tag HTML melainkan perintah seperti rm dan ;. Chapter Command Injection membedahnya sampai tuntas.