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.
Intisari
{ekspresi}di Astro selalu di-escape. Ini perbaikan besar dibanding<?= ?>di CI3.set:htmltidak 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, danstylejuga.
Yang sudah aman
---
const judul = '<script>alert(1)</script>';
---
<h1>{judul}</h1>
<!-- Keluarannya teks, bukan skrip: -->
<!-- <h1><script>alert(1)</script></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);
| Kapan | Untuk apa |
|---|---|
| Saat menyimpan | Kinerja โ sanitasi sekali, bukan tiap tampilan |
| Saat menampilkan | Keamanan โ 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
| Jalur | Risiko | Perbaikan |
|---|---|---|
set:html pada JSON-LD | Judul artikel memutus string JSON | JSON.stringify sudah meng-escape; jangan menyusun string manual |
Atribut dari data: href={a.url} | javascript:alert(1) | Validasi skema URL sebelum merender |
| Komentar pembaca | XSS tersimpan | Sanitasi lebih ketat lagi โ tanpa iframe, tanpa img |
| Nama penulis dari sindikasi | XSS tersimpan | Sudah aman lewat {} |
| Parameter pencarian yang digemakan | XSS terpantul | Aman lewat {}; berbahaya kalau lewat set:html |
| SVG yang diunggah | SVG 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.