Checklist Risiko Keamanan untuk Pemrosesan JSON
Intinya: JSON.parse sendiri aman; bahayanya ada pada apa yang terjadi setelahnya: deep merge, assignment berdasarkan path, inline ke HTML, kedalaman dan ukuran tanpa batas.

Intinya: tutup dulu dua titik masuk — setiap deep merge dan assignment path harus menolak proto, prototype, dan constructor, dan ukuran request body plus kedalaman nesting membutuhkan batas keras yang berlaku sebelum parsing. Mengawasi JSON.parse sendiri berarti membiarkan pintu berbahaya terbuka lebar.
Penyerang tidak perlu menemukan kerentanan baru; mereka hanya menambahkan satu key lagi ke JSON.
1. Prototype Pollution: Yang Paling Umum dan Paling Terlewat
1.1 JSON.parse sendiri aman
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse membuat __proto__ sebagai own property biasa dan tidak menulis ulang rantai prototype. Jadi klaim bahwa "JSON.parse menyebabkan prototype pollution" tidak akurat.
1.2 Merge yang menyusul itulah yang berbahaya
/**
* Deep merge tidak aman: menulis key object sumber langsung ke object target.
* @param target Object target
* @param source Object sumber (bisa berasal dari JSON pengguna)
* @returns Object hasil merge
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — seluruh situs tercemar
constructor.prototype adalah titik masuk lain dari jenis yang sama:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 Cara memperbaikinya
/** Nama key yang harus ditolak */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Deep merge aman: menolak key terkait prototype dan hanya menangani own property.
* @param target Object target
* @param source Object sumber
* @returns Object hasil merge yang baru
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// Gunakan defineProperty agar setter tidak bisa memicu mutasi prototype
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
Pendekatan yang lebih menyeluruh adalah memakai Object.create(null) sebagai wadah (tidak ada rantai prototype yang bisa tercemar), atau cukup sebuah Map.
Saat memakai pustaka siap pakai, pastikan pustaka itu sudah menangani ketiga key tersebut — banyak versi lama merge lodash dilaporkan karena tepat masalah ini.
1.4 Assignment berdasarkan path sama berbahayanya
// Anti-pola: path berasal dari input pengguna
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
Ketika __proto__ muncul di pathSegments, ini setara dengan pencemaran di atas. Implementasi JSON Pointer / JSON Patch harus memvalidasi setiap segmen token.
2. Serangan Kehabisan Sumber Daya
| Serangan | Taktik | Mitigasi |
|---|---|---|
| Nesting dalam | Seratus ribu level [[[[...]]]] |
Batasi kedalaman nesting (biasanya 32–64) dan pindai awal sebelum parsing |
| Array kebesaran | Array dengan puluhan juta elemen | Batasi ukuran request body dan panjang array |
| String sangat panjang | Satu string sepanjang ratusan MB | Batasi panjang field |
| Ledakan key duplikat | Key duplikat dalam jumlah sangat banyak | Batasi jumlah key object |
Dua pertahanan paling efektif di server adalah batas ukuran request body dan batas kedalaman, dan keduanya harus berlaku sebelum parsing apa pun terjadi.
/**
* Memeriksa kedalaman nesting dengan murah sebelum parsing untuk mencegah stack overflow saat parsing.
* @param text Teks JSON
* @param maxDepth Kedalaman maksimum yang diizinkan
* @returns Apakah kedalaman masih dalam rentang yang diizinkan
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. Injection Saat Inline ke HTML
Menulis JSON langsung ke halaman adalah praktik umum, tetapi itu mendatangkan tiga kelas masalah:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
</script> di dalam string menutup blok script lebih awal, dan sisanya di-parse sebagai HTML — XSS klasik.
Dua karakter lagi berperilaku sama: \u2028 (line separator) dan \u2029 (paragraph separator) legal dalam JSON, tetapi menyematkannya di sumber JS memicu syntax error.
Perbaikan:
/**
* Menyerialkan JSON dengan aman untuk inline script.
* @param value Object data
* @returns String yang aman diletakkan di dalam tag script
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Pendekatan yang lebih kokoh adalah menaruh data di dalam <script type="application/json"> (yang isinya tidak dieksekusi), lalu membacanya dengan JSON.parse(document.getElementById('data').textContent).
4. JSON Hijacking dan JSONP
Browser lama mengizinkan <script src="https://api.example.com/user"> lintas origin untuk mengeksekusi JavaScript yang dikembalikan secara langsung,
dan JSON yang top level-nya berupa array kebetulan juga merupakan ekspresi JS yang valid, sehingga penyerang bisa membaca data dengan menimpa konstruktor Array.
Browser modern sudah punya perlindungan, tetapi kami tetap menyarankan:
- Gunakan object, bukan array, di top level API (
{"data": [...]}); - Kirim
X-Content-Type-Options: nosniffdan tetapkanContent-Type: application/jsonsecara ketat; - Hentikan penggunaan JSONP; gunakan CORS ketika akses lintas origin benar-benar dibutuhkan.
5. Type Confusion Saat Deserialisasi ke Object
Begitu default typing diaktifkan di Jackson (enableDefaultTyping), penyerang bisa menentukan field @class agar server membuat instance class sembarang,
dan menggabungkannya dengan gadget chain yang bisa dieksploitasi untuk mencapai remote code execution. Kerentanan jenis ini sudah muncul berkali-kali (Fastjson dan Jackson sama-sama pernah terkena).
Prinsip: tipe target deserialisasi harus ditentukan secara statis oleh kode server dan tidak boleh pernah ditentukan oleh isi payload.
6. Denial of Service di Regular Expression dan Schema
patternJSON Schema danmatch()JSONPath keduanya menerima regular expression;- Pola seperti
(a+)+$melakukan backtracking eksponensial pada input panjang; - Jika regex berasal dari pengguna (aturan validasi kustom), Anda harus membatasi panjangnya, melarang backreference dan quantifier bersarang, atau beralih ke engine linear seperti RE2.
7. Logging dan Masking
Log JSON adalah cara termudah membocorkan informasi sensitif:
/** Nama field yang harus di-mask di log (dicocokkan dalam huruf kecil) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Menyensor field sensitif secara rekursif dalam object untuk output log.
* @param value Nilai asli
* @returns Salinan yang sudah disensor
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
Perhatikan bahwa masking harus terjadi sebelum log ditulis, bukan dengan membersihkan platform logging setelahnya.
8. Checklist Sebelum Rilis
- [ ] Semua fungsi deep-merge menolak
__proto__/prototype/constructor; - [ ] Token path JSON Pointer / Patch divalidasi terhadap whitelist;
- [ ] Ukuran request body dan kedalaman nesting punya batas keras, dan batas itu berlaku sebelum parsing;
- [ ] JSON yang di-inline ke HTML di-escape untuk
<,>,&,U+2028, danU+2029; - [ ] API memakai object di top level dan respons mengirim
nosniff; - [ ] Tipe target deserialisasi ditentukan secara statis di kode;
- [ ] Regular expression yang dikendalikan pengguna punya batas kompleksitas;
- [ ] Field log punya aturan masking dan aturan itu diuji.
FAQ
Bagaimana cara memeriksa apakah deep merge saya rentan terhadap prototype pollution?
Masukkan JSON.parse('{"__proto__": {"isAdmin": true}}') ke fungsi merge, lalu periksa apakah {}.isAdmin berubah menjadi true; ulangi dengan payload constructor.prototype. Dengan pustaka pihak ketiga, pastikan ia menolak __proto__, prototype, dan constructor — banyak versi lama merge lodash dilaporkan karena tepat masalah ini. Assignment berbasis path butuh pemeriksaan sendiri, karena token __proto__ di path mencemari dengan cara yang sama.
Mengapa JSON.parse sendiri bukan sumber prototype pollution?
Ia membuat __proto__ sebagai own property biasa dan tidak pernah menulis ulang rantai prototype, sehingga data.polluted dan {}.polluted keduanya tetap undefined. Pencemaran terjadi setelahnya: deep merge yang tidak aman menulis key object sumber langsung ke target, dan constructor.prototype adalah titik masuk dari jenis yang sama. Menganggap JSON.parse sebagai akar masalah berarti memperbaiki tempat yang salah.
Apakah JSON yang di-inline ke HTML harus selalu di-escape?
Ya. </script> di dalam string menutup blok script lebih awal dan sisanya di-parse sebagai HTML — XSS klasik; <, >, &, U+2028, dan U+2029 juga perlu di-escape. Opsi yang lebih aman adalah menaruh data di dalam <script type="application/json">, yang isinya tidak dieksekusi, lalu membacanya dengan JSON.parse pada textContent.