Aplikasi forum Siti menampilkan ulasan pembeli di bawah setiap produk. Ulasan itu diketik manusia yang tidak kita kenal: kebanyakan tulisan biasa, tetapi tidak ada jaminan bentuknya. Satu ulasan bisa saja berisi potongan markup — sengaja atau tidak — dan begitu string itu disisipkan ke DOM lewat innerHTML, browser akan mem-parsing-nya menjadi elemen sungguhan di halaman kita.

Di sinilah dua chapter terakhir bertemu: innerHTML dari chapter Reading dan Changing Content memang nyaman, tetapi nyaman hanya untuk string yang kita tulis sendiri. Chapter ini menuntaskan aturan mainnya — membuktikan apa yang sebenarnya terjadi pada input berbahaya, memilih alat yang aman secara bawaan, dan menyiapkan pola escapeHtml untuk kasus di mana markup memang tidak terhindarkan.

Apa yang Sebenarnya Terjadi di Balik innerHTML #

Mari kita buktikan pada level struktur — tanpa perlu browser sungguhan. Kita buat satu <div> sampingan yang tidak ditempelkan ke halaman (dari chapter Creating Elements), sisipkan input “pengguna” ke sana lewat innerHTML, lalu periksa apa yang terbentuk:

// tampilkan-komentar.js — menampilkan input pengguna dengan aman

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

// input "pengguna" — bisa berupa apa pun, termasuk markup berbahaya
const komentar = '<img src=x onerror="alert(\'Punten\')">';
const penulis = "Budi";

// 1. bukti pada struktur: innerHTML mem-parsing input
const sandbox = document.createElement("div");
sandbox.innerHTML = komentar;
console.log(sandbox.querySelectorAll("img").length);               // 1
console.log(sandbox.querySelector("img").getAttribute("onerror")); // alert('Punten')

// 2. textContent: input tetap menjadi teks
const panel = document.querySelector("#komentar-panel");
panel.textContent = komentar;
console.log(panel.querySelectorAll("img").length); // 0
console.log(panel.innerHTML);

// 3. bila markup memang dibutuhkan: escape dulu
panel.innerHTML = `<strong>${escapeHtml(penulis)}</strong>: ${escapeHtml("Diskon <b>50%</b>")}`;
console.log(panel.textContent);                      // Budi: Diskon <b>50%</b>
console.log(panel.querySelector("strong") !== null); // true
console.log(panel.querySelector("b") === null);      // true

Output yang Dihasilkan (console browser):

text
1
alert('Punten')
0
&lt;img src=x onerror="alert('Punten')"&gt;
Budi: Diskon <b>50%</b>
true
true

Keluaran dicatat dari eksekusi nyata di lingkungan jsdom (halaman hanya berisi <div id="komentar-panel"></div>); baris-baris yang sama dihasilkan console browser. Mari kita bedah:

  1. Baris 20 (sandbox.innerHTML = komentar): string milik “pengguna” di-parsing. Tidak ada interpretasi ken-indahan di sini — parser HTML melihat <img src=x ...> dan membangun elemen img sungguhan, lengkap dengan atribut onerror.
  2. Baris 21–22 (bukti struktur): querySelectorAll("img") menemukan satu elemen, dan atribut onerror berisi kode JavaScript siap jalan. Jika <div> sementara ini adalah panel ulasan sungguhan yang tertempel di halaman, markup buatan pengguna kini tinggal menunggu peristiwa untuk dieksekusi.
  3. Baris 26–28 (jalur textContent): string yang sama ditugaskan lewat textContent. Pemeriksaan querySelectorAll("img") kini menemukan nol elemen — pembacaan panel.innerHTML menunjukkan seluruh string tersimpan sebagai teks, dengan <, >, dan " dalam bentuk entitas. Ketikan pengguna hanya pernah menjadi teks.
  4. Baris 31 (markup dari kode, data dari pengguna): pola terakhir menyusun markup dengan template literal, tetapi setiap nilai yang berasal dari luar dilewatkan escapeHtml() terlebih dahulu.
  5. Baris 32–34 (verifikasi akhir): panel.textContent menampilkan teks apa adanya — Diskon <b>50%</b> tampil sebagai tulisan, bukan tebal. Elemen <strong> yang kita buat sendiri ada (true), sedangkan <b> dari input tidak pernah terbentuk (true untuk pemeriksaan ketiadaannya).

Pada halaman sungguhan, baris 20 adalah insiden: ketika gambar gagal dimuat, atribut onerror menjalankan kode penyerang — pola serangan yang dikenal sebagai XSS (Cross-Site Scripting). Contoh kita aman hanya karena <div> samping itu tidak ditempelkan ke dokumen dan jsdom tidak memuat resource.

textContent: Pilihan Aman Bawaan #

Kesimpulan dari bukti di atas sederhana: bila isinya teks, gunakan textContent. Nama pengguna, komentar, judul, angka — semuanya adalah teks, dan textContent menjamin string mana pun tetap menjadi teks. Properti ini juga lebih cepat karena browser tidak perlu menjalankan parser HTML sama sekali.

Kebiasaan yang sehat adalah membalik asumsi: textContent sebagai pilihan bawaan, dan innerHTML hanya dipakai setelah ada alasan eksplisit mengapa markup dibutuhkan.

Pola ketiga di berkas contoh — membangun node dengan createElement lalu mengisi textContent — adalah jalur ketiga yang aman dengan konstruksi yang sama: setiap nilai yang masuk lewat textContent tidak pernah diparsing, dari sumber mana pun. Tiga jalur ini (textContent, createElement + textContent, escapeHtml lalu innerHTML) menutup semua kebutuhan menampilkan data; tidak ada alasan teknis untuk menyisipkan input mentah.

escapeHtml: Ketika Markup Memang Dibutuhkan #

Ada kasus yang sah untuk innerHTML: kita ingin membungkus data dengan markup sendiri — nama penulis di dalam <strong>, misalnya. Solusinya bukan memilih antara “aman” dan “cantik”, melainkan membersihkan data sebelum masuk parser. Function escapeHtml di baris 3–12 berkas contoh melakukan persis itu:

  • Baris 4–10 (peta karakter): lima karakter yang bermakna khusus bagi parser HTML dipetakan ke entitasnya — &, <, >, ", dan '. Karakter & dipetakan juga karena dialah pembuka semua entitas; tanpa itu, teks yang memang berisi &amp; bisa ditafsir ulang.
  • Baris 11 (replace dengan regex): pola /[&<>"']/g mencocokkan satu karakter bahaya mana pun di seluruh string, dan callback (karakter) => peta[karakter] menggantinya dengan entitas dari peta. Setelah keluar dari function ini, tidak ada satu pun karakter yang bisa memulai tag, atribut, atau entitas palsu.

Urutan penggantian tidak menjadi masalah karena replace dengan callback mengganti karakter asli dalam satu lintasan — bukan mengganti berlapis-lapis hasil penggantian sebelumnya.

Aturan Keputusan #

Seluruh chapter ini diringkas dalam satu tabel. Kuncinya satu pertanyaan: dari mana string itu berasal?

Sumber stringAlat yang tepat
Kode kita sendiri (markup statis)innerHTML bebas dipakai
Data teks dari pengguna atau pihak laintextContent, selalu
Markup sendiri yang membungkus data penggunaescapeHtml() pada tiap nilai, lalu innerHTML
Markup yang datang utuh dari pihak lainjangan disisipkan; perlakukan sebagai tidak tepercaya

Baris terakhir tabel berlaku untuk kasus yang lebih jarang disadari: markup dari pihak ketiga (misalnya pratinjau artikel) bisa berisi apa pun, dan menyaringnya butuh pustaka sanitasi khusus — bukan replace buatan sendiri. Untuk batas-batas itu, section Security adalah kelanjutan alami bacaan Anda.

Satu catatan penutup tentang nama function: escapeHtml bersifat generik dan cukup untuk menyisipkan data sebagai teks di dalam markup. Ia bukan sanitizer — tidak mengenali tag mana yang “boleh” — dan memang tidak dimaksudkan untuk itu. Dua tugas berbeda, dua alat berbeda; memahami batas sebuah alat adalah bagian dari memakainya dengan benar.

Dengan babak keamanan ini, section DOM tuntas: kita bisa membaca pohon, memilih elemen, menjelajah, mengganti isi, mengatur atribut dan class, membuat, menghapus, menyusun dengan efisien — dan melakukannya tanpa membuka pintu bagi siapa pun. Langkah berikutnya adalah membuat halaman bereaksi: mendengarkan dan merespons aksi pengguna di section Browser Events.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

tampilkan-komentar.js

Unduh berkas
javascript
// tampilkan-komentar.js — menampilkan input pengguna dengan aman

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

// input "pengguna" — bisa berupa apa pun, termasuk markup berbahaya
const komentar = '<img src=x onerror="alert(\'Punten\')">';
const penulis = "Budi";

// 1. bukti pada struktur: innerHTML mem-parsing input
const sandbox = document.createElement("div");
sandbox.innerHTML = komentar;
console.log(sandbox.querySelectorAll("img").length);               // 1
console.log(sandbox.querySelector("img").getAttribute("onerror")); // alert('Punten')

// 2. textContent: input tetap menjadi teks
const panel = document.querySelector("#komentar-panel");
panel.textContent = komentar;
console.log(panel.querySelectorAll("img").length); // 0
console.log(panel.innerHTML);

// 3. bila markup memang dibutuhkan: escape dulu
panel.innerHTML = `<strong>${escapeHtml(penulis)}</strong>: ${escapeHtml("Diskon <b>50%</b>")}`;
console.log(panel.textContent);                      // Budi: Diskon <b>50%</b>
console.log(panel.querySelector("strong") !== null); // true
console.log(panel.querySelector("b") === null);      // true