JSON Toolkit

รายการตรวจสอบความเสี่ยงด้านความปลอดภัยในการประมวลผล JSON

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

ระดับกลางอ่านประมาณ 12 นาทีเผยแพร่ 2026-10-30อัปเดต 2026-10-30
ภาพปกสำหรับรายการตรวจสอบความเสี่ยงด้านความปลอดภัยในการประมวลผล JSON

สรุปสั้น: ปิดจุดเข้าสองจุดก่อน — การ 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