JSON 工具箱

JSON 處理中的安全風險清單

結論:JSON.parse 本身很安全,危險的是它之後發生的事:深合併、按路徑賦值、內聯到 HTML、無限制的深度與體積。

進階約 12 分鐘發佈於 2026-10-30更新於 2026-10-30
JSON 處理中的安全風險清單封面

結論:先堵住兩個入口——所有深合併與按路徑賦值必須拒絕 proto、prototype、constructor,請求體大小與巢狀深度必須在解析前就有硬限制;只盯著 JSON.parse 本身,等於把最危險的入口留在原地。

攻擊者不需要發明新漏洞,只要在 JSON 裡多寫一個鍵。

一、原型汙染:最常見也最容易被忽視

1.1 JSON.parse 本身是安全的

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

JSON.parse 會把 __proto__ 當成普通自有屬性建立,不會改寫原型鏈。所以「JSON.parse 導致原型汙染」這個說法不準確。

1.2 危險的是後續的合併

/**
 * 不安全的深合併:直接把源物件的鍵寫進目標物件。
 * @param target 目標物件
 * @param source 來源物件(可能來自使用者 JSON)
 * @returns 合併後的物件
 */
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']);

/**
 * 安全深合併:拒絕原型相關鍵,只處理自有屬性。
 * @param target 目標物件
 * @param source 來源物件
 * @returns 合併後的新物件
 */
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 觸發原型修改
    Object.defineProperty(result, key, {
      value: sourceValue,
      enumerable: true,
      writable: true,
      configurable: true,
    });
  }

  return result;
}

更徹底的做法是用 Object.create(null) 作為容器(沒有原型鏈可汙染),或直接用 Map。 使用現成庫時,確認它已處理這三個鍵——很多老版本 lodash 的 merge 曾因此被通報。

1.4 按路徑賦值同樣危險

// 反例:路徑來自使用者輸入
for (const segment of pathSegments) {
  current = current[segment] ?? (current[segment] = {});
}

當 pathSegments 裡出現 __proto__ 時,等同於上面的汙染。JSON Pointer / JSON Patch 的實現必須校驗每一段令牌。

二、資源耗盡型攻擊

攻擊 手段 防護
深層巢狀 十萬層 [[[[...]]]] 限制巢狀深度(通常 32~64),解析前預掃
超大陣列 千萬個元素的陣列 限制請求體大小、限制陣列長度
超長字串 單個字串幾百 MB 限制欄位長度
重複鍵爆炸 大量重複鍵 限制物件鍵數量

服務端最有效的兩道防線是請求體大小限制與深度限制,兩者都要在任何解析之前生效。

/**
 * 在解析前粗查巢狀深度,防止解析階段棧溢位。
 * @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;
}

三、內聯到 HTML 時的注入

把 JSON 直接寫進頁面是常見做法,但它引入三類問題:

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

字串裡的 </script> 會提前閉合指令碼塊,後面的內容被當作 HTML 解析——典型的 XSS。

另外兩個字元:\u2028(行分隔符)與 \u2029(段分隔符)在 JSON 裡合法,但嵌進 JS 原始碼會觸發語法錯誤。

修復:

/**
 * 把 JSON 安全地序列化進內聯指令碼。
 * @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) 讀取。

四、JSON 劫持與 JSONP

早期瀏覽器允許跨域 <script src="https://api.example.com/user"> 直接執行返回的 JavaScript, 而頂層是陣列的 JSON 恰好也是合法 JS 表示式,攻擊者可以透過重寫 Array 建構函式讀取資料。

現代瀏覽器已有防護,但仍建議:

  • 介面頂層使用物件而非陣列({"data": [...]});
  • 響應頭帶 X-Content-Type-Options: nosniff,並嚴格設定 Content-Type: application/json;
  • 不再使用 JSONP;確有跨域需求用 CORS。

五、反序列化到物件的型別混淆

Java 生態的 Jackson 開啟預設型別(enableDefaultTyping)後,攻擊者可以指定 @class 欄位讓服務端例項化任意類, 配合可利用的 gadget 鏈達成遠端程式碼執行。這類漏洞歷史上多次出現(Fastjson、Jackson 都中過招)。

原則:反序列化的目標型別必須由服務端程式碼靜態指定,絕不允許由報文內容決定。

六、正則與 schema 的拒絕服務

  • JSON Schema 的 pattern、JSONPath 的 match() 都接受正則;
  • (a+)+$ 這類寫法在長輸入上會指數回溯;
  • 若正則來自使用者(自定義校驗規則),必須限制長度、禁用回溯引用與巢狀量詞,或改用 RE2 這類線性引擎。

七、日誌與脫敏

JSON 日誌最容易洩露敏感資訊:

/** 日誌中必須脫敏的欄位名(小寫匹配) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);

/**
 * 遞迴脫敏物件中的敏感欄位,用於日誌輸出。
 * @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;
}

注意脫敏要在寫日誌之前做,而不是事後清理日誌平臺。

八、上線前檢查清單

  • [ ] 所有深合併函式拒絕 __proto__ / prototype / constructor;
  • [ ] JSON Pointer / Patch 的路徑令牌做白名單校驗;
  • [ ] 請求體大小與巢狀深度有硬限制,且在解析前生效;
  • [ ] 內聯到 HTML 的 JSON 做了 <、>、&、U+2028、U+2029 轉義;
  • [ ] 介面頂層使用物件,響應頭帶 nosniff;
  • [ ] 反序列化目標型別由程式碼靜態指定;
  • [ ] 使用者可控的正則有複雜度限制;
  • [ ] 日誌欄位有脫敏規則並做了測試。

常見問題

如何快速確認自己的深合併函式有沒有原型汙染入口?

把 JSON.parse('{"__proto__": {"isAdmin": true}}') 的結果餵給合併函式,隨後檢查 {}.isAdmin 是否變成 true;再把 constructor.prototype 那條也試一遍。用現成庫時,確認它是否拒絕 __proto__、prototype、constructor 三個鍵——很多老版本 lodash 的 merge 曾因此被通報。按路徑賦值的實現要單獨檢查,路徑令牌裡出現 __proto__ 時同樣會中招。

為什麼 JSON.parse 本身不會造成原型汙染?

它會把 __proto__ 當成普通自有屬性建立,不會改寫原型鏈,所以 data.polluted 和 {}.polluted 都是 undefined。汙染發生在解析之後:不安全的深合併把來源物件的鍵直接寫進目標物件,constructor.prototype 是同類入口。把「JSON.parse 導致原型汙染」當成根因,會修錯地方。

內聯 JSON 到 HTML 時是否必須轉義?

必須。字串裡的 </script> 會提前閉合指令碼塊,後面的內容被當作 HTML 解析,這是典型的 XSS;<、>、& 與 U+2028、U+2029 也要一併轉義。更穩的方案是把資料放在 <script type="application/json"> 裡(內容不執行),再用 JSON.parse 讀取 textContent。