Prototype pollution adalah bug khas JavaScript: sebuah fungsi merge menyalin key dari data user tanpa disaring, dan key bernama __proto__ membuatnya menulis ke cetak biru bersama semua object, bukan ke object tujuannya. Bayangin satu kantor yang semua karyawannya pakai satu buku peraturan bersama. Kalau seseorang menyelipkan satu baris "semua orang boleh masuk ruang server" ke buku itu, aturan itu berlaku buat semua karyawan sekaligus, termasuk yang baru masuk besok.
🧪 Tebakan cepat: sebuah deep merge yang gak menyaring key menerima
{"__proto__": {"isAdmin": true}}. Setelah itu,({}).isAdmin, object kosong yang baru dibuat, nilainya apa? Tebak dulu, jawabannya ada di bagian "Merge yang gak menyaring key".
Prototype: buku peraturan bersama
Setiap object biasa di JavaScript punya prototype, object lain tempat dia "meminjam" property yang gak dia punya sendiri. Untuk object biasa, prototype-nya adalah Object.prototype, dan semua object biasa memakai prototype yang sama itu.
const user = {};
console.log(user.toString); // function: dipinjam dari Object.prototype
console.log(Object.getPrototypeOf(user) === Object.prototype); // true
console.log(user.isAdmin); // undefined: gak ada di mana pun
- Kalau kamu membaca
user.toString, JS mencari diuserdulu. Gak ketemu, dia lanjut ke prototype-nya,Object.prototype, dan ketemu di sana. __proto__adalah jalan pintas lama untuk prototype itu. Menulisobj.__proto__ = xgak membuat key biasa, tapi mengganti prototypeobj.- Jadi kalau ada yang menambah property ke
Object.prototype, semua object biasa ikut "punya" property itu.
Merge yang gak menyaring key
Fungsi deep merge menyalin isi satu object ke object lain, termasuk object di dalamnya. Versi naif ini menyalin semua key tanpa bertanya:
// RENTAN: semua key disalin, termasuk "__proto__"
function deepMerge(target, source) {
for (const key in source) {
if (typeof source[key] === "object") {
target[key] = target[key] || {};
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const settings = {};
deepMerge(settings, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log(({}).isAdmin); // true
Ikuti langkahnya dengan input dari user tadi:
| Langkah | Yang terjadi |
|---|---|
key = "__proto__" |
target[key] membaca prototype settings, yaitu Object.prototype |
deepMerge(Object.prototype, {isAdmin: true}) |
merge masuk ke buku peraturan bersama |
key = "isAdmin" |
Object.prototype.isAdmin = true |
Hasilnya ({}).isAdmin bernilai true. Itu jawaban tebakannya. Object kosong yang baru dibuat ikut "punya" isAdmin: true, jadi kode seperti if (user.isAdmin) bisa meloloskan siapa saja. Key constructor dan prototype juga bisa membuka jalan yang sama (obj.constructor.prototype adalah Object.prototype), jadi ketiganya disaring.
Perbaikan 1: lewati key berbahaya
Periksa tiap key sebelum disalin, dan lewati yang ada di daftar terlarang dengan continue:
function safeMerge(target, source) {
const blocked = ["__proto__", "constructor", "prototype"];
for (const key of Object.keys(source)) {
if (blocked.includes(key)) {
continue; // dilewati, gak pernah disalin
}
target[key] = source[key];
}
return target;
}
console.log(safeMerge({ a: 1 }, { b: 2 })); // { a: 1, b: 2 }
safeMerge({}, JSON.parse('{"__proto__": {"polluted": true}}'));
console.log(({}).polluted); // undefined
Object.keys(source)mengambil key miliksourcesendiri saja, gak ikut key warisan.blocked.includes(key)mengecek apakah key ada di daftar terlarang. Kalau iya,continuelangsung loncat ke key berikutnya.- Key lain disalin biasa, lalu
targetdikembalikan. Object kosong baru tetap bersih:({}).pollutedbernilaiundefined.
Perbaikan 2: wadah tanpa prototype
Cara lain: simpan data dari user di wadah yang memang gak punya prototype, jadi gak ada buku bersama yang bisa dicemari.
const store = Object.create(null); // object tanpa prototype
store.theme = "dark";
console.log(store.toString); // undefined: gak mewarisi apa pun
const prefs = new Map(); // Map: key apa pun cuma jadi key biasa
prefs.set("__proto__", { isAdmin: true });
console.log(({}).isAdmin); // undefined
Object.create(null)membuat object tanpa prototype. Menulis key__proto__ke dalamnya cuma menambah key biasa.Mapmenyimpan key lewatsetdanget, gak lewat property, jadi"__proto__"cuma teks.- Lapisan tambahan: validasi body dengan schema (misalnya Zod) yang menolak key yang gak dikenal, dan update library merge lama (lodash di bawah 4.17.12 pernah rentan).
⚠️ Jebakan yang sering ketemu
- Menyaring setelah menyalin. Menghapus
__proto__setelahtarget[key] = ...sudah terlambat. Cek dulu, baru salin. - Cuma memblokir
__proto__.constructordanprototypejuga bisa membuka jalan ke prototype. Blokir ketiganya. for...intanpaObject.keys.for...inikut menelusuri key warisan.Object.keyshanya key milik object itu.- Mengira merge dangkal sudah aman. Merge dangkal yang menyalin
__proto__memang gak mengubahObject.prototype, tapi dia mengganti prototype object tujuannya sendiri, jaditarget.pollutedtiba-tibatrue. Tetap lewati key-nya.
TL;DR: Semua object biasa meminjam property dari satu prototype bersama, Object.prototype. Deep merge yang menyalin semua key membiarkan {"__proto__": {"isAdmin": true}} menulis ke sana, jadi ({}).isAdmin bernilai true untuk semua object. Cegah dengan memeriksa tiap key sebelum disalin: for (const key of Object.keys(source)), lalu if (blocked.includes(key)) continue; untuk __proto__, constructor, prototype. Untuk data dari user, pakai wadah tanpa prototype: Object.create(null) atau Map.