โ† Semua pembelajaran / Astro Nol โ†’ Portal Berita
Fase 7 ยท Keamanan

XSS & bahaya set:html

Portal berita menyimpan HTML di database dan merendernya apa adanya. Itu satu-satunya jalur XSS yang benar-benar penting untukmu, dan ia ada di halaman yang paling banyak dibaca.

Sumber asli owasp.org Artikel Rangkuman ~9 menit baca

Intisari

  • {ekspresi} di Astro selalu di-escape. Ini perbaikan besar dibanding <?= ?> di CI3.
  • set:html tidak di-escape. Ia jalur XSS-mu, dan kamu memang membutuhkannya untuk badan artikel.
  • Sanitasi HTML dari database dengan daftar-izin di sisi server, bukan di browser.
  • Sanitasi saat menyimpan dan saat menampilkan โ€” yang pertama untuk kinerja, yang kedua untuk keamanan.
  • Atribut berbahaya bukan cuma onerror: href="javascript:", srcdoc, dan style juga.

Yang sudah aman

---
const judul = '<script>alert(1)</script>';
---
<h1>{judul}</h1>

<!-- Keluarannya teks, bukan skrip: -->
<!-- <h1>&lt;script&gt;alert(1)&lt;/script&gt;</h1> -->

Semua interpolasi {} di Astro di-escape otomatis. Di CI3, <?= $judul ?> tidak โ€” kamu harus ingat menulis html_escape() setiap kali. Perpindahan ini saja sudah menutup mayoritas XSS tersimpan di portal berita, karena sebagian besarnya masuk lewat judul dan nama penulis.

Yang tidak aman, dan yang kamu butuhkan

<!-- Badan artikel dari database: HTML sungguhan, sengaja tidak di-escape -->
<div class="badan-artikel" set:html={artikel.isi} />

Kalau editor bisa menempelkan HTML apa pun โ€” dan di CMS berbasis WYSIWYG, mereka bisa โ€” atau kalau tabelmu pernah tersentuh SQL injection, maka artikel.isi bisa berisi apa saja.

Di portal berita ini bukan risiko hipotetis. Konten sering datang dari banyak sumber: editor internal, kontributor lepas, umpan sindikasi dari kantor berita, dan artikel warisan yang dimigrasikan dari sistem sebelum sistem CI3-mu. Salah satu di antaranya kemungkinan besar mengandung HTML yang tidak pernah ditinjau siapa pun.

Sanitasi di server

pnpm add sanitize-html
pnpm add -D @types/sanitize-html
// src/lib/sanitasi.ts
import sanitizeHtml from "sanitize-html";

const KONFIG: sanitizeHtml.IOptions = {
  allowedTags: [
    "p", "br", "strong", "em", "u", "s", "blockquote",
    "h2", "h3", "h4", "ul", "ol", "li",
    "a", "img", "figure", "figcaption",
    "table", "thead", "tbody", "tr", "th", "td",
    "iframe",                                    // untuk embed video
  ],
  allowedAttributes: {
    a: ["href", "title", "rel", "target"],
    img: ["src", "alt", "width", "height", "loading"],
    iframe: ["src", "width", "height", "allow", "allowfullscreen", "title"],
    "*": ["class"],
  },
  allowedSchemes: ["http", "https", "mailto"],   // "javascript" TIDAK ada
  allowedIframeHostnames: ["www.youtube.com", "www.youtube-nocookie.com", "player.vimeo.com"],

  transformTags: {
    // Tautan keluar: cegah tabnabbing
    a: (namaTag, atr) => ({
      tagName: "a",
      attribs: {
        ...atr,
        rel: "noopener noreferrer nofollow",
        ...(atr.href?.startsWith("http") && !atr.href.includes("portal.contoh.id")
          ? { target: "_blank" }
          : {}),
      },
    }),
    // Gambar: selalu malas, kecuali yang pertama
    img: (namaTag, atr) => ({
      tagName: "img",
      attribs: { ...atr, loading: "lazy", decoding: "async" },
    }),
  },
};

export const bersihkan = (kotor: string) => sanitizeHtml(kotor, KONFIG);

Daftar-izin, bukan daftar-larang. Mencoba memblokir <script>, onerror, onload, dan seterusnya adalah pertempuran yang tidak bisa dimenangkan โ€” daftar vektornya panjang dan bertambah. Daftar yang menyebut apa yang boleh aman secara bawaan: apa pun yang belum kamu pikirkan otomatis ditolak.

allowedSchemes yang tidak memuat javascript menutup <a href="javascript:โ€ฆ">, dan allowedIframeHostnames mencegah embed <iframe> ke domain penyerang.

Dua kali: saat menyimpan dan saat menampilkan

// Saat editor menyimpan
const bersih = bersihkan(input.isi);
await dbTulis.updateTable("artikel").set({ isi: bersih }).where("id", "=", id).execute();
// Saat menampilkan โ€” untuk artikel warisan yang masuk sebelum sanitasi ada
const isi = artikel.disanitasi ? artikel.isi : bersihkan(artikel.isi);
KapanUntuk apa
Saat menyimpanKinerja โ€” sanitasi sekali, bukan tiap tampilan
Saat menampilkanKeamanan โ€” melindungi 200 ribu artikel warisan

Kolom penanda disanitasi membuatmu bisa membersihkan arsip secara bertahap di latar belakang alih-alih dalam satu operasi besar โ€” dan sementara itu, artikel yang belum tersentuh tetap aman karena dibersihkan saat dirender.

Jalur lain yang sering terlewat

JalurRisikoPerbaikan
set:html pada JSON-LDJudul artikel memutus string JSONJSON.stringify sudah meng-escape; jangan menyusun string manual
Atribut dari data: href={a.url}javascript:alert(1)Validasi skema URL sebelum merender
Komentar pembacaXSS tersimpanSanitasi lebih ketat lagi โ€” tanpa iframe, tanpa img
Nama penulis dari sindikasiXSS tersimpanSudah aman lewat {}
Parameter pencarian yang digemakanXSS terpantulAman lewat {}; berbahaya kalau lewat set:html
SVG yang diunggahSVG bisa berisi <script>Sajikan sebagai <img>, jangan sisipkan sebaris
// Validasi URL sebelum dipakai sebagai href
export function urlAman(u: string | null): string | undefined {
  if (!u) return undefined;
  try {
    const url = new URL(u, "https://portal.contoh.id");
    return ["http:", "https:", "mailto:"].includes(url.protocol) ? url.href : undefined;
  } catch {
    return undefined;
  }
}

Uji

const MUATAN = [
  '<script>alert(1)</script>',
  '<img src=x onerror=alert(1)>',
  '<a href="javascript:alert(1)">klik</a>',
  '<iframe src="https://jahat.id"></iframe>',
  '<svg onload=alert(1)>',
  '<div style="background:url(javascript:alert(1))">',
  '<math><mtext><table><mglyph><style><!--</style><img src=x onerror=alert(1)>',
];

for (const m of MUATAN) {
  const hasil = bersihkan(m);
  console.assert(!/script|onerror|onload|javascript:/i.test(hasil), `LOLOS: ${m}`);
}

Muatan terakhir itu memanfaatkan cara parser HTML menangani konten asing (MathML/SVG) dan rutin melewati sanitizer buatan sendiri. Ini alasan memakai pustaka yang teruji alih-alih regex โ€” regex tidak mem-parsing HTML, dan HTML jauh lebih aneh daripada yang terlihat.

Latihan: pasang sanitize-html dengan konfigurasi di atas dan jalankan daftar muatan uji itu. Lalu ambil sepuluh artikel acak dari database produksimu, jalankan lewat sanitizer, dan diff hasilnya dengan aslinya โ€” apa pun yang berubah adalah HTML yang selama ini kamu render tanpa pemeriksaan.

Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.