JSON Toolkit

Danh sách kiểm tra rủi ro bảo mật khi xử lý JSON

Kết luận: Bản thân JSON.parse an toàn; nguy hiểm là những gì xảy ra sau đó: gộp sâu, gán theo đường dẫn, nhúng vào HTML, độ sâu và kích thước không giới hạn.

Trung cấpĐọc khoảng 12 phútXuất bản 2026-10-30Cập nhật 2026-10-30
Ảnh bìa cho Danh sách kiểm tra rủi ro bảo mật khi xử lý JSON

Kết luận: hãy đóng hai điểm vào trước — mọi lần gộp sâu và gán theo đường dẫn phải từ chối proto, prototype và constructor, còn kích thước thân yêu cầu cùng độ sâu lồng nhau cần giới hạn cứng áp dụng trước khi phân tích. Chỉ nhìn vào JSON.parse sẽ để những cánh cửa nguy hiểm mở toang.

Kẻ tấn công không cần phát minh lỗ hổng mới; họ chỉ thêm một khóa nữa vào JSON.

1. Ô nhiễm prototype: phổ biến nhất và bị bỏ qua nhất

1.1 Bản thân JSON.parse an toàn

const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted);        // undefined
console.log({}.polluted);          // undefined

JSON.parse tạo __proto__ như một thuộc tính riêng thông thường và không ghi lại chuỗi prototype. Vậy nên tuyên bố "JSON.parse gây ô nhiễm prototype" là không chính xác.

1.2 Thứ nguy hiểm là lần gộp diễn ra sau đó

/**
 * Unsafe deep merge: writes the source object's keys straight into the target object.
 * @param target Target object
 * @param source Source object (may come from user JSON)
 * @returns Merged object
 */
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 — the entire site is polluted

constructor.prototype là một điểm vào khác cùng loại:

{ "constructor": { "prototype": { "isAdmin": true } } }

1.3 Cách sửa

/** Key names that must be rejected */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

/**
 * Safe deep merge: rejects prototype-related keys and handles own properties only.
 * @param target Target object
 * @param source Source object
 * @returns New merged object
 */
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;
    }

    // Use defineProperty so a setter cannot trigger prototype mutation
    Object.defineProperty(result, key, {
      value: sourceValue,
      enumerable: true,
      writable: true,
      configurable: true,
    });
  }

  return result;
}

Một cách triệt để hơn là dùng Object.create(null) làm vật chứa (không có chuỗi prototype để ô nhiễm), hoặc đơn giản là một Map. Khi dùng thư viện có sẵn, hãy xác nhận nó đã xử lý ba khóa này — nhiều phiên bản lodash merge cũ từng bị báo lỗi đúng vì lý do này.

1.4 Gán theo đường dẫn cũng nguy hiểm không kém

// Anti-pattern: the path comes from user input
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

Khi __proto__ xuất hiện trong pathSegments, điều này tương đương với việc ô nhiễm ở trên. Các hiện thực JSON Pointer / JSON Patch phải kiểm tra từng đoạn token.

2. Tấn công làm cạn kiệt tài nguyên

Tấn công Chiêu thức Cách giảm thiểu
Lồng quá sâu Hàng trăm nghìn cấp [[[[...]]]] Giới hạn độ sâu lồng nhau (thường 32–64) và quét trước khi phân tích
Mảng quá lớn Một mảng có hàng chục triệu phần tử Giới hạn kích thước thân yêu cầu và độ dài mảng
Chuỗi cực dài Một chuỗi dài hàng trăm MB Giới hạn độ dài trường
Bùng nổ khóa trùng lặp Số lượng khóa trùng lặp rất lớn Giới hạn số khóa của object

Hai biện pháp phòng thủ hiệu quả nhất trên máy chủ là giới hạn kích thước thân yêu cầu và giới hạn độ sâu, và cả hai phải có hiệu lực trước khi bất kỳ việc phân tích nào diễn ra.

/**
 * Cheaply checks nesting depth before parsing to prevent stack overflow during parsing.
 * @param text JSON text
 * @param maxDepth Maximum allowed depth
 * @returns Whether the depth is within the allowed range
 */
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. Chèn mã khi nhúng vào HTML

Việc ghi JSON trực tiếp vào trang là thực hành phổ biến, nhưng nó mang lại ba loại vấn đề:

<script>
  window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>

</script> bên trong chuỗi đóng khối script sớm, và phần còn lại được phân tích như HTML — một lỗi XSS kinh điển.

Hai ký tự nữa cũng hành xử tương tự: \u2028 (ký tự phân tách dòng) và \u2029 (ký tự phân tách đoạn) hợp lệ trong JSON, nhưng nhúng chúng vào mã nguồn JS sẽ gây lỗi cú pháp.

Cách sửa:

/**
 * Serializes JSON safely for an inline script.
 * @param value Data object
 * @returns String that can be safely placed inside a script tag
 */
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');
}

Một cách vững chắc hơn là đặt dữ liệu vào <script type="application/json"> (nội dung của nó không được thực thi), rồi đọc bằng JSON.parse(document.getElementById('data').textContent).

4. Chiếm đoạt JSON và JSONP

Các trình duyệt thời đầu cho phép một <script src="https://api.example.com/user"> khác nguồn thực thi trực tiếp JavaScript trả về, và JSON có cấp cao nhất là mảng tình cờ cũng là một biểu thức JS hợp lệ, nên kẻ tấn công có thể đọc dữ liệu bằng cách ghi đè hàm dựng Array.

Trình duyệt hiện đại đã có các biện pháp bảo vệ, nhưng chúng tôi vẫn khuyến nghị:

  • Dùng object thay vì mảng ở cấp cao nhất của API ({"data": [...]});
  • Gửi X-Content-Type-Options: nosniff và đặt Content-Type: application/json một cách nghiêm ngặt;
  • Ngừng dùng JSONP; dùng CORS khi thật sự cần truy cập khác nguồn.

5. Nhầm lẫn kiểu khi giải tuần tự thành object

Khi bật kiểu mặc định trong Jackson (enableDefaultTyping), kẻ tấn công có thể chỉ định trường @class để buộc máy chủ khởi tạo một lớp tùy ý, và kết hợp với một chuỗi gadget có thể khai thác để đạt được thực thi mã từ xa. Lỗ hổng loại này đã xuất hiện nhiều lần (cả Fastjson lẫn Jackson đều từng bị).

Nguyên tắc: kiểu đích của việc giải tuần tự phải được chỉ định tĩnh bởi mã phía máy chủ và không bao giờ được quyết định bởi nội dung payload.

6. Từ chối dịch vụ trong biểu thức chính quy và lược đồ

  • pattern của JSON Schema và match() của JSONPath đều nhận biểu thức chính quy;
  • Các mẫu như (a+)+$ quay lui theo cấp số nhân trên đầu vào dài;
  • Nếu regex đến từ người dùng (quy tắc kiểm tra tùy biến), bạn phải giới hạn độ dài của nó, cấm backreference và lượng từ lồng nhau, hoặc chuyển sang engine tuyến tính như RE2.

7. Ghi nhật ký và che dữ liệu

Nhật ký JSON là cách dễ nhất để rò rỉ thông tin nhạy cảm:

/** Field names that must be masked in logs (matched in lowercase) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);

/**
 * Recursively masks sensitive fields in an object for log output.
 * @param value Original value
 * @returns Masked copy
 */
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;
}

Lưu ý rằng việc che dữ liệu phải diễn ra trước khi nhật ký được ghi, không phải bằng cách dọn dẹp nền tảng ghi nhật ký về sau.

8. Danh sách kiểm tra trước khi phát hành

  • [ ] Mọi hàm gộp sâu từ chối __proto__ / prototype / constructor;
  • [ ] Các token đường dẫn của JSON Pointer / Patch được kiểm tra theo danh sách trắng;
  • [ ] Kích thước thân yêu cầu và độ sâu lồng nhau có giới hạn cứng, và các giới hạn đó áp dụng trước khi phân tích;
  • [ ] JSON nhúng vào HTML escape <, >, &, U+2028 và U+2029;
  • [ ] API dùng object ở cấp cao nhất và phản hồi gửi nosniff;
  • [ ] Kiểu đích của việc giải tuần tự được chỉ định tĩnh trong mã;
  • [ ] Biểu thức chính quy do người dùng kiểm soát có giới hạn độ phức tạp;
  • [ ] Các trường trong nhật ký có quy tắc che dữ liệu và các quy tắc đó được kiểm thử.

FAQ

Làm sao kiểm tra hàm gộp sâu của tôi có dễ bị ô nhiễm prototype không?

Đưa JSON.parse('{"__proto__": {"isAdmin": true}}') vào hàm gộp, rồi kiểm tra xem {}.isAdmin có biến thành true không; lặp lại với payload constructor.prototype. Với thư viện bên thứ ba, hãy xác nhận nó từ chối __proto__, prototype và constructor — nhiều phiên bản lodash merge cũ từng bị báo lỗi đúng vì lý do này. Việc gán theo đường dẫn cần kiểm tra riêng, vì một token __proto__ trong đường dẫn ô nhiễm theo cùng cách.

Tại sao bản thân JSON.parse không phải nguồn gốc của ô nhiễm prototype?

Nó tạo __proto__ như một thuộc tính riêng thông thường và không bao giờ ghi lại chuỗi prototype, nên cả data.polluted lẫn {}.polluted đều giữ undefined. Việc ô nhiễm xảy ra sau đó: một lần gộp sâu không an toàn ghi khóa của object nguồn thẳng vào đích, và constructor.prototype là một điểm vào cùng loại. Coi JSON.parse là nguyên nhân gốc nghĩa là sửa sai chỗ.

JSON nhúng vào HTML có phải luôn escape không?

Có. Một </script> bên trong chuỗi đóng khối script sớm và phần còn lại được phân tích như HTML — một lỗi XSS kinh điển; <, >, &, U+2028 và U+2029 cũng cần escape. Một lựa chọn an toàn hơn là đặt dữ liệu vào <script type="application/json">, nội dung của nó không được thực thi, và đọc bằng JSON.parse trên textContent.