รายการตรวจสอบความเสี่ยงด้านความปลอดภัยในการประมวลผล JSON
สรุปสำคัญ: JSON.parse เองปลอดภัย อันตรายอยู่ที่สิ่งที่เกิดขึ้นหลังจากนั้น: การ deep merge การกำหนดค่าตาม path การฝังลงใน HTML ความลึกและขนาดที่ไม่จำกัด

สรุปสั้น: ปิดจุดเข้าสองจุดก่อน — การ deep merge และการกำหนดค่าตาม path ทุกครั้งต้องปฏิเสธ proto, prototype และ constructor และขนาด request body รวมถึงความลึกของการซ้อนต้องมีขีดจำกัดแข็งที่บังคับใช้ก่อนการแยกวิเคราะห์ การเฝ้าดู JSON.parse เองนั้นปล่อยให้ประตูอันตรายเปิดกว้าง
ผู้โจมตีไม่จำเป็นต้องประดิษฐ์ช่องโหว่ใหม่ พวกเขาเพียงเพิ่มคีย์อีกหนึ่งตัวลงใน JSON
1. Prototype Pollution: พบบ่อยที่สุดและถูกละเลยที่สุด
1.1 JSON.parse เองปลอดภัย
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse สร้าง __proto__ เป็น own property ธรรมดา และไม่เขียน prototype chain ใหม่ ดังนั้นคำกล่าวที่ว่า "JSON.parse ทำให้เกิด prototype pollution" นั้นไม่ถูกต้อง
1.2 การ merge ที่ตามมาคือสิ่งที่อันตราย
/**
* Deep merge ที่ไม่ปลอดภัย: เขียนคีย์ของ source ลงใน target โดยตรง
* @param target ออบเจ็กต์เป้าหมาย
* @param source ออบเจ็กต์ต้นทาง (อาจมาจาก JSON ของผู้ใช้)
* @returns ออบเจ็กต์ที่ 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 — ทั้งไซต์ถูกปนเปื้อน
constructor.prototype เป็นจุดเข้าอีกจุดหนึ่งในแบบเดียวกัน:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 วิธีแก้ไข
/** ชื่อคีย์ที่ต้องปฏิเสธ */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Deep merge ที่ปลอดภัย: ปฏิเสธคีย์ที่เกี่ยวข้องกับ prototype และจัดการเฉพาะ own property
* @param target ออบเจ็กต์เป้าหมาย
* @param source ออบเจ็กต์ต้นทาง
* @returns ออบเจ็กต์ที่ merge ใหม่
*/
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;
}
// ใช้ defineProperty เพื่อไม่ให้ setter กระตุ้นการกลายพันธุ์ของ prototype
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
แนวทางที่ละเอียดกว่าคือใช้ Object.create(null) เป็นคอนเทนเนอร์ (ไม่มี prototype chain ให้ปนเปื้อน) หรือใช้ Map ง่าย ๆ
เมื่อใช้ไลบรารีสำเร็จรูป ให้ยืนยันว่ามันจัดการคีย์ทั้งสามนี้แล้ว — lodash merge หลายเวอร์ชันเก่าถูกรายงานด้วยเหตุผลนี้พอดี
1.4 การกำหนดค่าตาม path ก็อันตรายพอ ๆ กัน
// แพตเทิร์นต้าน: path มาจากอินพุตของผู้ใช้
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
เมื่อ __proto__ ปรากฏใน pathSegments สิ่งนี้เทียบเท่ากับการปนเปื้อนข้างต้น การใช้งาน JSON Pointer / JSON Patch ต้องตรวจสอบทุก token segment
2. การโจมตีที่ทำให้ทรัพยากรหมด
| การโจมตี | กลยุทธ์ | การป้องกัน |
|---|---|---|
| การซ้อนลึก | [[[[...]]]] หนึ่งแสนระดับ |
จำกัดความลึกการซ้อน (ปกติ 32–64) และสแกนล่วงหน้าก่อนแยกวิเคราะห์ |
| อาร์เรย์ขนาดใหญ่เกิน | อาร์เรย์ที่มีสมาชิกหลายสิบล้านตัว | จำกัดขนาด request body และความยาวอาร์เรย์ |
| สตริงยาวมาก | สตริงเดียวขนาดหลายร้อย MB | จำกัดความยาวฟิลด์ |
| คีย์ซ้ำระเบิด | คีย์ซ้ำจำนวนมหาศาล | จำกัดจำนวนคีย์ของออบเจ็กต์ |
การป้องกันสองข้อที่มีประสิทธิผลที่สุดบนเซิร์ฟเวอร์คือ ขีดจำกัดขนาด request body และ ขีดจำกัดความลึก และทั้งสองต้องมีผลก่อนการแยกวิเคราะห์ใด ๆ เกิดขึ้น
/**
* ตรวจความลึกการซ้อนก่อนแยกวิเคราะห์อย่างประหยัด เพื่อป้องกัน stack overflow ระหว่างแยกวิเคราะห์
* @param text ข้อความ JSON
* @param maxDepth ความลึกสูงสุดที่อนุญาต
* @returns ว่าความลึกอยู่ในช่วงที่อนุญาตหรือไม่
*/
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. การฉีดโค้ดเมื่อฝังลงใน HTML
การเขียน JSON ลงในหน้าเว็บโดยตรงเป็นวิธีที่พบบ่อย แต่มันนำปัญหาสามประเภทเข้ามา:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
</script> ภายในสตริงปิดบล็อก script ก่อนเวลา และส่วนที่เหลือถูกแยกวิเคราะห์เป็น HTML — XSS คลาสสิก
อักขระอีกสองตัวทำงานแบบเดียวกัน: \u2028 (line separator) และ \u2029 (paragraph separator) ถูกต้องตามกฎหมายใน JSON แต่การฝังในซอร์ส JS ทำให้เกิด syntax error
วิธีแก้:
/**
* ทำให้ JSON เป็นอนุกรมอย่างปลอดภัยสำหรับ inline script
* @param value ออบเจ็กต์ข้อมูล
* @returns สตริงที่วางในแท็ก 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');
}
แนวทางที่แข็งแรงกว่าคือใส่ข้อมูลใน <script type="application/json"> (เนื้อหาของมันไม่ถูกเรียกใช้) แล้วอ่านด้วย JSON.parse(document.getElementById('data').textContent)
4. JSON Hijacking และ JSONP
เบราว์เซอร์รุ่นแรกอนุญาตให้ <script src="https://api.example.com/user"> ข้ามโดเมนเรียกใช้ JavaScript ที่ส่งกลับมาโดยตรง
และ JSON ที่ระดับบนสุดเป็นอาร์เรย์ก็เป็นนิพจน์ JS ที่ถูกต้องเช่นกัน ผู้โจมตีจึงสามารถอ่านข้อมูลได้โดยเขียนทับคอนสตรัคเตอร์ Array
เบราว์เซอร์สมัยใหม่มีการป้องกันอยู่แล้ว แต่เรายังแนะนำว่า:
- ใช้เป็นออบเจ็กต์แทนอาร์เรย์ที่ระดับบนสุดของ API (
{"data": [...]}); - ส่ง
X-Content-Type-Options: nosniffและกำหนดContent-Type: application/jsonอย่างเคร่งครัด; - เลิกใช้ JSONP; ใช้ CORS เมื่อต้องการเข้าถึงข้ามโดเมนจริง ๆ
5. Type Confusion เมื่อดีซีเรียลไลซ์เป็นออบเจ็กต์
เมื่อเปิด default typing ใน Jackson (enableDefaultTyping) ผู้โจมตีสามารถระบุฟิลด์ @class เพื่อให้เซิร์ฟเวอร์สร้างอินสแตนซ์ของคลาสใดก็ได้
และรวมกับ gadget chain ที่ใช้ประโยชน์ได้เพื่อให้เกิด remote code execution ช่องโหว่ประเภทนี้ปรากฏหลายครั้ง (ทั้ง Fastjson และ Jackson เคยโดน)
หลักการ: ชนิดเป้าหมายของการดีซีเรียลไลซ์ต้องถูก ระบุแบบคงที่ โดยโค้ดฝั่งเซิร์ฟเวอร์ และต้องไม่ถูกกำหนดโดยเนื้อหา payload
6. Denial of Service ใน regular expression และ schema
patternของ JSON Schema และmatch()ของ JSONPath ต่างรับ regular expression;- รูปแบบอย่าง
(a+)+$จะ backtrack แบบเอ็กซ์โพเนนเชียลบนอินพุตยาว; - หาก regex มาจากผู้ใช้ (กฎการตรวจสอบกำหนดเอง) ต้องจำกัดความยาว ห้าม backreference และ nested quantifier หรือเปลี่ยนไปใช้เอนจินเชิงเส้นอย่าง RE2
7. การบันทึก log และการปกปิดข้อมูล
Log JSON เป็นวิธีที่ง่ายที่สุดในการรั่วไหลข้อมูลอ่อนไหว:
/** ชื่อฟิลด์ที่ต้องปกปิดใน log (จับคู่เป็นตัวพิมพ์เล็ก) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* ปกปิดฟิลด์อ่อนไหวในออบเจ็กต์แบบเรียกซ้ำสำหรับเอาต์พุต log
* @param value ค่าต้นฉบับ
* @returns สำเนาที่ถูกปกปิด
*/
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;
}
โปรดทราบว่า การปกปิดต้องเกิดขึ้น ก่อนเขียน log ไม่ใช่ด้วยการทำความสะอาดแพลตฟอร์ม log ภายหลัง
8. รายการตรวจสอบก่อนปล่อยจริง
- [ ] ฟังก์ชัน deep-merge ทั้งหมดปฏิเสธ
__proto__/prototype/constructor; - [ ] token path ของ JSON Pointer / Patch ถูกตรวจสอบกับ whitelist;
- [ ] ขนาด request body และความลึกการซ้อนมีขีดจำกัดแข็ง และขีดจำกัดเหล่านั้นมีผลก่อนแยกวิเคราะห์;
- [ ] JSON ที่ฝังใน HTML escape
<,>,&,U+2028และU+2029; - [ ] API ใช้เป็นออบเจ็กต์ที่ระดับบนสุด และ response ส่ง
nosniff; - [ ] ชนิดเป้าหมายของการดีซีเรียลไลซ์ถูกระบุแบบคงที่ในโค้ด;
- [ ] regular expression ที่ผู้ใช้ควบคุมได้มีขีดจำกัดความซับซ้อน;
- [ ] ฟิลด์ log มีกฎการปกปิดและกฎเหล่านั้นถูกทดสอบ
FAQ
จะตรวจได้อย่างไรว่า deep merge ของฉันมีช่องโหว่ prototype pollution?
ป้อน JSON.parse('{"__proto__": {"isAdmin": true}}') เข้าไปในฟังก์ชัน merge แล้วตรวจว่า {}.isAdmin กลายเป็น true หรือไม่ ทำซ้ำด้วย payload constructor.prototype หากใช้ไลบรารีของบุคคลที่สาม ให้ยืนยันว่ามันปฏิเสธ __proto__, prototype และ constructor — lodash merge หลายเวอร์ชันเก่าถูกรายงานด้วยเหตุผลนี้พอดี การกำหนดค่าตาม path ต้องตรวจของตัวเอง เพราะ token __proto__ ใน path ปนเปื้อนแบบเดียวกัน
ทำไม JSON.parse เองจึงไม่ใช่ต้นเหตุของ prototype pollution?
มันสร้าง __proto__ เป็น own property ธรรมดาและไม่เคยเขียน prototype chain ใหม่ ดังนั้นทั้ง data.polluted และ {}.polluted จึงยังเป็น undefined การปนเปื้อนเกิดขึ้นภายหลัง: การ deep merge ที่ไม่ปลอดภัยเขียนคีย์ของ source ลงใน target โดยตรง และ constructor.prototype เป็นจุดเข้าแบบเดียวกัน การมอง JSON.parse เป็นต้นเหตุหมายถึงการแก้ผิดที่
JSON ที่ฝังใน HTML ต้อง escape เสมอหรือไม่?
ใช่ </script> ภายในสตริงปิดบล็อก script ก่อนเวลาและส่วนที่เหลือถูกแยกวิเคราะห์เป็น HTML — XSS คลาสสิก; <, >, &, U+2028 และ U+2029 ก็ต้อง escape เช่นกัน ทางเลือกที่ปลอดภัยกว่าคือใส่ข้อมูลใน <script type="application/json"> ซึ่งเนื้อหาไม่ถูกเรียกใช้ แล้วอ่านด้วย JSON.parse บน textContent